mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-06 04:22:11 -04:00
Compare commits
244 Commits
ac509933d7
...
v1.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03a8e1e0d5 | ||
|
|
4d4d3c20a1 | ||
|
|
cef16d6bc9 | ||
|
|
aafaad1791 | ||
|
|
7906fdc2b0 | ||
|
|
397650ca52 | ||
|
|
826207006a | ||
|
|
58c2fcd31c | ||
|
|
b2a2b425ec | ||
|
|
942c9c9609 | ||
|
|
46d6e1cff3 | ||
|
|
a4137c57c1 | ||
|
|
1ad8b627f1 | ||
|
|
58a02ce290 | ||
|
|
8e1ad1a2be | ||
|
|
68cd7ab32c | ||
|
|
f649ce9a8e | ||
|
|
c4df242f07 | ||
|
|
26846c8d55 | ||
|
|
31b44a667c | ||
|
|
4f3b73ee21 | ||
|
|
4cfae91f02 | ||
|
|
8d947a6e95 | ||
|
|
1e84d4252c | ||
|
|
76072e1d4c | ||
|
|
6408dce4a9 | ||
|
|
0b2e1cca38 | ||
|
|
c1bfd8c0b7 | ||
|
|
90ffa5833b | ||
|
|
169c669286 | ||
|
|
f8350deafc | ||
|
|
0286a1b80b | ||
|
|
7c3e6c1f02 | ||
|
|
d2d72db3c9 | ||
|
|
f81f861408 | ||
|
|
af494543f5 | ||
|
|
db4de55338 | ||
|
|
37ecbbbbde | ||
|
|
d6a6d2a438 | ||
|
|
bf1c6eec74 | ||
|
|
0ddae80584 | ||
|
|
5c96c03bfa | ||
|
|
dfe36e47d8 | ||
|
|
63e1b75e57 | ||
|
|
29efdd8598 | ||
|
|
34d03cf11b | ||
|
|
c339389d44 | ||
|
|
af5f6eb656 | ||
|
|
a6d28e2553 | ||
|
|
6213267908 | ||
|
|
d084114149 | ||
|
|
f6d99eca0d | ||
|
|
722eb3289e | ||
|
|
b7f2bdcb2d | ||
|
|
11c20db6e6 | ||
|
|
8a4e3f8bb1 | ||
|
|
bc8fe97c13 | ||
|
|
47262155aa | ||
|
|
dd4c41a6b2 | ||
|
|
92a25fdb6a | ||
|
|
d6650be008 | ||
|
|
2646e7b19a | ||
|
|
4133f11d82 | ||
|
|
22ed740394 | ||
|
|
063299a434 | ||
|
|
44d836c975 | ||
|
|
da437e77fb | ||
|
|
34a6bbfb32 | ||
|
|
9ed53bac9e | ||
|
|
3a6752c3d2 | ||
|
|
ef19568dd7 | ||
|
|
f280cd9d3b | ||
|
|
cf4ce3c476 | ||
|
|
ae59e53c4c | ||
|
|
7e0d661f63 | ||
|
|
0b33d3f905 | ||
|
|
d62bdda56b | ||
|
|
5841b38cd9 | ||
|
|
83e2b5a7a6 | ||
|
|
2f863f64ee | ||
|
|
1a8b397cfd | ||
|
|
196c421b75 | ||
|
|
8399d64c2d | ||
|
|
c530eab303 | ||
|
|
45b6362dd3 | ||
|
|
50b77dcfc3 | ||
|
|
be8f3adf01 | ||
|
|
75a8c171ea | ||
|
|
466ff59573 | ||
|
|
053bb91927 | ||
|
|
2c9b22c016 | ||
|
|
a9ee91586e | ||
|
|
81bce74612 | ||
|
|
f2a6d2c7da | ||
|
|
0a9a34912e | ||
|
|
abff670814 | ||
|
|
0d49acaaa8 | ||
|
|
ebe1785411 | ||
|
|
f9f0192b22 | ||
|
|
e5cdbf4cf5 | ||
|
|
13ef1efa7b | ||
|
|
fbd9301a2d | ||
|
|
24e3024b57 | ||
|
|
52d5af11ba | ||
|
|
44a45b00cf | ||
|
|
2b78fe5b9f | ||
|
|
14f92669c6 | ||
|
|
124106de87 | ||
|
|
bb8e0d384f | ||
|
|
59d37847ec | ||
|
|
acdc531dca | ||
|
|
ce75dac81b | ||
|
|
b8d40761ff | ||
|
|
3a7430f6da | ||
|
|
242660c51d | ||
|
|
8a6c1e45ce | ||
|
|
b8e5f9f3b1 | ||
|
|
d60e70f9cc | ||
|
|
cdb70fadb3 | ||
|
|
7867deef60 | ||
|
|
a77c1adb32 | ||
|
|
da14d75a3b | ||
|
|
7c66a34931 | ||
|
|
425715e0f0 | ||
|
|
a3baf8ce31 | ||
|
|
605e03b065 | ||
|
|
0e9b21d359 | ||
|
|
ba5bf0cabc | ||
|
|
96b9d7aab3 | ||
|
|
750e4c4527 | ||
|
|
7417e26444 | ||
|
|
00e1099912 | ||
|
|
bd46d29ff0 | ||
|
|
1a9d7684b9 | ||
|
|
0133c19276 | ||
|
|
46bb3b613b | ||
|
|
5839a5de30 | ||
|
|
535d0bb0f0 | ||
|
|
4d316007af | ||
|
|
3c2d60d8e1 | ||
|
|
9c4f4cbd0d | ||
|
|
a337585b00 | ||
|
|
1cdec5d687 | ||
|
|
081b15e24c | ||
|
|
b04cb7b3cc | ||
|
|
e2c3ff00fb | ||
|
|
c783ff3dcf | ||
|
|
2c360dc3e8 | ||
|
|
5342647bfb | ||
|
|
46a2f6f0d8 | ||
|
|
f8af8fc171 | ||
|
|
3d0ee9d72b | ||
|
|
5a0bb260b4 | ||
|
|
9a7f1f5f2f | ||
|
|
d88b04fc30 | ||
|
|
6fe4cc98b9 | ||
|
|
b9bcfd8d2c | ||
|
|
e3bd31bb52 | ||
|
|
0922e3e459 | ||
|
|
a168b12bb2 | ||
|
|
8c01deba86 | ||
|
|
b645487e79 | ||
|
|
91569affd7 | ||
|
|
1ed44ee6f3 | ||
|
|
fce120fa31 | ||
|
|
a02b0c0c3c | ||
|
|
c86999f389 | ||
|
|
2b546967d2 | ||
|
|
591d2ba4d4 | ||
|
|
37cc4ab197 | ||
|
|
d775974a90 | ||
|
|
cc62aa4a9e | ||
|
|
5b8b7b04be | ||
|
|
b4a8853591 | ||
|
|
4220dfe2a5 | ||
|
|
4557426c28 | ||
|
|
8ee7fe8e66 | ||
|
|
c4a41f994a | ||
|
|
fa639424f5 | ||
|
|
e618a8390c | ||
|
|
393e9ed2e4 | ||
|
|
e1ea441215 | ||
|
|
654661fd66 | ||
|
|
c5a21f8da0 | ||
|
|
ca5b168117 | ||
|
|
aa88eb42ee | ||
|
|
ac84cadd77 | ||
|
|
81d5235b9f | ||
|
|
8944762c76 | ||
|
|
3d05c34673 | ||
|
|
c2ee41c844 | ||
|
|
6b537f30a5 | ||
|
|
a3ae95df09 | ||
|
|
4349d68f87 | ||
|
|
7d5c20125a | ||
|
|
2583dbd3f2 | ||
|
|
a103b93583 | ||
|
|
fff018eafb | ||
|
|
60b824e7a4 | ||
|
|
e27e904157 | ||
|
|
fe15667986 | ||
|
|
bd9029e533 | ||
|
|
fa71d563ea | ||
|
|
143918bc5e | ||
|
|
961680af8c | ||
|
|
6e3b3ce888 | ||
|
|
44292c3b55 | ||
|
|
c024c1b8e4 | ||
|
|
13adfdec11 | ||
|
|
e9ec28aab7 | ||
|
|
0af4d1d6e3 | ||
|
|
3ef0e63533 | ||
|
|
f4dad69ccd | ||
|
|
b811316d0c | ||
|
|
f59aeb2782 | ||
|
|
24ce41935e | ||
|
|
3c4749ead0 | ||
|
|
22ab5b9660 | ||
|
|
22f16f1da3 | ||
|
|
a97409dfd7 | ||
|
|
eaa6a664c8 | ||
|
|
d934b3b3b4 | ||
|
|
546cbfb3ca | ||
|
|
39b70a53a0 | ||
|
|
795f84adce | ||
|
|
3d80a9dd0f | ||
|
|
9669e9bc87 | ||
|
|
5f2a5a5d7d | ||
|
|
ecfd721fc0 | ||
|
|
07242a00b3 | ||
|
|
4602442feb | ||
|
|
a90717b20c | ||
|
|
02edce2999 | ||
|
|
f2d9066f90 | ||
|
|
f6f7b1ed72 | ||
|
|
803bc1cb7f | ||
|
|
67d3aa9da3 | ||
|
|
9fbff5e833 | ||
|
|
c371140a97 | ||
|
|
c755a3719d | ||
|
|
4f153f3026 | ||
|
|
f2b1dbd256 | ||
|
|
be0ca993ff | ||
|
|
ed87e1b00b |
57
.agents/settings.local.json
Normal file
57
.agents/settings.local.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cat:*)",
|
||||
"Bash(git -C /home/purian23/dms diff --stat .github/workflows/)",
|
||||
"Bash(git -C /home/purian23/projects/danklinux diff --stat .github/workflows/)",
|
||||
"Bash(git -C /home/purian23/dms diff .github/workflows/)",
|
||||
"Bash(git -C /home/purian23/dms diff .github/workflows/run-ppa.yml)",
|
||||
"Bash(osc cat:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(git show-ref:*)",
|
||||
"Bash(git tag:*)",
|
||||
"Bash(bash -c 'ALL_PATHS=$(grep -A 5 \"\"<service name=\\\"\"download_url\\\"\">\"\" distro/debian/dms/_service | grep \"\"<param name=\\\"\"path\\\"\">\"\" | sed \"\"s/.*<param name=\\\"\"path\\\"\">\\(.*\\)<\\/param>.*/\\1/\"\"); SOURCE_PATH=\"\"\"\"; for path in $ALL_PATHS; do if echo \"\"$path\"\" | grep -qE \"\"(source|archive|\\.tar\\.(gz|xz|bz2))\"\" && ! echo \"\"$path\"\" | grep -qE \"\"(distropkg|binary)\"\"; then SOURCE_PATH=\"\"$path\"\"; break; fi; done; echo \"\"Selected path: $SOURCE_PATH\"\"')",
|
||||
"Bash(curl:*)",
|
||||
"Bash(tar:*)",
|
||||
"Bash(git -C /home/purian23/dms log:*)",
|
||||
"Bash(osc status:*)",
|
||||
"Bash(osc commit:*)",
|
||||
"Bash(osc up:*)",
|
||||
"Bash(osc results:*)",
|
||||
"Bash(osc api:*)",
|
||||
"Bash(systemctl:*)",
|
||||
"Bash(dms version:*)",
|
||||
"Bash(git describe:*)",
|
||||
"Bash(qmlsc:*)",
|
||||
"Bash(qmllint-qt6:*)",
|
||||
"Bash(make fmt:*)",
|
||||
"Bash(make test:*)",
|
||||
"Bash(dms chroma list-styles:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(time dms chroma:*)",
|
||||
"Bash(dms chroma:*)",
|
||||
"Bash(make build:*)",
|
||||
"Bash(pgrep:*)",
|
||||
"Bash(go build:*)",
|
||||
"Bash(/tmp/dms-test chroma:*)",
|
||||
"Bash(1)",
|
||||
"Bash(go install:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(journalctl:*)",
|
||||
"Bash(qdbus:*)",
|
||||
"Bash(TZ='Asia/Tokyo' date:*)",
|
||||
"Bash(dms --help:*)",
|
||||
"Bash(dms run:*)",
|
||||
"Bash(dms status:*)",
|
||||
"Bash(dms kill:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(qmlscene:*)",
|
||||
"Bash(quickshell --version:*)",
|
||||
"WebFetch(domain:forum.qt.io)",
|
||||
"Bash(gh api:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
71
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
71
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -8,31 +8,31 @@ body:
|
||||
value: |
|
||||
## DankMaterialShell Bug Report
|
||||
Limit your report to one issue per submission unless closely related
|
||||
- type: checkboxes
|
||||
- type: dropdown
|
||||
id: compositor
|
||||
attributes:
|
||||
label: Compositor
|
||||
options:
|
||||
- label: Niri
|
||||
- label: Hyprland
|
||||
- label: MangoWC (dwl)
|
||||
- label: Sway
|
||||
- Niri
|
||||
- Hyprland
|
||||
- MangoWC (dwl)
|
||||
- Sway
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
- type: dropdown
|
||||
id: distribution
|
||||
attributes:
|
||||
label: Distribution
|
||||
options:
|
||||
- label: Arch Linux
|
||||
- label: CachyOS
|
||||
- label: Fedora
|
||||
- label: NixOS
|
||||
- label: Debian
|
||||
- label: Ubuntu
|
||||
- label: Gentoo
|
||||
- label: OpenSUSE
|
||||
- label: Other (specify below)
|
||||
- Arch Linux
|
||||
- CachyOS
|
||||
- Fedora
|
||||
- NixOS
|
||||
- Debian
|
||||
- Ubuntu
|
||||
- Gentoo
|
||||
- OpenSUSE
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -42,12 +42,45 @@ body:
|
||||
placeholder: e.g., PikaOS, Void Linux, etc.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: installation_method
|
||||
attributes:
|
||||
label: Select your Installation Method
|
||||
options:
|
||||
- DankInstaller
|
||||
- Distro Packaging
|
||||
- Source
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: original_installation_method_different
|
||||
attributes:
|
||||
label: Was your original Installation method different?
|
||||
options:
|
||||
- "Yes"
|
||||
- No (specify below)
|
||||
default: 0
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: original_installation_method_specify
|
||||
attributes:
|
||||
label: If no, specify
|
||||
placeholder: e.g., Distro Packaging, then Source
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: dms_doctor
|
||||
attributes:
|
||||
label: dms doctor -v
|
||||
description: Output of `dms doctor -v` command
|
||||
placeholder: Paste the output of `dms doctor -v` here
|
||||
label: dms doctor -vC
|
||||
description: Output of `dms doctor -vC` command — paste between the lines below to keep it collapsed in the issue
|
||||
placeholder: Paste the output of `dms doctor -vC` here
|
||||
value: |
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
|
||||
</details>
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -69,7 +102,7 @@ body:
|
||||
- type: textarea
|
||||
id: steps_to_reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce & Installation Method
|
||||
label: Steps to Reproduce
|
||||
description: Please provide detailed steps to reproduce the issue
|
||||
placeholder: |
|
||||
1. ...
|
||||
|
||||
21
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
21
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -23,18 +23,25 @@ body:
|
||||
placeholder: Why is this feature important?
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
- type: dropdown
|
||||
id: compositor
|
||||
attributes:
|
||||
label: Compositor(s)
|
||||
description: Is this feature specific to one or more compositors?
|
||||
options:
|
||||
- label: All compositors
|
||||
- label: Niri
|
||||
- label: Hyprland
|
||||
- label: MangoWC (dwl)
|
||||
- label: Sway
|
||||
- label: Other (specify below)
|
||||
- All compositors
|
||||
- Niri
|
||||
- Hyprland
|
||||
- MangoWC (dwl)
|
||||
- Sway
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: compositor_other
|
||||
attributes:
|
||||
label: If Other, please specify
|
||||
placeholder: e.g., Wayfire, Mutter, etc.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
79
.github/ISSUE_TEMPLATE/support_request.yml
vendored
79
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -7,32 +7,87 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
## DankMaterialShell Support Request
|
||||
- type: checkboxes
|
||||
- type: dropdown
|
||||
id: compositor
|
||||
attributes:
|
||||
label: Compositor
|
||||
options:
|
||||
- label: Niri
|
||||
- label: Hyprland
|
||||
- label: MangoWC (dwl)
|
||||
- label: Sway
|
||||
- label: Other (specify below)
|
||||
- Niri
|
||||
- Hyprland
|
||||
- MangoWC (dwl)
|
||||
- Sway
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: compositor_other
|
||||
attributes:
|
||||
label: If Other, please specify
|
||||
placeholder: e.g., Wayfire, Mutter, etc.
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
- type: dropdown
|
||||
id: distribution
|
||||
attributes:
|
||||
label: Distribution
|
||||
description: Which Linux distribution are you using? (e.g., Arch, Fedora, Debian, etc.)
|
||||
placeholder: Your Linux distribution
|
||||
options:
|
||||
- Arch Linux
|
||||
- CachyOS
|
||||
- Fedora
|
||||
- NixOS
|
||||
- Debian
|
||||
- Ubuntu
|
||||
- Gentoo
|
||||
- OpenSUSE
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: distribution_other
|
||||
attributes:
|
||||
label: If Other, please specify
|
||||
placeholder: e.g., PikaOS, Void Linux, etc.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: installation_method
|
||||
attributes:
|
||||
label: Select your Installation Method
|
||||
options:
|
||||
- DankInstaller
|
||||
- Distro Packaging
|
||||
- Source
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: original_installation_method_different
|
||||
attributes:
|
||||
label: Was your original Installation method different?
|
||||
options:
|
||||
- "Yes"
|
||||
- No (specify below)
|
||||
default: 0
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: original_installation_method_specify
|
||||
attributes:
|
||||
label: If no, specify
|
||||
placeholder: e.g., Distro Packaging, then Source
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: dms_doctor
|
||||
attributes:
|
||||
label: dms doctor -v
|
||||
description: Output of `dms doctor -v` command
|
||||
placeholder: Paste the output of `dms doctor -v` here
|
||||
label: dms doctor -vC
|
||||
description: Output of `dms doctor -vC` command — paste between the lines below to keep it collapsed in the issue
|
||||
placeholder: Paste the output of `dms doctor -vC` here
|
||||
value: |
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
|
||||
</details>
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
383
.github/workflows/backup/run-obs.yml.bak
vendored
383
.github/workflows/backup/run-obs.yml.bak
vendored
@@ -1,383 +0,0 @@
|
||||
name: Update OBS Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to update (dms, dms-git, or all)"
|
||||
required: false
|
||||
default: "all"
|
||||
force_upload:
|
||||
description: "Force upload without version check"
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
rebuild_release:
|
||||
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
|
||||
required: false
|
||||
default: ""
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
schedule:
|
||||
- cron: "0 */3 * * *" # Every 3 hours for dms-git builds
|
||||
|
||||
jobs:
|
||||
check-updates:
|
||||
name: Check for updates
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
packages: ${{ steps.check.outputs.packages }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install OSC
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y osc
|
||||
|
||||
mkdir -p ~/.config/osc
|
||||
cat > ~/.config/osc/oscrc << EOF
|
||||
[general]
|
||||
apiurl = https://api.opensuse.org
|
||||
|
||||
[https://api.opensuse.org]
|
||||
user = ${{ secrets.OBS_USERNAME }}
|
||||
pass = ${{ secrets.OBS_PASSWORD }}
|
||||
EOF
|
||||
chmod 600 ~/.config/osc/oscrc
|
||||
|
||||
- name: Check for updates
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by tag: $VERSION (always update)"
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "Checking if dms-git source has changed..."
|
||||
|
||||
# Get current commit hash (8 chars to match spec format)
|
||||
CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
# Check OBS for last uploaded commit
|
||||
OBS_BASE="$HOME/.cache/osc-checkouts"
|
||||
mkdir -p "$OBS_BASE"
|
||||
OBS_PROJECT="home:AvengeMedia:dms-git"
|
||||
|
||||
if [[ -d "$OBS_BASE/$OBS_PROJECT/dms-git" ]]; then
|
||||
cd "$OBS_BASE/$OBS_PROJECT/dms-git"
|
||||
osc up -q 2>/dev/null || true
|
||||
|
||||
# Extract commit hash from spec Version line & format like; 0.6.2+git2264.a679be68
|
||||
if [[ -f "dms-git.spec" ]]; then
|
||||
OBS_COMMIT=$(grep "^Version:" "dms-git.spec" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
||||
|
||||
if [[ -n "$OBS_COMMIT" ]]; then
|
||||
if [[ "$CURRENT_COMMIT" == "$OBS_COMMIT" ]]; then
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 Commit $CURRENT_COMMIT already uploaded to OBS, skipping"
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 New commit detected: $CURRENT_COMMIT (OBS has $OBS_COMMIT)"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 Could not extract OBS commit, proceeding with update"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 No spec file in OBS, proceeding with update"
|
||||
fi
|
||||
|
||||
cd "${{ github.workspace }}"
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 First upload to OBS, update needed"
|
||||
fi
|
||||
elif [[ "${{ github.event.inputs.force_upload }}" == "true" ]]; then
|
||||
PKG="${{ github.event.inputs.package }}"
|
||||
if [[ -z "$PKG" || "$PKG" == "all" ]]; then
|
||||
echo "packages=all" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: all packages"
|
||||
else
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: $PKG"
|
||||
fi
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: ${{ github.event.inputs.package }}"
|
||||
else
|
||||
echo "packages=all" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
update-obs:
|
||||
name: Upload to OBS
|
||||
needs: check-updates
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: |
|
||||
github.event.inputs.force_upload == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.check-updates.outputs.has_updates == 'true'
|
||||
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Check if last commit was automated
|
||||
id: check-loop
|
||||
run: |
|
||||
LAST_COMMIT_MSG=$(git log -1 --pretty=%B | head -1)
|
||||
if [[ "$LAST_COMMIT_MSG" == "ci: Auto-update PPA packages"* ]] || [[ "$LAST_COMMIT_MSG" == "ci: Auto-update OBS packages"* ]]; then
|
||||
echo "⏭️ Last commit was automated ($LAST_COMMIT_MSG), skipping to prevent infinite loop"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "✅ Last commit was not automated, proceeding"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Determine packages to update
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: packages
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by tag: $VERSION"
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by schedule: updating git package"
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: ${{ github.event.inputs.package }}"
|
||||
else
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Update dms-git spec version
|
||||
if: steps.check-loop.outputs.skip != 'true' && (contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all')
|
||||
run: |
|
||||
# Get commit info for dms-git versioning
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "0.6.2")
|
||||
|
||||
NEW_VERSION="${BASE_VERSION}+git${COMMIT_COUNT}.${COMMIT_HASH}"
|
||||
echo "📦 Updating dms-git.spec to version: $NEW_VERSION"
|
||||
|
||||
# Update version in spec
|
||||
sed -i "s/^Version:.*/Version: $NEW_VERSION/" distro/opensuse/dms-git.spec
|
||||
|
||||
# Add changelog entry
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
CHANGELOG_ENTRY="* $DATE_STR Avenge Media <AvengeMedia.US@gmail.com> - ${NEW_VERSION}-1\n- Git snapshot (commit $COMMIT_COUNT: $COMMIT_HASH)"
|
||||
sed -i "/%changelog/a\\$CHANGELOG_ENTRY" distro/opensuse/dms-git.spec
|
||||
|
||||
- name: Update Debian dms-git changelog version
|
||||
if: steps.check-loop.outputs.skip != 'true' && (contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all')
|
||||
run: |
|
||||
# Get commit info for dms-git versioning
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "0.6.2")
|
||||
|
||||
# Debian version format: 0.6.2+git2256.9162e314
|
||||
NEW_VERSION="${BASE_VERSION}+git${COMMIT_COUNT}.${COMMIT_HASH}"
|
||||
echo "📦 Updating Debian dms-git changelog to version: $NEW_VERSION"
|
||||
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
|
||||
CHANGELOG_FILE="distro/debian/dms-git/debian/changelog"
|
||||
|
||||
# Get current version from changelog
|
||||
CURRENT_VERSION=$(head -1 "$CHANGELOG_FILE" | sed 's/.*(\([^)]*\)).*/\1/')
|
||||
|
||||
echo "Current Debian version: $CURRENT_VERSION"
|
||||
echo "New version: $NEW_VERSION"
|
||||
|
||||
# Only update if version changed
|
||||
if [ "$CURRENT_VERSION" != "$NEW_VERSION" ]; then
|
||||
# Create new changelog entry at top
|
||||
TEMP_CHANGELOG=$(mktemp)
|
||||
|
||||
cat > "$TEMP_CHANGELOG" << EOF
|
||||
dms-git ($NEW_VERSION) nightly; urgency=medium
|
||||
|
||||
* Git snapshot (commit $COMMIT_COUNT: $COMMIT_HASH)
|
||||
|
||||
-- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE
|
||||
|
||||
EOF
|
||||
|
||||
# Prepend to existing changelog
|
||||
cat "$CHANGELOG_FILE" >> "$TEMP_CHANGELOG"
|
||||
mv "$TEMP_CHANGELOG" "$CHANGELOG_FILE"
|
||||
|
||||
echo "✓ Updated Debian changelog: $CURRENT_VERSION → $NEW_VERSION"
|
||||
else
|
||||
echo "✓ Debian changelog already at version $NEW_VERSION"
|
||||
fi
|
||||
|
||||
- name: Update dms stable version
|
||||
if: steps.check-loop.outputs.skip != 'true' && steps.packages.outputs.version != ''
|
||||
run: |
|
||||
VERSION="${{ steps.packages.outputs.version }}"
|
||||
VERSION_NO_V="${VERSION#v}"
|
||||
echo "Updating packaging to version $VERSION_NO_V"
|
||||
|
||||
# Update openSUSE dms spec (stable only)
|
||||
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
|
||||
|
||||
# Update openSUSE spec changelog
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
CHANGELOG_ENTRY="* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1\\n- Update to stable $VERSION release\\n- Bug fixes and improvements"
|
||||
sed -i "/%changelog/a\\$CHANGELOG_ENTRY\\n" distro/opensuse/dms.spec
|
||||
|
||||
# Update Debian _service files (both tar_scm and download_url formats)
|
||||
for service in distro/debian/*/_service; do
|
||||
if [[ -f "$service" ]]; then
|
||||
# Update tar_scm revision parameter (for dms-git)
|
||||
sed -i "s|<param name=\"revision\">v[0-9.]*</param>|<param name=\"revision\">$VERSION</param>|" "$service"
|
||||
|
||||
# Update download_url paths (for dms stable)
|
||||
sed -i "s|/v[0-9.]\+/|/$VERSION/|g" "$service"
|
||||
sed -i "s|/tags/v[0-9.]\+\.tar\.gz|/tags/$VERSION.tar.gz|g" "$service"
|
||||
fi
|
||||
done
|
||||
|
||||
# Update Debian changelog for dms stable
|
||||
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
TEMP_CHANGELOG=$(mktemp)
|
||||
|
||||
cat > "$TEMP_CHANGELOG" << EOF
|
||||
dms ($VERSION_NO_V) stable; urgency=medium
|
||||
|
||||
* Update to $VERSION stable release
|
||||
* Bug fixes and improvements
|
||||
|
||||
-- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE
|
||||
|
||||
EOF
|
||||
|
||||
cat "distro/debian/dms/debian/changelog" >> "$TEMP_CHANGELOG"
|
||||
mv "$TEMP_CHANGELOG" "distro/debian/dms/debian/changelog"
|
||||
|
||||
echo "✓ Updated Debian changelog to $VERSION_NO_V"
|
||||
fi
|
||||
|
||||
- name: Install Go
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
|
||||
- name: Install OSC
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y osc
|
||||
|
||||
mkdir -p ~/.config/osc
|
||||
cat > ~/.config/osc/oscrc << EOF
|
||||
[general]
|
||||
apiurl = https://api.opensuse.org
|
||||
|
||||
[https://api.opensuse.org]
|
||||
user = ${{ secrets.OBS_USERNAME }}
|
||||
pass = ${{ secrets.OBS_PASSWORD }}
|
||||
EOF
|
||||
chmod 600 ~/.config/osc/oscrc
|
||||
|
||||
- name: Upload to OBS
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
env:
|
||||
FORCE_UPLOAD: ${{ github.event.inputs.force_upload }}
|
||||
REBUILD_RELEASE: ${{ github.event.inputs.rebuild_release }}
|
||||
run: |
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
MESSAGE="Automated update from GitHub Actions"
|
||||
|
||||
if [[ -n "${{ steps.packages.outputs.version }}" ]]; then
|
||||
MESSAGE="Update to ${{ steps.packages.outputs.version }}"
|
||||
fi
|
||||
|
||||
if [[ "$PACKAGES" == "all" ]]; then
|
||||
bash distro/scripts/obs-upload.sh dms "$MESSAGE"
|
||||
bash distro/scripts/obs-upload.sh dms-git "Automated git update"
|
||||
else
|
||||
bash distro/scripts/obs-upload.sh "$PACKAGES" "$MESSAGE"
|
||||
fi
|
||||
|
||||
- name: Get changed packages
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: changed-packages
|
||||
run: |
|
||||
# Check if there are any changes to commit
|
||||
if git diff --exit-code distro/debian/ distro/opensuse/ >/dev/null 2>&1; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 No changelog or spec changes to commit"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
# Get list of changed packages for commit message
|
||||
CHANGED_DEB=$(git diff --name-only distro/debian/ 2>/dev/null | grep 'debian/changelog' | xargs dirname 2>/dev/null | xargs dirname 2>/dev/null | xargs basename 2>/dev/null | tr '\n' ', ' | sed 's/, $//' || echo "")
|
||||
CHANGED_SUSE=$(git diff --name-only distro/opensuse/ 2>/dev/null | grep '\.spec$' | sed 's|distro/opensuse/||' | sed 's/\.spec$//' | tr '\n' ', ' | sed 's/, $//' || echo "")
|
||||
|
||||
PKGS=$(echo "$CHANGED_DEB,$CHANGED_SUSE" | tr ',' '\n' | grep -v '^$' | sort -u | tr '\n' ',' | sed 's/,$//')
|
||||
echo "packages=$PKGS" >> $GITHUB_OUTPUT
|
||||
echo "📋 Changed packages: $PKGS"
|
||||
fi
|
||||
|
||||
- name: Commit packaging changes
|
||||
if: steps.check-loop.outputs.skip != 'true' && steps.changed-packages.outputs.has_changes == 'true'
|
||||
run: |
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git add distro/debian/*/debian/changelog distro/opensuse/*.spec
|
||||
git commit -m "ci: Auto-update OBS packages [${{ steps.changed-packages.outputs.packages }}]" -m "🤖 Automated by GitHub Actions"
|
||||
git pull --rebase origin master
|
||||
git push
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### OBS Package Update Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Packages**: ${{ steps.packages.outputs.packages }}" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ -n "${{ steps.packages.outputs.version }}" ]]; then
|
||||
echo "- **Version**: ${{ steps.packages.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
if [[ "${{ needs.check-updates.outputs.has_updates }}" == "false" ]]; then
|
||||
echo "- **Status**: Skipped (no changes detected)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "- **Project**: https://build.opensuse.org/project/show/home:AvengeMedia" >> $GITHUB_STEP_SUMMARY
|
||||
298
.github/workflows/backup/run-ppa.yml.bak
vendored
298
.github/workflows/backup/run-ppa.yml.bak
vendored
@@ -1,298 +0,0 @@
|
||||
name: Update PPA Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to upload (dms, dms-git, dms-greeter, or all)"
|
||||
required: false
|
||||
default: "dms-git"
|
||||
force_upload:
|
||||
description: "Force upload without version check"
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
rebuild_release:
|
||||
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
|
||||
required: false
|
||||
default: ""
|
||||
schedule:
|
||||
- cron: "0 */3 * * *" # Every 3 hours for dms-git builds
|
||||
|
||||
jobs:
|
||||
check-updates:
|
||||
name: Check for updates
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
packages: ${{ steps.check.outputs.packages }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for updates
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "Checking if dms-git source has changed..."
|
||||
|
||||
# Get current commit hash (8 chars to match changelog format)
|
||||
CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
# Extract commit hash from changelog
|
||||
# Format: dms-git (0.6.2+git2264.c5c5ce84) questing; urgency=medium
|
||||
CHANGELOG_FILE="distro/ubuntu/dms-git/debian/changelog"
|
||||
|
||||
if [[ -f "$CHANGELOG_FILE" ]]; then
|
||||
CHANGELOG_COMMIT=$(head -1 "$CHANGELOG_FILE" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
||||
|
||||
if [[ -n "$CHANGELOG_COMMIT" ]]; then
|
||||
if [[ "$CURRENT_COMMIT" == "$CHANGELOG_COMMIT" ]]; then
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 Commit $CURRENT_COMMIT already in changelog, skipping upload"
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 New commit detected: $CURRENT_COMMIT (changelog has $CHANGELOG_COMMIT)"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 Could not extract commit from changelog, proceeding with upload"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 No changelog file found, proceeding with upload"
|
||||
fi
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: ${{ github.event.inputs.package }}"
|
||||
else
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
upload-ppa:
|
||||
name: Upload to PPA
|
||||
needs: check-updates
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: |
|
||||
github.event.inputs.force_upload == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.check-updates.outputs.has_updates == 'true'
|
||||
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Check if last commit was automated
|
||||
id: check-loop
|
||||
run: |
|
||||
LAST_COMMIT_MSG=$(git log -1 --pretty=%B | head -1)
|
||||
if [[ "$LAST_COMMIT_MSG" == "ci: Auto-update PPA packages"* ]] || [[ "$LAST_COMMIT_MSG" == "ci: Auto-update OBS packages"* ]]; then
|
||||
echo "⏭️ Last commit was automated ($LAST_COMMIT_MSG), skipping to prevent infinite loop"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "✅ Last commit was not automated, proceeding"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: false
|
||||
|
||||
- name: Install build dependencies
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
debhelper \
|
||||
devscripts \
|
||||
dput \
|
||||
lftp \
|
||||
build-essential \
|
||||
fakeroot \
|
||||
dpkg-dev
|
||||
|
||||
- name: Configure GPG
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
env:
|
||||
GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
run: |
|
||||
echo "$GPG_KEY" | gpg --import
|
||||
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
||||
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Determine packages to upload
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: packages
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.force_upload }}" == "true" ]]; then
|
||||
PKG="${{ github.event.inputs.package }}"
|
||||
if [[ -z "$PKG" || "$PKG" == "all" ]]; then
|
||||
echo "packages=all" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: all packages"
|
||||
else
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: $PKG"
|
||||
fi
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by schedule: uploading git package"
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
# Manual package selection should respect change detection
|
||||
SELECTED_PKG="${{ github.event.inputs.package }}"
|
||||
UPDATED_PKG="${{ needs.check-updates.outputs.packages }}"
|
||||
|
||||
# Check if manually selected package is in the updated list
|
||||
if [[ "$UPDATED_PKG" == *"$SELECTED_PKG"* ]] || [[ "$SELECTED_PKG" == "all" ]]; then
|
||||
echo "packages=$SELECTED_PKG" >> $GITHUB_OUTPUT
|
||||
echo "📦 Manual selection (has updates): $SELECTED_PKG"
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Manual selection '$SELECTED_PKG' has no updates - skipping (use force_upload to override)"
|
||||
fi
|
||||
else
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Upload to PPA
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
run: |
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
||||
|
||||
if [[ -z "$PACKAGES" ]]; then
|
||||
echo "No packages selected for upload. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Build command arguments
|
||||
BUILD_ARGS=()
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
BUILD_ARGS+=("$REBUILD_RELEASE")
|
||||
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
fi
|
||||
|
||||
if [[ "$PACKAGES" == "all" ]]; then
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading dms to PPA..."
|
||||
if [ -n "$REBUILD_RELEASE" ]; then
|
||||
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh dms dms questing "${BUILD_ARGS[@]}"
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading dms-git to PPA..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh dms-git dms-git questing "${BUILD_ARGS[@]}"
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading dms-greeter to PPA..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh dms-greeter danklinux questing "${BUILD_ARGS[@]}"
|
||||
else
|
||||
# Map package to PPA name
|
||||
case "$PACKAGES" in
|
||||
dms)
|
||||
PPA_NAME="dms"
|
||||
;;
|
||||
dms-git)
|
||||
PPA_NAME="dms-git"
|
||||
;;
|
||||
dms-greeter)
|
||||
PPA_NAME="danklinux"
|
||||
;;
|
||||
*)
|
||||
PPA_NAME="$PACKAGES"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading $PACKAGES to PPA..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh "$PACKAGES" "$PPA_NAME" questing "${BUILD_ARGS[@]}"
|
||||
fi
|
||||
|
||||
- name: Get changed packages
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: changed-packages
|
||||
run: |
|
||||
# Check if there are any changelog changes to commit
|
||||
if git diff --exit-code distro/ubuntu/ >/dev/null 2>&1; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 No changelog changes to commit"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
# Get list of changed packages for commit message (deduplicate)
|
||||
CHANGED=$(git diff --name-only distro/ubuntu/ | grep 'debian/changelog' | sed 's|/debian/changelog||' | xargs -I{} basename {} | sort -u | tr '\n' ',' | sed 's/,$//')
|
||||
echo "packages=$CHANGED" >> $GITHUB_OUTPUT
|
||||
echo "📋 Changed packages: $CHANGED"
|
||||
echo "📋 Debug - Changed files:"
|
||||
git diff --name-only distro/ubuntu/ | grep 'debian/changelog' || echo "No changelog files found"
|
||||
fi
|
||||
|
||||
- name: Commit changelog changes
|
||||
if: steps.check-loop.outputs.skip != 'true' && steps.changed-packages.outputs.has_changes == 'true'
|
||||
run: |
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git add distro/ubuntu/*/debian/changelog
|
||||
git commit -m "ci: Auto-update PPA packages [${{ steps.changed-packages.outputs.packages }}]" -m "🤖 Automated by GitHub Actions"
|
||||
git pull --rebase origin master
|
||||
git push
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### PPA Package Upload Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Packages**: ${{ steps.packages.outputs.packages }}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ needs.check-updates.outputs.has_updates }}" == "false" ]]; then
|
||||
echo "- **Status**: Skipped (no changes detected)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
if [[ "$PACKAGES" == "all" ]]; then
|
||||
echo "- **PPA dms**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **PPA dms-git**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **PPA danklinux**: https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "$PACKAGES" == "dms" ]]; then
|
||||
echo "- **PPA**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "$PACKAGES" == "dms-git" ]]; then
|
||||
echo "- **PPA**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "$PACKAGES" == "dms-greeter" ]]; then
|
||||
echo "- **PPA**: https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ -n "${{ steps.packages.outputs.version }}" ]]; then
|
||||
echo "- **Version**: ${{ steps.packages.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Builds will appear once Launchpad processes the uploads." >> $GITHUB_STEP_SUMMARY
|
||||
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@@ -191,6 +191,11 @@ jobs:
|
||||
git fetch origin --force tag ${TAG}
|
||||
git checkout ${TAG}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
- name: Download core artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -229,6 +234,7 @@ jobs:
|
||||
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
|
||||
- **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems
|
||||
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
|
||||
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
|
||||
- **`dms-qml.tar.gz`** - QML source code only
|
||||
|
||||
### Checksums
|
||||
@@ -387,6 +393,19 @@ jobs:
|
||||
rm -rf _temp_full
|
||||
done
|
||||
|
||||
- name: Generate vendored source tarball
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
VERSION_NUM=${TAG#v}
|
||||
cd core
|
||||
go mod vendor
|
||||
cd ..
|
||||
tar czf "_release_assets/dms-cli-${VERSION_NUM}.tar.gz" \
|
||||
--transform "s,^core/,dms-cli-${VERSION_NUM}/," \
|
||||
--exclude='core/.git' \
|
||||
core/
|
||||
(cd _release_assets && sha256sum "dms-cli-${VERSION_NUM}.tar.gz" > "dms-cli-${VERSION_NUM}.tar.gz.sha256")
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
2
.github/workflows/run-obs.yml
vendored
2
.github/workflows/run-obs.yml
vendored
@@ -335,7 +335,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
- name: Install OSC
|
||||
run: |
|
||||
|
||||
2
.github/workflows/run-ppa.yml
vendored
2
.github/workflows/run-ppa.yml
vendored
@@ -158,7 +158,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version-file: ./core/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Install build dependencies
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -56,6 +56,8 @@ UNUSED
|
||||
|
||||
CLAUDE-activeContext.md
|
||||
CLAUDE-temp.md
|
||||
AGENTS-activeContext.md
|
||||
AGENTS-temp.md
|
||||
|
||||
# Auto-generated theme files
|
||||
*.generated.*
|
||||
|
||||
@@ -5,11 +5,13 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: v0.10.0.1
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
args: [-e, SC2164, -e, SC2001, -e, SC2012, -e, SC2317]
|
||||
name: shellcheck
|
||||
entry: shellcheck -e SC2164 -e SC2001 -e SC2012 -e SC2317
|
||||
language: system
|
||||
types: [shell]
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: go-mod-tidy
|
||||
|
||||
@@ -22,7 +22,7 @@ nix develop
|
||||
|
||||
This will provide:
|
||||
|
||||
- Go 1.24 toolchain (go, gopls, delve, go-tools) and GNU Make
|
||||
- Go 1.25+ toolchain (go, gopls, delve, go-tools) and GNU Make
|
||||
- Quickshell and required QML packages
|
||||
- Properly configured QML2_IMPORT_PATH
|
||||
|
||||
@@ -37,10 +37,43 @@ This is a monorepo, the easiest thing to do is to open an editor in either `quic
|
||||
1. Install the [QML Extension](https://doc.qt.io/vscodeext/)
|
||||
2. Configure `ctrl+shift+p` -> user preferences (json) with qmlls path
|
||||
|
||||
**Note:** Paths may vary by distribution. Below are examples for Arch Linux and Fedora.
|
||||
|
||||
**Arch Linux:**
|
||||
|
||||
```json
|
||||
{
|
||||
"[qml]": {
|
||||
"editor.defaultFormatter": "qt-project.qmlls",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"qt-qml.doNotAskForQmllsDownload": true,
|
||||
"qt-qml.qmlls.customExePath": "/usr/lib/qt6/bin/qmlls"
|
||||
"qt-qml.qmlls.customExePath": "/usr/lib/qt6/bin/qmlls",
|
||||
"qt-core.additionalQtPaths": [
|
||||
{
|
||||
"name": "Qt-6.x-linux-g++",
|
||||
"path": "/usr/bin/qmake"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Fedora:**
|
||||
|
||||
```json
|
||||
{
|
||||
"[qml]": {
|
||||
"editor.defaultFormatter": "qt-project.qmlls",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"qt-qml.doNotAskForQmllsDownload": true,
|
||||
"qt-qml.qmlls.customExePath": "/usr/bin/qmlls",
|
||||
"qt-core.additionalQtPaths": [
|
||||
{
|
||||
"name": "Qt-6.x-Fedora-linux-g++",
|
||||
"path": "/usr/bin/qmake6"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
|
||||
|
||||
</div>
|
||||
|
||||
DankMaterialShell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
|
||||
DankMaterialShell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), [Miracle WM](https://github.com/miracle-wm-org/miracle-wm), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -105,7 +105,7 @@ Extend functionality with the [plugin registry](https://plugins.danklinux.com).
|
||||
|
||||
## Supported Compositors
|
||||
|
||||
Works best with [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [Sway](https://swaywm.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [labwc](https://labwc.github.io/), and [Scroll](https://github.com/dawsers/scroll) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features.
|
||||
Works best with [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [Sway](https://swaywm.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), and [Miracle WM](https://github.com/miracle-wm-org/miracle-wm) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features.
|
||||
|
||||
[Compositor configuration guide](https://danklinux.com/docs/dankmaterialshell/compositors)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v2.6.2
|
||||
rev: v2.9.0
|
||||
hooks:
|
||||
- id: golangci-lint-fmt
|
||||
require_serial: true
|
||||
|
||||
@@ -96,7 +96,7 @@ The on-screen preview displays the selected format. JSON output includes hex, RG
|
||||
|
||||
## Building
|
||||
|
||||
Requires Go 1.24+
|
||||
Requires Go 1.25+
|
||||
|
||||
**Development build:**
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ var clipClearCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var clipWatchStore bool
|
||||
var clipWatchMimes bool
|
||||
|
||||
var clipSearchCmd = &cobra.Command{
|
||||
Use: "search [query]",
|
||||
@@ -211,6 +212,7 @@ func init() {
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard tracking")
|
||||
|
||||
clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)")
|
||||
clipWatchCmd.Flags().BoolVarP(&clipWatchMimes, "mimes", "m", false, "Show all offered MIME types")
|
||||
|
||||
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
|
||||
|
||||
@@ -328,6 +330,30 @@ func runClipWatch(cmd *cobra.Command, args []string) {
|
||||
}); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
case clipWatchMimes:
|
||||
if err := clipboard.WatchAll(ctx, func(data []byte, mimeType string, allMimes []string) {
|
||||
if clipJSONOutput {
|
||||
out := map[string]any{
|
||||
"data": string(data),
|
||||
"mimeType": mimeType,
|
||||
"mimeTypes": allMimes,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"size": len(data),
|
||||
}
|
||||
j, _ := json.Marshal(out)
|
||||
fmt.Println(string(j))
|
||||
return
|
||||
}
|
||||
fmt.Printf("=== Clipboard Change ===\n")
|
||||
fmt.Printf("Selected: %s\n", mimeType)
|
||||
fmt.Printf("All MIME types:\n")
|
||||
for _, m := range allMimes {
|
||||
fmt.Printf(" - %s\n", m)
|
||||
}
|
||||
fmt.Printf("Size: %d bytes\n\n", len(data))
|
||||
}); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
case clipJSONOutput:
|
||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
out := map[string]any{
|
||||
|
||||
@@ -524,5 +524,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
chromaCmd,
|
||||
doctorCmd,
|
||||
configCmd,
|
||||
dlCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
@@ -101,11 +102,13 @@ var doctorCmd = &cobra.Command{
|
||||
var (
|
||||
doctorVerbose bool
|
||||
doctorJSON bool
|
||||
doctorCopy bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions")
|
||||
doctorCmd.Flags().BoolVarP(&doctorJSON, "json", "j", false, "Output results in JSON format")
|
||||
doctorCmd.Flags().BoolVarP(&doctorCopy, "copy", "C", false, "Copy results to clipboard in GitHub-friendly format")
|
||||
}
|
||||
|
||||
type category int
|
||||
@@ -192,7 +195,7 @@ func (r checkResult) toJSON() checkResultJSON {
|
||||
}
|
||||
|
||||
func runDoctor(cmd *cobra.Command, args []string) {
|
||||
if !doctorJSON {
|
||||
if !doctorJSON && !doctorCopy {
|
||||
printDoctorHeader()
|
||||
}
|
||||
|
||||
@@ -210,9 +213,17 @@ func runDoctor(cmd *cobra.Command, args []string) {
|
||||
checkEnvironmentVars(),
|
||||
)
|
||||
|
||||
if doctorJSON {
|
||||
switch {
|
||||
case doctorCopy:
|
||||
text := formatResultsPlain(results)
|
||||
if err := clipboard.CopyOpts([]byte(text), "text/plain;charset=utf-8", false, false); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to copy to clipboard: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Doctor report copied to clipboard")
|
||||
case doctorJSON:
|
||||
printResultsJSON(results)
|
||||
} else {
|
||||
default:
|
||||
printResults(results)
|
||||
printSummary(results, qsMissingFeatures)
|
||||
}
|
||||
@@ -638,6 +649,109 @@ func checkI2CAvailability() checkResult {
|
||||
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control", doctorDocsURL + "#optional-features"}
|
||||
}
|
||||
|
||||
func checkImageFormatPlugins() []checkResult {
|
||||
url := doctorDocsURL + "#optional-features"
|
||||
|
||||
pluginDir := findQtPluginDir()
|
||||
if pluginDir == "" {
|
||||
return []checkResult{
|
||||
{catOptionalFeatures, "qt6-imageformats", statusInfo, "Cannot detect (plugin dir not found)", "WebP, TIFF, JP2 support", url},
|
||||
{catOptionalFeatures, "kimageformats", statusInfo, "Cannot detect (plugin dir not found)", "AVIF, HEIF, JXL support", url},
|
||||
}
|
||||
}
|
||||
|
||||
imageFormatsDir := filepath.Join(pluginDir, "imageformats")
|
||||
|
||||
type pluginCheck struct {
|
||||
name string
|
||||
desc string
|
||||
plugins []struct{ file, format string }
|
||||
}
|
||||
|
||||
checks := []pluginCheck{
|
||||
{
|
||||
name: "qt6-imageformats",
|
||||
desc: "WebP, TIFF, GIF, JP2 support",
|
||||
plugins: []struct{ file, format string }{
|
||||
{"libqwebp.so", "WebP"},
|
||||
{"libqtiff.so", "TIFF"},
|
||||
{"libqgif.so", "GIF"},
|
||||
{"libqjp2.so", "JP2"},
|
||||
{"libqicns.so", "ICNS"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "kimageformats",
|
||||
desc: "AVIF, HEIF, JXL support",
|
||||
plugins: []struct{ file, format string }{
|
||||
{"kimg_avif.so", "AVIF"},
|
||||
{"kimg_heif.so", "HEIF"},
|
||||
{"kimg_jxl.so", "JXL"},
|
||||
{"kimg_exr.so", "EXR"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var results []checkResult
|
||||
for _, c := range checks {
|
||||
var found []string
|
||||
for _, p := range c.plugins {
|
||||
if _, err := os.Stat(filepath.Join(imageFormatsDir, p.file)); err == nil {
|
||||
found = append(found, p.format)
|
||||
}
|
||||
}
|
||||
|
||||
var result checkResult
|
||||
switch {
|
||||
case len(found) == 0:
|
||||
result = checkResult{catOptionalFeatures, c.name, statusWarn, "Not installed", c.desc, url}
|
||||
default:
|
||||
details := ""
|
||||
if doctorVerbose {
|
||||
details = fmt.Sprintf("Formats: %s (%s)", strings.Join(found, ", "), imageFormatsDir)
|
||||
}
|
||||
result = checkResult{catOptionalFeatures, c.name, statusOK, fmt.Sprintf("Installed (%d formats)", len(found)), details, url}
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func findQtPluginDir() string {
|
||||
// Check QT_PLUGIN_PATH env var first (used by NixOS and custom setups)
|
||||
if envPath := os.Getenv("QT_PLUGIN_PATH"); envPath != "" {
|
||||
for dir := range strings.SplitSeq(envPath, ":") {
|
||||
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try qtpaths
|
||||
for _, cmd := range []string{"qtpaths6", "qtpaths"} {
|
||||
if output, err := exec.Command(cmd, "-query", "QT_INSTALL_PLUGINS").Output(); err == nil {
|
||||
if dir := strings.TrimSpace(string(output)); dir != "" {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: common distro paths
|
||||
for _, dir := range []string{
|
||||
"/usr/lib/qt6/plugins",
|
||||
"/usr/lib64/qt6/plugins",
|
||||
"/usr/lib/x86_64-linux-gnu/qt6/plugins",
|
||||
"/usr/lib/aarch64-linux-gnu/qt6/plugins",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectNetworkBackend(stackResult *network.DetectResult) string {
|
||||
switch stackResult.Backend {
|
||||
case network.BackendNetworkManager:
|
||||
@@ -678,7 +792,21 @@ func checkOptionalDependencies() []checkResult {
|
||||
logindStatus, logindMsg := getOptionalDBusStatus("org.freedesktop.login1")
|
||||
results = append(results, checkResult{catOptionalFeatures, "logind", logindStatus, logindMsg, "Session management", optionalFeaturesURL})
|
||||
|
||||
cupsPkHelperBus := "org.opensuse.CupsPkHelper.Mechanism"
|
||||
var cupsPkStatus status
|
||||
var cupsPkMsg string
|
||||
switch {
|
||||
case utils.IsDBusServiceAvailable(cupsPkHelperBus):
|
||||
cupsPkStatus, cupsPkMsg = statusOK, "Running"
|
||||
case utils.IsDBusServiceActivatable(cupsPkHelperBus):
|
||||
cupsPkStatus, cupsPkMsg = statusOK, "Available"
|
||||
default:
|
||||
cupsPkStatus, cupsPkMsg = statusWarn, "Not available (install cups-pk-helper)"
|
||||
}
|
||||
results = append(results, checkResult{catOptionalFeatures, "cups-pk-helper", cupsPkStatus, cupsPkMsg, "Printer management", optionalFeaturesURL})
|
||||
|
||||
results = append(results, checkI2CAvailability())
|
||||
results = append(results, checkImageFormatPlugins()...)
|
||||
|
||||
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
||||
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
|
||||
@@ -929,3 +1057,36 @@ func printSummary(results []checkResult, qsMissingFeatures bool) {
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func formatResultsPlain(results []checkResult) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## DMS Doctor Report\n\n")
|
||||
|
||||
currentCategory := category(-1)
|
||||
for _, r := range results {
|
||||
if r.category != currentCategory {
|
||||
if currentCategory != -1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**%s**\n", r.category.String()))
|
||||
currentCategory = r.category
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("- [%s] %s: %s\n", r.status, r.name, r.message))
|
||||
|
||||
if doctorVerbose && r.details != "" {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", r.details))
|
||||
}
|
||||
}
|
||||
|
||||
var ds DoctorStatus
|
||||
for _, r := range results {
|
||||
ds.Add(r)
|
||||
}
|
||||
|
||||
sb.WriteString("\n---\n")
|
||||
sb.WriteString(fmt.Sprintf("**Summary:** %d error(s), %d warning(s), %d ok\n",
|
||||
ds.ErrorCount(), ds.WarningCount(), ds.OKCount()))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
99
core/cmd/dms/commands_download.go
Normal file
99
core/cmd/dms/commands_download.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var dlOutput string
|
||||
var dlUserAgent string
|
||||
var dlTimeout int
|
||||
var dlIPv4Only bool
|
||||
|
||||
var dlCmd = &cobra.Command{
|
||||
Use: "dl <url>",
|
||||
Short: "Download a URL to stdout or file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runDownload(args[0]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)")
|
||||
dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header")
|
||||
dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Request timeout in seconds")
|
||||
dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only")
|
||||
}
|
||||
|
||||
func runDownload(url string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(dlTimeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case dlUserAgent != "":
|
||||
req.Header.Set("User-Agent", dlUserAgent)
|
||||
default:
|
||||
req.Header.Set("User-Agent", "DankMaterialShell/1.0 (Linux)")
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
transport := &http.Transport{DialContext: dialer.DialContext}
|
||||
if dlIPv4Only {
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "tcp4", addr)
|
||||
}
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if dlOutput == "" {
|
||||
_, err = io.Copy(os.Stdout, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(dlOutput); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Create(dlOutput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create failed: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
os.Remove(dlOutput)
|
||||
return fmt.Errorf("write failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(dlOutput)
|
||||
return nil
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func installGreeter() error {
|
||||
}
|
||||
|
||||
fmt.Println("\nSynchronizing DMS configurations...")
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, ""); err != nil {
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -147,12 +147,30 @@ func syncGreeter() error {
|
||||
}
|
||||
fmt.Printf("✓ Found DMS at: %s\n", dmsPath)
|
||||
|
||||
if !isGreeterEnabled() {
|
||||
fmt.Println("\n⚠ DMS greeter is not enabled in greetd config.")
|
||||
fmt.Print("Would you like to enable it now? (Y/n): ")
|
||||
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
|
||||
if response != "n" && response != "no" {
|
||||
if err := enableGreeter(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("greeter must be enabled before syncing")
|
||||
}
|
||||
}
|
||||
|
||||
cacheDir := "/var/cache/dms-greeter"
|
||||
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("greeter cache directory not found at %s\nPlease install the greeter first", cacheDir)
|
||||
}
|
||||
|
||||
greeterGroupExists := checkGroupExists("greeter")
|
||||
greeterGroup := greeter.DetectGreeterGroup()
|
||||
greeterGroupExists := utils.HasGroup(greeterGroup)
|
||||
if greeterGroupExists {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
@@ -165,36 +183,59 @@ func syncGreeter() error {
|
||||
return fmt.Errorf("failed to check groups: %w", err)
|
||||
}
|
||||
|
||||
inGreeterGroup := strings.Contains(string(groupsOutput), "greeter")
|
||||
inGreeterGroup := strings.Contains(string(groupsOutput), greeterGroup)
|
||||
if !inGreeterGroup {
|
||||
fmt.Println("\n⚠ Warning: You are not in the greeter group.")
|
||||
fmt.Print("Would you like to add your user to the greeter group? (y/N): ")
|
||||
fmt.Printf("\n⚠ Warning: You are not in the %s group.\n", greeterGroup)
|
||||
fmt.Printf("Would you like to add your user to the %s group? (Y/n): ", greeterGroup)
|
||||
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
|
||||
if response == "y" || response == "yes" {
|
||||
fmt.Println("\nAdding user to greeter group...")
|
||||
addUserCmd := exec.Command("sudo", "usermod", "-aG", "greeter", currentUser.Username)
|
||||
if response != "n" && response != "no" {
|
||||
fmt.Printf("\nAdding user to %s group...\n", greeterGroup)
|
||||
addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username)
|
||||
addUserCmd.Stdout = os.Stdout
|
||||
addUserCmd.Stderr = os.Stderr
|
||||
if err := addUserCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to add user to greeter group: %w", err)
|
||||
return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err)
|
||||
}
|
||||
fmt.Println("✓ User added to greeter group")
|
||||
fmt.Printf("✓ User added to %s group\n", greeterGroup)
|
||||
fmt.Println("⚠ You will need to log out and back in for the group change to take effect")
|
||||
} else {
|
||||
return fmt.Errorf("aborted: user must be in the greeter group before syncing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compositor := detectConfiguredCompositor()
|
||||
if compositor == "" {
|
||||
compositors := greeter.DetectCompositors()
|
||||
switch len(compositors) {
|
||||
case 0:
|
||||
return fmt.Errorf("no supported compositors found")
|
||||
case 1:
|
||||
compositor = compositors[0]
|
||||
fmt.Printf("✓ Using compositor: %s\n", compositor)
|
||||
default:
|
||||
var err error
|
||||
compositor, err = promptCompositorChoice(compositors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ Selected compositor: %s\n", compositor)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("✓ Detected compositor from config: %s\n", compositor)
|
||||
}
|
||||
|
||||
fmt.Println("\nSetting up permissions and ACLs...")
|
||||
if err := greeter.SetupDMSGroup(logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\nSynchronizing DMS configurations...")
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, ""); err != nil {
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -205,21 +246,6 @@ func syncGreeter() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkGroupExists(groupName string) bool {
|
||||
data, err := os.ReadFile("/etc/group")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
lines := strings.SplitSeq(string(data), "\n")
|
||||
for line := range lines {
|
||||
if strings.HasPrefix(line, groupName+":") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func disableDisplayManager(dmName string) (bool, error) {
|
||||
state, err := getSystemdServiceState(dmName)
|
||||
if err != nil {
|
||||
@@ -351,7 +377,7 @@ func ensureGraphicalTarget() error {
|
||||
func handleConflictingDisplayManagers() error {
|
||||
fmt.Println("\n=== Checking for Conflicting Display Managers ===")
|
||||
|
||||
conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm"}
|
||||
conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
|
||||
|
||||
disabledAny := false
|
||||
var errors []string
|
||||
@@ -552,6 +578,39 @@ func enableGreeter() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isGreeterEnabled() bool {
|
||||
data, err := os.ReadFile("/etc/greetd/config.toml")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(data), "dms-greeter")
|
||||
}
|
||||
|
||||
func detectConfiguredCompositor() string {
|
||||
data, err := os.ReadFile("/etc/greetd/config.toml")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "command") || !strings.Contains(trimmed, "dms-greeter") {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(trimmed, "--command niri"):
|
||||
return "niri"
|
||||
case strings.Contains(trimmed, "--command hyprland"):
|
||||
return "hyprland"
|
||||
case strings.Contains(trimmed, "--command sway"):
|
||||
return "sway"
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func promptCompositorChoice(compositors []string) (string, error) {
|
||||
fmt.Println("\nMultiple compositors detected:")
|
||||
for i, comp := range compositors {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
|
||||
@@ -63,6 +64,7 @@ func init() {
|
||||
keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked")
|
||||
keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds")
|
||||
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
|
||||
keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)")
|
||||
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
|
||||
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
|
||||
|
||||
@@ -81,24 +83,35 @@ func init() {
|
||||
func initializeProviders() {
|
||||
registry := keybinds.GetDefaultRegistry()
|
||||
|
||||
hyprlandProvider := providers.NewHyprlandProvider("$HOME/.config/hypr")
|
||||
hyprlandProvider := providers.NewHyprlandProvider("")
|
||||
if err := registry.Register(hyprlandProvider); err != nil {
|
||||
log.Warnf("Failed to register Hyprland provider: %v", err)
|
||||
}
|
||||
|
||||
mangowcProvider := providers.NewMangoWCProvider("$HOME/.config/mango")
|
||||
mangowcProvider := providers.NewMangoWCProvider("")
|
||||
if err := registry.Register(mangowcProvider); err != nil {
|
||||
log.Warnf("Failed to register MangoWC provider: %v", err)
|
||||
}
|
||||
|
||||
scrollProvider := providers.NewSwayProvider("$HOME/.config/scroll")
|
||||
if err := registry.Register(scrollProvider); err != nil {
|
||||
log.Warnf("Failed to register Scroll provider: %v", err)
|
||||
configDir, _ := os.UserConfigDir()
|
||||
|
||||
if configDir != "" {
|
||||
scrollProvider := providers.NewSwayProvider(filepath.Join(configDir, "scroll"))
|
||||
if err := registry.Register(scrollProvider); err != nil {
|
||||
log.Warnf("Failed to register Scroll provider: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
swayProvider := providers.NewSwayProvider("$HOME/.config/sway")
|
||||
if err := registry.Register(swayProvider); err != nil {
|
||||
log.Warnf("Failed to register Sway provider: %v", err)
|
||||
miracleProvider := providers.NewMiracleProvider("")
|
||||
if err := registry.Register(miracleProvider); err != nil {
|
||||
log.Warnf("Failed to register Miracle WM provider: %v", err)
|
||||
}
|
||||
|
||||
if configDir != "" {
|
||||
swayProvider := providers.NewSwayProvider(filepath.Join(configDir, "sway"))
|
||||
if err := registry.Register(swayProvider); err != nil {
|
||||
log.Warnf("Failed to register Sway provider: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
niriProvider := providers.NewNiriProvider("")
|
||||
@@ -143,6 +156,8 @@ func makeProviderWithPath(name, path string) keybinds.Provider {
|
||||
return providers.NewSwayProvider(path)
|
||||
case "scroll":
|
||||
return providers.NewSwayProvider(path)
|
||||
case "miracle":
|
||||
return providers.NewMiracleProvider(path)
|
||||
case "niri":
|
||||
return providers.NewNiriProvider(path)
|
||||
default:
|
||||
@@ -212,6 +227,9 @@ func runKeybindsSet(cmd *cobra.Command, args []string) {
|
||||
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
||||
options["repeat"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
|
||||
options["allow-inhibiting"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
||||
options["flags"] = v
|
||||
}
|
||||
|
||||
@@ -13,16 +13,16 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ssOutputName string
|
||||
ssIncludeCursor bool
|
||||
ssFormat string
|
||||
ssQuality int
|
||||
ssOutputDir string
|
||||
ssFilename string
|
||||
ssNoClipboard bool
|
||||
ssNoFile bool
|
||||
ssNoNotify bool
|
||||
ssStdout bool
|
||||
ssOutputName string
|
||||
ssCursor string
|
||||
ssFormat string
|
||||
ssQuality int
|
||||
ssOutputDir string
|
||||
ssFilename string
|
||||
ssNoClipboard bool
|
||||
ssNoFile bool
|
||||
ssNoNotify bool
|
||||
ssStdout bool
|
||||
)
|
||||
|
||||
var screenshotCmd = &cobra.Command{
|
||||
@@ -52,7 +52,7 @@ Examples:
|
||||
dms screenshot last # Last region (pre-selected)
|
||||
dms screenshot --no-clipboard # Save file only
|
||||
dms screenshot --no-file # Clipboard only
|
||||
dms screenshot --cursor # Include cursor
|
||||
dms screenshot --cursor=on # Include cursor
|
||||
dms screenshot -f jpg -q 85 # JPEG with quality 85`,
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ var notifyActionCmd = &cobra.Command{
|
||||
|
||||
func init() {
|
||||
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssIncludeCursor, "cursor", false, "Include cursor in screenshot")
|
||||
screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
|
||||
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
|
||||
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
|
||||
screenshotCmd.PersistentFlags().StringVarP(&ssOutputDir, "dir", "d", "", "Output directory")
|
||||
@@ -136,7 +136,9 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
config := screenshot.DefaultConfig()
|
||||
config.Mode = mode
|
||||
config.OutputName = ssOutputName
|
||||
config.IncludeCursor = ssIncludeCursor
|
||||
if strings.EqualFold(ssCursor, "on") {
|
||||
config.Cursor = screenshot.CursorOn
|
||||
}
|
||||
config.Clipboard = !ssNoClipboard
|
||||
config.SaveFile = !ssNoFile
|
||||
config.Notify = !ssNoNotify
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -24,6 +26,243 @@ var setupCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var setupBindsCmd = &cobra.Command{
|
||||
Use: "binds",
|
||||
Short: "Deploy default keybinds config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("binds"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupLayoutCmd = &cobra.Command{
|
||||
Use: "layout",
|
||||
Short: "Deploy default layout config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("layout"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupColorsCmd = &cobra.Command{
|
||||
Use: "colors",
|
||||
Short: "Deploy default colors config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("colors"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupAlttabCmd = &cobra.Command{
|
||||
Use: "alttab",
|
||||
Short: "Deploy default alt-tab config (niri only)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("alttab"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupOutputsCmd = &cobra.Command{
|
||||
Use: "outputs",
|
||||
Short: "Deploy default outputs config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("outputs"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupCursorCmd = &cobra.Command{
|
||||
Use: "cursor",
|
||||
Short: "Deploy default cursor config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("cursor"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupWindowrulesCmd = &cobra.Command{
|
||||
Use: "windowrules",
|
||||
Short: "Deploy default window rules config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("windowrules"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type dmsConfigSpec struct {
|
||||
niriFile string
|
||||
hyprFile string
|
||||
niriContent func(terminal string) string
|
||||
hyprContent func(terminal string) string
|
||||
}
|
||||
|
||||
var dmsConfigSpecs = map[string]dmsConfigSpec{
|
||||
"binds": {
|
||||
niriFile: "binds.kdl",
|
||||
hyprFile: "binds.conf",
|
||||
niriContent: func(t string) string {
|
||||
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
||||
},
|
||||
hyprContent: func(t string) string {
|
||||
return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
||||
},
|
||||
},
|
||||
"layout": {
|
||||
niriFile: "layout.kdl",
|
||||
hyprFile: "layout.conf",
|
||||
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
||||
hyprContent: func(_ string) string { return config.HyprLayoutConfig },
|
||||
},
|
||||
"colors": {
|
||||
niriFile: "colors.kdl",
|
||||
hyprFile: "colors.conf",
|
||||
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
||||
hyprContent: func(_ string) string { return config.HyprColorsConfig },
|
||||
},
|
||||
"alttab": {
|
||||
niriFile: "alttab.kdl",
|
||||
niriContent: func(_ string) string { return config.NiriAlttabConfig },
|
||||
},
|
||||
"outputs": {
|
||||
niriFile: "outputs.kdl",
|
||||
hyprFile: "outputs.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return "" },
|
||||
},
|
||||
"cursor": {
|
||||
niriFile: "cursor.kdl",
|
||||
hyprFile: "cursor.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return "" },
|
||||
},
|
||||
"windowrules": {
|
||||
niriFile: "windowrules.kdl",
|
||||
hyprFile: "windowrules.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return "" },
|
||||
},
|
||||
}
|
||||
|
||||
func detectTerminal() (string, error) {
|
||||
terminals := []string{"ghostty", "foot", "kitty", "alacritty"}
|
||||
var found []string
|
||||
for _, t := range terminals {
|
||||
if utils.CommandExists(t) {
|
||||
found = append(found, t)
|
||||
}
|
||||
}
|
||||
|
||||
switch len(found) {
|
||||
case 0:
|
||||
return "ghostty", nil
|
||||
case 1:
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
fmt.Println("Multiple terminals detected:")
|
||||
for i, t := range found {
|
||||
fmt.Printf("%d) %s\n", i+1, t)
|
||||
}
|
||||
fmt.Printf("\nChoice (1-%d): ", len(found))
|
||||
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
choice := 0
|
||||
fmt.Sscanf(response, "%d", &choice)
|
||||
if choice < 1 || choice > len(found) {
|
||||
return "", fmt.Errorf("invalid choice")
|
||||
}
|
||||
return found[choice-1], nil
|
||||
}
|
||||
|
||||
func detectCompositorForSetup() (string, error) {
|
||||
compositors := greeter.DetectCompositors()
|
||||
|
||||
switch len(compositors) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)")
|
||||
case 1:
|
||||
return strings.ToLower(compositors[0]), nil
|
||||
}
|
||||
|
||||
selected, err := greeter.PromptCompositorChoice(compositors)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(selected), nil
|
||||
}
|
||||
|
||||
func runSetupDmsConfig(name string) error {
|
||||
spec, ok := dmsConfigSpecs[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown config: %s", name)
|
||||
}
|
||||
|
||||
compositor, err := detectCompositorForSetup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filename string
|
||||
var contentFn func(string) string
|
||||
switch compositor {
|
||||
case "niri":
|
||||
filename = spec.niriFile
|
||||
contentFn = spec.niriContent
|
||||
case "hyprland":
|
||||
filename = spec.hyprFile
|
||||
contentFn = spec.hyprContent
|
||||
default:
|
||||
return fmt.Errorf("unsupported compositor: %s", compositor)
|
||||
}
|
||||
|
||||
if filename == "" {
|
||||
return fmt.Errorf("%s is not supported for %s", name, compositor)
|
||||
}
|
||||
|
||||
var dmsDir string
|
||||
switch compositor {
|
||||
case "niri":
|
||||
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms")
|
||||
case "hyprland":
|
||||
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create dms directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dmsDir, filename)
|
||||
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||
return fmt.Errorf("%s already exists and is not empty: %s", name, path)
|
||||
}
|
||||
|
||||
terminal := "ghostty"
|
||||
if contentFn != nil && name == "binds" {
|
||||
terminal, err = detectTerminal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
content := contentFn(terminal)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", filename, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Deployed %s to %s\n", name, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSetup() error {
|
||||
fmt.Println("=== DMS Configuration Setup ===")
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ var windowrulesListCmd = &cobra.Command{
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -40,7 +40,8 @@ var windowrulesAddCmd = &cobra.Command{
|
||||
Args: cobra.ExactArgs(2),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
// ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -54,7 +55,7 @@ var windowrulesUpdateCmd = &cobra.Command{
|
||||
Args: cobra.ExactArgs(3),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -68,7 +69,7 @@ var windowrulesRemoveCmd = &cobra.Command{
|
||||
Args: cobra.ExactArgs(2),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -82,7 +83,7 @@ var windowrulesReorderCmd = &cobra.Command{
|
||||
Args: cobra.ExactArgs(2),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -117,9 +118,9 @@ func getCompositor(args []string) string {
|
||||
if os.Getenv("NIRI_SOCKET") != "" {
|
||||
return "niri"
|
||||
}
|
||||
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
||||
return "hyprland"
|
||||
}
|
||||
// if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
||||
// return "hyprland"
|
||||
// }
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -182,6 +183,7 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
|
||||
result.DMSStatus = parseResult.DMSStatus
|
||||
|
||||
case "hyprland":
|
||||
log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland
|
||||
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to expand hyprland config path: %v", err)
|
||||
|
||||
@@ -19,6 +19,9 @@ func init() {
|
||||
// Add subcommands to greeter
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
||||
|
||||
// Add subcommands to setup
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
|
||||
// Add subcommands to update
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ func init() {
|
||||
// Add subcommands to greeter
|
||||
greeterCmd.AddCommand(greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
||||
|
||||
// Add subcommands to setup
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
|
||||
// Add subcommands to plugins
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ func runShellInteractive(session bool) {
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
||||
}
|
||||
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland")
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||
}
|
||||
|
||||
cmd.Stdin = os.Stdin
|
||||
@@ -450,7 +450,7 @@ func runShellDaemon(session bool) {
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
||||
}
|
||||
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland")
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||
}
|
||||
|
||||
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
||||
|
||||
39
core/go.mod
39
core/go.mod
@@ -1,11 +1,11 @@
|
||||
module github.com/AvengeMedia/DankMaterialShell/core
|
||||
|
||||
go 1.24.6
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
@@ -19,44 +19,43 @@ require (
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
|
||||
golang.org/x/image v0.35.0
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
|
||||
golang.org/x/image v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.8.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.4 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -70,7 +69,11 @@ require (
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/text v0.33.0
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/text v0.34.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
// v0.0.1 tag is missing a LICENSE file; master has it.
|
||||
// See: https://github.com/mattn/go-localereader/issues/2
|
||||
replace github.com/mattn/go-localereader v0.0.1 => github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75
|
||||
|
||||
70
core/go.sum
70
core/go.sum
@@ -20,30 +20,28 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
|
||||
github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.8.0 h1:/z8v+H+4XLluJKS7rAc7uHZTalT5Z+1430ld3lePSRI=
|
||||
github.com/clipperhouse/displaywidth v0.8.0/go.mod h1:UpOXiIKep+TohQYwvAAM/VDU8v3Z5rnWTxiwueR0XvQ=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
|
||||
github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -66,12 +64,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc h1:rhkjrnRkamkRC7woapp425E4CAH6RPcqsS9X8LA93IY=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc/go.mod h1:X1oe0Z2qMsa9hkar3AAPuL9hu4Mi3ztXEjdqRhr6fcc=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9 h1:VzdR70t+SMjYnBgnbtNpq4ElZAAovLPMG+GFX8OBRtM=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9/go.mod h1:EWlxLBkiFCzXNCadvt05fT9PCAE2sUedgDsvUUIo18s=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -88,8 +86,8 @@ github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvE
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -103,8 +101,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
@@ -152,24 +150,24 @@ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.m
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
)
|
||||
|
||||
type ClipboardChange struct {
|
||||
Data []byte
|
||||
MimeType string
|
||||
Data []byte
|
||||
MimeType string
|
||||
MimeTypes []string
|
||||
}
|
||||
|
||||
func Watch(ctx context.Context, callback func(data []byte, mimeType string)) error {
|
||||
@@ -141,6 +142,131 @@ func Watch(ctx context.Context, callback func(data []byte, mimeType string)) err
|
||||
}
|
||||
}
|
||||
|
||||
func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, allMimeTypes []string)) error {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
defer display.Destroy()
|
||||
|
||||
wlCtx := 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(wlCtx)
|
||||
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(wlCtx)
|
||||
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()
|
||||
|
||||
offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string)
|
||||
|
||||
device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
|
||||
if e.Id == nil {
|
||||
return
|
||||
}
|
||||
offerMimeTypes[e.Id] = nil
|
||||
e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) {
|
||||
offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType)
|
||||
})
|
||||
})
|
||||
|
||||
device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
|
||||
if e.Id == nil {
|
||||
return
|
||||
}
|
||||
|
||||
mimes := offerMimeTypes[e.Id]
|
||||
selectedMime := selectPreferredMimeType(mimes)
|
||||
if selectedMime == "" {
|
||||
return
|
||||
}
|
||||
|
||||
mimesCopy := make([]string, len(mimes))
|
||||
copy(mimesCopy, mimes)
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil {
|
||||
w.Close()
|
||||
r.Close()
|
||||
return
|
||||
}
|
||||
w.Close()
|
||||
|
||||
go func() {
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
callback(data, selectedMime, mimesCopy)
|
||||
}()
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
|
||||
return fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
if err := wlCtx.Dispatch(); err != nil {
|
||||
if isTimeoutError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("dispatch: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeoutError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -644,7 +644,7 @@ func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalC
|
||||
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") {
|
||||
startupSectionFound = true
|
||||
result = append(result, "exec-once = dms run")
|
||||
result = append(result, "env = QT_QPA_PLATFORM,wayland")
|
||||
result = append(result, "env = QT_QPA_PLATFORM,wayland;xcb")
|
||||
result = append(result, "env = ELECTRON_OZONE_PLATFORM_HINT,auto")
|
||||
result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3")
|
||||
result = append(result, "env = QT_QPA_PLATFORMTHEME_QT6,gtk3")
|
||||
@@ -659,7 +659,7 @@ func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalC
|
||||
if strings.Contains(line, "STARTUP APPS") {
|
||||
insertLines := []string{
|
||||
"exec-once = dms run",
|
||||
"env = QT_QPA_PLATFORM,wayland",
|
||||
"env = QT_QPA_PLATFORM,wayland;xcb",
|
||||
"env = ELECTRON_OZONE_PLATFORM_HINT,auto",
|
||||
"env = QT_QPA_PLATFORMTHEME,gtk3",
|
||||
"env = QT_QPA_PLATFORMTHEME_QT6,gtk3",
|
||||
@@ -677,7 +677,7 @@ func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalC
|
||||
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
|
||||
envVars := fmt.Sprintf(`environment {
|
||||
XDG_CURRENT_DESKTOP "niri"
|
||||
QT_QPA_PLATFORM "wayland"
|
||||
QT_QPA_PLATFORM "wayland;xcb"
|
||||
ELECTRON_OZONE_PLATFORM_HINT "auto"
|
||||
QT_QPA_PLATFORMTHEME "gtk3"
|
||||
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
|
||||
|
||||
@@ -27,6 +27,8 @@ bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
|
||||
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
|
||||
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
|
||||
bindl = , XF86AudioNext, exec, dms ipc call mpris next
|
||||
bindel = CTRL, XF86AudioRaiseVolume, exec, dms ipc call mpris increment 3
|
||||
bindel = CTRL, XF86AudioLowerVolume, exec, dms ipc call mpris decrement 3
|
||||
|
||||
# === Brightness Controls ===
|
||||
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
|
||||
|
||||
@@ -98,9 +98,11 @@ windowrule = float on, match:class ^(gnome-calculator)$
|
||||
windowrule = float on, match:class ^(galculator)$
|
||||
windowrule = float on, match:class ^(blueman-manager)$
|
||||
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
|
||||
windowrule = float on, match:class ^(steam)$
|
||||
windowrule = float on, match:class ^(xdg-desktop-portal)$
|
||||
|
||||
windowrule = no_initial_focus on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
||||
windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
||||
|
||||
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
|
||||
windowrule = float on, match:class ^(zoom)$
|
||||
|
||||
@@ -109,6 +111,7 @@ windowrule = float on, match:class ^(zoom)$
|
||||
# windowrule = float on, match:class ^(org.quickshell)$
|
||||
|
||||
layerrule = no_anim on, match:namespace ^(quickshell)$
|
||||
layerrule = no_anim on, match:namespace ^dms:.*
|
||||
|
||||
source = ./dms/colors.conf
|
||||
source = ./dms/outputs.conf
|
||||
|
||||
@@ -60,6 +60,12 @@ binds {
|
||||
XF86AudioNext allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "next";
|
||||
}
|
||||
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "increment" "3";
|
||||
}
|
||||
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
|
||||
}
|
||||
|
||||
// === Brightness Controls ===
|
||||
XF86MonBrightnessUp allow-when-locked=true {
|
||||
|
||||
17
core/internal/config/embedded/niri-greeter.kdl
Normal file
17
core/internal/config/embedded/niri-greeter.kdl
Normal file
@@ -0,0 +1,17 @@
|
||||
hotkey-overlay {
|
||||
skip-at-startup
|
||||
}
|
||||
|
||||
environment {
|
||||
DMS_RUN_GREETER "1"
|
||||
}
|
||||
|
||||
gestures {
|
||||
hot-corners {
|
||||
off
|
||||
}
|
||||
}
|
||||
|
||||
layout {
|
||||
background-color "#000000"
|
||||
}
|
||||
@@ -228,10 +228,14 @@ window-rule {
|
||||
match app-id=r#"^galculator$"#
|
||||
match app-id=r#"^blueman-manager$"#
|
||||
match app-id=r#"^org\.gnome\.Nautilus$"#
|
||||
match app-id=r#"^steam$"#
|
||||
match app-id=r#"^xdg-desktop-portal$"#
|
||||
open-floating true
|
||||
}
|
||||
window-rule {
|
||||
match app-id=r#"^steam$"# title=r#"^notificationtoasts_\d+_desktop$"#
|
||||
default-floating-position x=10 y=10 relative-to="bottom-right"
|
||||
open-focused false
|
||||
}
|
||||
window-rule {
|
||||
match app-id=r#"^org\.wezfurlong\.wezterm$"#
|
||||
match app-id="Alacritty"
|
||||
|
||||
@@ -16,3 +16,6 @@ var NiriAlttabConfig string
|
||||
|
||||
//go:embed embedded/niri-binds.kdl
|
||||
var NiriBindsConfig string
|
||||
|
||||
//go:embed embedded/niri-greeter.kdl
|
||||
var NiriGreeterConfig string
|
||||
|
||||
@@ -26,6 +26,9 @@ func init() {
|
||||
Register("cachyos", "#08A283", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||
return NewArchDistribution(config, logChan)
|
||||
})
|
||||
Register("catos", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||
return NewArchDistribution(config, logChan)
|
||||
})
|
||||
Register("endeavouros", "#7F3FBF", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||
return NewArchDistribution(config, logChan)
|
||||
})
|
||||
|
||||
@@ -430,7 +430,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
||||
}
|
||||
|
||||
// Add repository
|
||||
repoLine := fmt.Sprintf("deb [signed-by=%s, arch=%s] %s/ /", keyringPath, runtime.GOARCH, baseURL)
|
||||
repoLine := fmt.Sprintf("deb [signed-by=%s arch=%s] %s/ /", keyringPath, runtime.GOARCH, baseURL)
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
|
||||
@@ -8,10 +8,13 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/sblinch/kdl-go"
|
||||
"github.com/sblinch/kdl-go/document"
|
||||
)
|
||||
|
||||
// DetectDMSPath checks for DMS installation following XDG Base Directory specification
|
||||
@@ -19,6 +22,21 @@ func DetectDMSPath() (string, error) {
|
||||
return config.LocateDMSConfig()
|
||||
}
|
||||
|
||||
func DetectGreeterGroup() string {
|
||||
data, err := os.ReadFile("/etc/group")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "⚠ Warning: could not read /etc/group, defaulting to greeter")
|
||||
return "greeter"
|
||||
}
|
||||
|
||||
if group, found := utils.FindGroupData(string(data), "greeter", "greetd", "_greeter"); found {
|
||||
return group
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "⚠ Warning: no greeter group found in /etc/group, defaulting to greeter")
|
||||
return "greeter"
|
||||
}
|
||||
|
||||
// DetectCompositors checks which compositors are installed
|
||||
func DetectCompositors() []string {
|
||||
var compositors []string
|
||||
@@ -191,14 +209,17 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
if err := runSudoCmd(sudoPassword, "chown", "greeter:greeter", cacheDir); err != nil {
|
||||
group := DetectGreeterGroup()
|
||||
owner := fmt.Sprintf("%s:%s", group, group)
|
||||
|
||||
if err := runSudoCmd(sudoPassword, "chown", owner, cacheDir); err != nil {
|
||||
return fmt.Errorf("failed to set cache directory owner: %w", err)
|
||||
}
|
||||
|
||||
if err := runSudoCmd(sudoPassword, "chmod", "750", cacheDir); err != nil {
|
||||
if err := runSudoCmd(sudoPassword, "chmod", "755", cacheDir); err != nil {
|
||||
return fmt.Errorf("failed to set cache directory permissions: %w", err)
|
||||
}
|
||||
logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: greeter:greeter, permissions: 750)", cacheDir))
|
||||
logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: %s, permissions: 755)", cacheDir, owner))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -231,6 +252,8 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
|
||||
{filepath.Join(homeDir, ".local", "share"), ".local/share directory"},
|
||||
}
|
||||
|
||||
owner := DetectGreeterGroup()
|
||||
|
||||
logFunc("\nSetting up parent directory ACLs for greeter user access...")
|
||||
|
||||
for _, dir := range parentDirs {
|
||||
@@ -242,9 +265,9 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
|
||||
}
|
||||
|
||||
// Set ACL to allow greeter user read+execute permission (for session discovery)
|
||||
if err := runSudoCmd(sudoPassword, "setfacl", "-m", "u:greeter:rx", dir.path); err != nil {
|
||||
if err := runSudoCmd(sudoPassword, "setfacl", "-m", fmt.Sprintf("u:%s:rx", owner), dir.path); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err))
|
||||
logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m u:greeter:x %s", dir.path))
|
||||
logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m u:%s:x %s", owner, dir.path))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -268,17 +291,19 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
||||
return fmt.Errorf("failed to determine current user")
|
||||
}
|
||||
|
||||
group := DetectGreeterGroup()
|
||||
|
||||
// Check if user is already in greeter group
|
||||
groupsCmd := exec.Command("groups", currentUser)
|
||||
groupsOutput, err := groupsCmd.Output()
|
||||
if err == nil && strings.Contains(string(groupsOutput), "greeter") {
|
||||
logFunc(fmt.Sprintf("✓ %s is already in greeter group", currentUser))
|
||||
if err == nil && strings.Contains(string(groupsOutput), group) {
|
||||
logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group))
|
||||
} else {
|
||||
// Add current user to greeter group for file access permissions
|
||||
if err := runSudoCmd(sudoPassword, "usermod", "-aG", "greeter", currentUser); err != nil {
|
||||
return fmt.Errorf("failed to add %s to greeter group: %w", currentUser, err)
|
||||
if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, currentUser); err != nil {
|
||||
return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err)
|
||||
}
|
||||
logFunc(fmt.Sprintf("✓ Added %s to greeter group (logout/login required for changes to take effect)", currentUser))
|
||||
logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group))
|
||||
}
|
||||
|
||||
configDirs := []struct {
|
||||
@@ -301,7 +326,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := runSudoCmd(sudoPassword, "chgrp", "-R", "greeter", dir.path); err != nil {
|
||||
if err := runSudoCmd(sudoPassword, "chgrp", "-R", group, dir.path); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err))
|
||||
continue
|
||||
}
|
||||
@@ -322,7 +347,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func SyncDMSConfigs(dmsPath string, logFunc func(string), sudoPassword string) error {
|
||||
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
@@ -378,9 +403,351 @@ func SyncDMSConfigs(dmsPath string, logFunc func(string), sudoPassword string) e
|
||||
logFunc(fmt.Sprintf("✓ Synced %s", link.desc))
|
||||
}
|
||||
|
||||
if strings.ToLower(compositor) != "niri" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := syncNiriGreeterConfig(logFunc, sudoPassword); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to sync niri greeter config: %v", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type niriGreeterSync struct {
|
||||
processed map[string]bool
|
||||
nodes []*document.Node
|
||||
inputCount int
|
||||
outputCount int
|
||||
cursorCount int
|
||||
debugCount int
|
||||
cursorNode *document.Node
|
||||
}
|
||||
|
||||
func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve user config directory: %w", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "niri", "config.kdl")
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
logFunc("ℹ Niri config not found; skipping greeter niri sync")
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to stat niri config: %w", err)
|
||||
}
|
||||
|
||||
extractor := &niriGreeterSync{
|
||||
processed: make(map[string]bool),
|
||||
}
|
||||
|
||||
if err := extractor.processFile(configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(extractor.nodes) == 0 {
|
||||
logFunc("ℹ No niri input/output sections found; skipping greeter niri sync")
|
||||
return nil
|
||||
}
|
||||
|
||||
content := extractor.render()
|
||||
if strings.TrimSpace(content) == "" {
|
||||
logFunc("ℹ No niri input/output content to sync; skipping greeter niri sync")
|
||||
return nil
|
||||
}
|
||||
|
||||
greeterDir := "/etc/greetd/niri"
|
||||
greeterGroup := DetectGreeterGroup()
|
||||
if err := runSudoCmd(sudoPassword, "mkdir", "-p", greeterDir); err != nil {
|
||||
return fmt.Errorf("failed to create greetd niri directory: %w", err)
|
||||
}
|
||||
if err := runSudoCmd(sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil {
|
||||
return fmt.Errorf("failed to set greetd niri directory ownership: %w", err)
|
||||
}
|
||||
if err := runSudoCmd(sudoPassword, "chmod", "755", greeterDir); err != nil {
|
||||
return fmt.Errorf("failed to set greetd niri directory permissions: %w", err)
|
||||
}
|
||||
|
||||
dmsTemp, err := os.CreateTemp("", "dms-greeter-niri-dms-*.kdl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(dmsTemp.Name())
|
||||
|
||||
if _, err := dmsTemp.WriteString(content); err != nil {
|
||||
_ = dmsTemp.Close()
|
||||
return fmt.Errorf("failed to write temp niri config: %w", err)
|
||||
}
|
||||
if err := dmsTemp.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp niri config: %w", err)
|
||||
}
|
||||
|
||||
dmsPath := filepath.Join(greeterDir, "dms.kdl")
|
||||
if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil {
|
||||
return fmt.Errorf("failed to backup %s: %w", dmsPath, err)
|
||||
}
|
||||
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil {
|
||||
return fmt.Errorf("failed to install greetd niri dms config: %w", err)
|
||||
}
|
||||
|
||||
mainContent := fmt.Sprintf("%s\ninclude \"%s\"\n", config.NiriGreeterConfig, dmsPath)
|
||||
mainTemp, err := os.CreateTemp("", "dms-greeter-niri-main-*.kdl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(mainTemp.Name())
|
||||
|
||||
if _, err := mainTemp.WriteString(mainContent); err != nil {
|
||||
_ = mainTemp.Close()
|
||||
return fmt.Errorf("failed to write temp niri main config: %w", err)
|
||||
}
|
||||
if err := mainTemp.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp niri main config: %w", err)
|
||||
}
|
||||
|
||||
mainPath := filepath.Join(greeterDir, "config.kdl")
|
||||
if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil {
|
||||
return fmt.Errorf("failed to backup %s: %w", mainPath, err)
|
||||
}
|
||||
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil {
|
||||
return fmt.Errorf("failed to install greetd niri main config: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureGreetdNiriConfig(logFunc, sudoPassword, mainPath); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to update greetd config for niri: %v", err))
|
||||
}
|
||||
|
||||
logFunc(fmt.Sprintf("✓ Synced niri greeter config (%d input, %d output, %d cursor, %d debug) to %s", extractor.inputCount, extractor.outputCount, extractor.cursorCount, extractor.debugCount, dmsPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfigPath string) error {
|
||||
configPath := "/etc/greetd/config.toml"
|
||||
data, err := os.ReadFile(configPath)
|
||||
if os.IsNotExist(err) {
|
||||
logFunc("ℹ greetd config not found; skipping niri config wiring")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read greetd config: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
updated := false
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "command") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
command := strings.Trim(strings.TrimSpace(parts[1]), "\"")
|
||||
if !strings.Contains(command, "dms-greeter") {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(command, "--command niri") {
|
||||
continue
|
||||
}
|
||||
// Strip existing -C or --config and their arguments
|
||||
command = stripConfigFlag(command)
|
||||
|
||||
newCommand := fmt.Sprintf("%s -C %s", command, niriConfigPath)
|
||||
idx := strings.Index(line, "command")
|
||||
leading := ""
|
||||
if idx > 0 {
|
||||
leading = line[:idx]
|
||||
}
|
||||
lines[i] = fmt.Sprintf("%scommand = \"%s\"", leading, newCommand)
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
|
||||
if !updated {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := backupFileIfExists(sudoPassword, configPath, ".backup"); err != nil {
|
||||
return fmt.Errorf("failed to backup greetd config: %w", err)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp greetd config: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.WriteString(strings.Join(lines, "\n")); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
return fmt.Errorf("failed to write temp greetd config: %w", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp greetd config: %w", err)
|
||||
}
|
||||
|
||||
if err := runSudoCmd(sudoPassword, "mv", tmpFile.Name(), configPath); err != nil {
|
||||
return fmt.Errorf("failed to update greetd config: %w", err)
|
||||
}
|
||||
|
||||
logFunc(fmt.Sprintf("✓ Updated greetd config to use niri config %s", niriConfigPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupFileIfExists(sudoPassword string, path string, suffix string) error {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405"))
|
||||
return runSudoCmd(sudoPassword, "cp", "-p", path, backupPath)
|
||||
}
|
||||
|
||||
func (s *niriGreeterSync) processFile(filePath string) error {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve path %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if s.processed[absPath] {
|
||||
return nil
|
||||
}
|
||||
s.processed[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
doc, err := kdl.Parse(strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse KDL in %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(absPath)
|
||||
for _, node := range doc.Nodes {
|
||||
name := node.Name.String()
|
||||
switch name {
|
||||
case "include":
|
||||
if err := s.handleInclude(node, baseDir); err != nil {
|
||||
return err
|
||||
}
|
||||
case "input":
|
||||
s.nodes = append(s.nodes, node)
|
||||
s.inputCount++
|
||||
case "output":
|
||||
s.nodes = append(s.nodes, node)
|
||||
s.outputCount++
|
||||
case "cursor":
|
||||
if s.cursorNode == nil {
|
||||
s.cursorNode = node
|
||||
s.cursorNode.Children = dedupeCursorChildren(s.cursorNode.Children)
|
||||
s.nodes = append(s.nodes, node)
|
||||
s.cursorCount++
|
||||
} else if len(node.Children) > 0 {
|
||||
s.cursorNode.Children = mergeCursorChildren(s.cursorNode.Children, node.Children)
|
||||
}
|
||||
case "debug":
|
||||
s.nodes = append(s.nodes, node)
|
||||
s.debugCount++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeCursorChildren(existing []*document.Node, incoming []*document.Node) []*document.Node {
|
||||
if len(incoming) == 0 {
|
||||
return existing
|
||||
}
|
||||
|
||||
indexByName := make(map[string]int, len(existing))
|
||||
for i, child := range existing {
|
||||
indexByName[child.Name.String()] = i
|
||||
}
|
||||
|
||||
for _, child := range incoming {
|
||||
name := child.Name.String()
|
||||
if idx, ok := indexByName[name]; ok {
|
||||
existing[idx] = child
|
||||
continue
|
||||
}
|
||||
indexByName[name] = len(existing)
|
||||
existing = append(existing, child)
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
func dedupeCursorChildren(children []*document.Node) []*document.Node {
|
||||
if len(children) == 0 {
|
||||
return children
|
||||
}
|
||||
|
||||
var result []*document.Node
|
||||
indexByName := make(map[string]int, len(children))
|
||||
for _, child := range children {
|
||||
name := child.Name.String()
|
||||
if idx, ok := indexByName[name]; ok {
|
||||
result[idx] = child
|
||||
continue
|
||||
}
|
||||
indexByName[name] = len(result)
|
||||
result = append(result, child)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *niriGreeterSync) handleInclude(node *document.Node, baseDir string) error {
|
||||
if len(node.Arguments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
includePath := strings.Trim(node.Arguments[0].String(), "\"")
|
||||
if includePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fullPath := includePath
|
||||
if !filepath.IsAbs(includePath) {
|
||||
fullPath = filepath.Join(baseDir, includePath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to stat include %s: %w", fullPath, err)
|
||||
}
|
||||
|
||||
return s.processFile(fullPath)
|
||||
}
|
||||
|
||||
func (s *niriGreeterSync) render() string {
|
||||
if len(s.nodes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for _, node := range s.nodes {
|
||||
_, _ = node.WriteToOptions(&builder, document.NodeWriteOptions{
|
||||
LeadingTrailingSpace: true,
|
||||
NameAndType: true,
|
||||
Depth: 0,
|
||||
Indent: []byte(" "),
|
||||
IgnoreFlags: false,
|
||||
})
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func ConfigureGreetd(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||
configPath := "/etc/greetd/config.toml"
|
||||
|
||||
@@ -392,17 +759,19 @@ func ConfigureGreetd(dmsPath, compositor string, logFunc func(string), sudoPassw
|
||||
logFunc(fmt.Sprintf("✓ Backed up existing config to %s", backupPath))
|
||||
}
|
||||
|
||||
greeterUser := DetectGreeterGroup()
|
||||
|
||||
var configContent string
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
configContent = string(data)
|
||||
} else {
|
||||
configContent = `[terminal]
|
||||
configContent = fmt.Sprintf(`[terminal]
|
||||
vt = 1
|
||||
|
||||
[default_session]
|
||||
|
||||
user = "greeter"
|
||||
`
|
||||
user = "%s"
|
||||
`, greeterUser)
|
||||
}
|
||||
|
||||
lines := strings.Split(configContent, "\n")
|
||||
@@ -411,7 +780,7 @@ user = "greeter"
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") {
|
||||
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
|
||||
newLines = append(newLines, `user = "greeter"`)
|
||||
newLines = append(newLines, fmt.Sprintf(`user = "%s"`, greeterUser))
|
||||
} else {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
@@ -463,10 +832,41 @@ user = "greeter"
|
||||
return fmt.Errorf("failed to move config to /etc/greetd: %w", err)
|
||||
}
|
||||
|
||||
logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: greeter, command: %s --command %s -p %s)", wrapperCmd, compositorLower, dmsPath))
|
||||
logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s --command %s -p %s)", greeterUser, wrapperCmd, compositorLower, dmsPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripConfigFlag(command string) string {
|
||||
for _, flag := range []string{" -C ", " --config "} {
|
||||
idx := strings.Index(command, flag)
|
||||
if idx == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
before := command[:idx]
|
||||
after := command[idx+len(flag):]
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(after, `"`):
|
||||
if end := strings.Index(after[1:], `"`); end != -1 {
|
||||
after = after[end+2:]
|
||||
} else {
|
||||
after = ""
|
||||
}
|
||||
default:
|
||||
if space := strings.Index(after, " "); space != -1 {
|
||||
after = after[space:]
|
||||
} else {
|
||||
after = ""
|
||||
}
|
||||
}
|
||||
|
||||
command = strings.TrimSpace(before + after)
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func runSudoCmd(sudoPassword string, command string, args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
|
||||
95
core/internal/keybinds/providers/miracle.go
Normal file
95
core/internal/keybinds/providers/miracle.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
type MiracleProvider struct {
|
||||
configPath string
|
||||
}
|
||||
|
||||
func NewMiracleProvider(configPath string) *MiracleProvider {
|
||||
if configPath == "" {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err == nil {
|
||||
configPath = filepath.Join(configDir, "miracle-wm")
|
||||
}
|
||||
}
|
||||
return &MiracleProvider{configPath: configPath}
|
||||
}
|
||||
|
||||
func (m *MiracleProvider) Name() string {
|
||||
return "miracle"
|
||||
}
|
||||
|
||||
func (m *MiracleProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
||||
config, err := ParseMiracleConfig(m.configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse miracle-wm config: %w", err)
|
||||
}
|
||||
|
||||
bindings := MiracleConfigToBindings(config)
|
||||
categorizedBinds := make(map[string][]keybinds.Keybind)
|
||||
|
||||
for _, kb := range bindings {
|
||||
category := m.categorizeAction(kb.Action)
|
||||
bind := keybinds.Keybind{
|
||||
Key: m.formatKey(kb),
|
||||
Description: kb.Comment,
|
||||
Action: kb.Action,
|
||||
}
|
||||
categorizedBinds[category] = append(categorizedBinds[category], bind)
|
||||
}
|
||||
|
||||
return &keybinds.CheatSheet{
|
||||
Title: "Miracle WM Keybinds",
|
||||
Provider: m.Name(),
|
||||
Binds: categorizedBinds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MiracleProvider) GetOverridePath() string {
|
||||
expanded, err := utils.ExpandPath(m.configPath)
|
||||
if err != nil {
|
||||
return filepath.Join(m.configPath, "config.yaml")
|
||||
}
|
||||
return filepath.Join(expanded, "config.yaml")
|
||||
}
|
||||
|
||||
func (m *MiracleProvider) formatKey(kb MiracleKeyBinding) string {
|
||||
parts := make([]string, 0, len(kb.Mods)+1)
|
||||
parts = append(parts, kb.Mods...)
|
||||
parts = append(parts, kb.Key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (m *MiracleProvider) categorizeAction(action string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(action, "select_workspace_") || strings.HasPrefix(action, "move_to_workspace_"):
|
||||
return "Workspace"
|
||||
case strings.Contains(action, "select_") || strings.Contains(action, "move_"):
|
||||
return "Window"
|
||||
case action == "toggle_resize" || strings.HasPrefix(action, "resize_"):
|
||||
return "Window"
|
||||
case action == "fullscreen" || action == "toggle_floating" || action == "quit_active_window" || action == "toggle_pinned_to_workspace":
|
||||
return "Window"
|
||||
case action == "toggle_tabbing" || action == "toggle_stacking" || action == "request_vertical" || action == "request_horizontal":
|
||||
return "Layout"
|
||||
case action == "quit_compositor":
|
||||
return "System"
|
||||
case action == "terminal":
|
||||
return "Execute"
|
||||
case strings.HasPrefix(action, "magnifier_"):
|
||||
return "Accessibility"
|
||||
case strings.HasPrefix(action, "dms ") || strings.Contains(action, "dms ipc"):
|
||||
return "Execute"
|
||||
default:
|
||||
return "Execute"
|
||||
}
|
||||
}
|
||||
320
core/internal/keybinds/providers/miracle_parser.go
Normal file
320
core/internal/keybinds/providers/miracle_parser.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type MiracleConfig struct {
|
||||
Terminal string `yaml:"terminal"`
|
||||
ActionKey string `yaml:"action_key"`
|
||||
DefaultActionOverrides []MiracleActionOverride `yaml:"default_action_overrides"`
|
||||
CustomActions []MiracleCustomAction `yaml:"custom_actions"`
|
||||
}
|
||||
|
||||
type MiracleActionOverride struct {
|
||||
Name string `yaml:"name"`
|
||||
Action string `yaml:"action"`
|
||||
Modifiers []string `yaml:"modifiers"`
|
||||
Key string `yaml:"key"`
|
||||
}
|
||||
|
||||
type MiracleCustomAction struct {
|
||||
Command string `yaml:"command"`
|
||||
Action string `yaml:"action"`
|
||||
Modifiers []string `yaml:"modifiers"`
|
||||
Key string `yaml:"key"`
|
||||
}
|
||||
|
||||
type MiracleKeyBinding struct {
|
||||
Mods []string
|
||||
Key string
|
||||
Action string
|
||||
Comment string
|
||||
}
|
||||
|
||||
var miracleDefaultBinds = []MiracleKeyBinding{
|
||||
{Mods: []string{"Super"}, Key: "Return", Action: "terminal", Comment: "Open terminal"},
|
||||
{Mods: []string{"Super"}, Key: "v", Action: "request_vertical", Comment: "Layout windows vertically"},
|
||||
{Mods: []string{"Super"}, Key: "h", Action: "request_horizontal", Comment: "Layout windows horizontally"},
|
||||
{Mods: []string{"Super"}, Key: "Up", Action: "select_up", Comment: "Select window above"},
|
||||
{Mods: []string{"Super"}, Key: "Down", Action: "select_down", Comment: "Select window below"},
|
||||
{Mods: []string{"Super"}, Key: "Left", Action: "select_left", Comment: "Select window left"},
|
||||
{Mods: []string{"Super"}, Key: "Right", Action: "select_right", Comment: "Select window right"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "Up", Action: "move_up", Comment: "Move window up"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "Down", Action: "move_down", Comment: "Move window down"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "Left", Action: "move_left", Comment: "Move window left"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "Right", Action: "move_right", Comment: "Move window right"},
|
||||
{Mods: []string{"Super"}, Key: "r", Action: "toggle_resize", Comment: "Toggle resize mode"},
|
||||
{Mods: []string{"Super"}, Key: "f", Action: "fullscreen", Comment: "Toggle fullscreen"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "q", Action: "quit_active_window", Comment: "Close window"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "e", Action: "quit_compositor", Comment: "Exit compositor"},
|
||||
{Mods: []string{"Super"}, Key: "Space", Action: "toggle_floating", Comment: "Toggle floating"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "p", Action: "toggle_pinned_to_workspace", Comment: "Toggle pinned to workspace"},
|
||||
{Mods: []string{"Super"}, Key: "w", Action: "toggle_tabbing", Comment: "Toggle tabbing layout"},
|
||||
{Mods: []string{"Super"}, Key: "s", Action: "toggle_stacking", Comment: "Toggle stacking layout"},
|
||||
{Mods: []string{"Super"}, Key: "1", Action: "select_workspace_0", Comment: "Workspace 1"},
|
||||
{Mods: []string{"Super"}, Key: "2", Action: "select_workspace_1", Comment: "Workspace 2"},
|
||||
{Mods: []string{"Super"}, Key: "3", Action: "select_workspace_2", Comment: "Workspace 3"},
|
||||
{Mods: []string{"Super"}, Key: "4", Action: "select_workspace_3", Comment: "Workspace 4"},
|
||||
{Mods: []string{"Super"}, Key: "5", Action: "select_workspace_4", Comment: "Workspace 5"},
|
||||
{Mods: []string{"Super"}, Key: "6", Action: "select_workspace_5", Comment: "Workspace 6"},
|
||||
{Mods: []string{"Super"}, Key: "7", Action: "select_workspace_6", Comment: "Workspace 7"},
|
||||
{Mods: []string{"Super"}, Key: "8", Action: "select_workspace_7", Comment: "Workspace 8"},
|
||||
{Mods: []string{"Super"}, Key: "9", Action: "select_workspace_8", Comment: "Workspace 9"},
|
||||
{Mods: []string{"Super"}, Key: "0", Action: "select_workspace_9", Comment: "Workspace 10"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "1", Action: "move_to_workspace_0", Comment: "Move to workspace 1"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "2", Action: "move_to_workspace_1", Comment: "Move to workspace 2"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "3", Action: "move_to_workspace_2", Comment: "Move to workspace 3"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "4", Action: "move_to_workspace_3", Comment: "Move to workspace 4"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "5", Action: "move_to_workspace_4", Comment: "Move to workspace 5"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "6", Action: "move_to_workspace_5", Comment: "Move to workspace 6"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "7", Action: "move_to_workspace_6", Comment: "Move to workspace 7"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "8", Action: "move_to_workspace_7", Comment: "Move to workspace 8"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "9", Action: "move_to_workspace_8", Comment: "Move to workspace 9"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "0", Action: "move_to_workspace_9", Comment: "Move to workspace 10"},
|
||||
}
|
||||
|
||||
func ParseMiracleConfig(configPath string) (*MiracleConfig, error) {
|
||||
expanded, err := utils.ExpandPath(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := os.Stat(expanded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var configFile string
|
||||
if info.IsDir() {
|
||||
configFile = filepath.Join(expanded, "config.yaml")
|
||||
} else {
|
||||
configFile = expanded
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config MiracleConfig
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config.ActionKey == "" {
|
||||
config.ActionKey = "meta"
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func resolveMiracleModifier(mod, actionKey string) string {
|
||||
switch mod {
|
||||
case "primary":
|
||||
return resolveActionKey(actionKey)
|
||||
case "alt", "alt_left", "alt_right":
|
||||
return "Alt"
|
||||
case "shift", "shift_left", "shift_right":
|
||||
return "Shift"
|
||||
case "ctrl", "ctrl_left", "ctrl_right":
|
||||
return "Ctrl"
|
||||
case "meta", "meta_left", "meta_right":
|
||||
return "Super"
|
||||
default:
|
||||
return mod
|
||||
}
|
||||
}
|
||||
|
||||
func resolveActionKey(actionKey string) string {
|
||||
switch actionKey {
|
||||
case "meta":
|
||||
return "Super"
|
||||
case "alt":
|
||||
return "Alt"
|
||||
case "ctrl":
|
||||
return "Ctrl"
|
||||
default:
|
||||
return "Super"
|
||||
}
|
||||
}
|
||||
|
||||
func miracleKeyCodeToName(keyCode string) string {
|
||||
name := strings.TrimPrefix(keyCode, "KEY_")
|
||||
name = strings.ToLower(name)
|
||||
|
||||
switch name {
|
||||
case "enter":
|
||||
return "Return"
|
||||
case "space":
|
||||
return "Space"
|
||||
case "up":
|
||||
return "Up"
|
||||
case "down":
|
||||
return "Down"
|
||||
case "left":
|
||||
return "Left"
|
||||
case "right":
|
||||
return "Right"
|
||||
case "tab":
|
||||
return "Tab"
|
||||
case "escape", "esc":
|
||||
return "Escape"
|
||||
case "delete":
|
||||
return "Delete"
|
||||
case "backspace":
|
||||
return "BackSpace"
|
||||
case "home":
|
||||
return "Home"
|
||||
case "end":
|
||||
return "End"
|
||||
case "pageup":
|
||||
return "Page_Up"
|
||||
case "pagedown":
|
||||
return "Page_Down"
|
||||
case "print":
|
||||
return "Print"
|
||||
case "pause":
|
||||
return "Pause"
|
||||
case "volumeup":
|
||||
return "XF86AudioRaiseVolume"
|
||||
case "volumedown":
|
||||
return "XF86AudioLowerVolume"
|
||||
case "mute":
|
||||
return "XF86AudioMute"
|
||||
case "micmute":
|
||||
return "XF86AudioMicMute"
|
||||
case "brightnessup":
|
||||
return "XF86MonBrightnessUp"
|
||||
case "brightnessdown":
|
||||
return "XF86MonBrightnessDown"
|
||||
case "kbdillumup":
|
||||
return "XF86KbdBrightnessUp"
|
||||
case "kbdillumdown":
|
||||
return "XF86KbdBrightnessDown"
|
||||
case "comma":
|
||||
return "comma"
|
||||
case "minus":
|
||||
return "minus"
|
||||
case "equal":
|
||||
return "equal"
|
||||
}
|
||||
|
||||
if len(name) == 1 {
|
||||
return name
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func MiracleConfigToBindings(config *MiracleConfig) []MiracleKeyBinding {
|
||||
overridden := make(map[string]bool)
|
||||
var bindings []MiracleKeyBinding
|
||||
|
||||
for _, override := range config.DefaultActionOverrides {
|
||||
mods := make([]string, 0, len(override.Modifiers))
|
||||
for _, mod := range override.Modifiers {
|
||||
mods = append(mods, resolveMiracleModifier(mod, config.ActionKey))
|
||||
}
|
||||
|
||||
bindings = append(bindings, MiracleKeyBinding{
|
||||
Mods: mods,
|
||||
Key: miracleKeyCodeToName(override.Key),
|
||||
Action: override.Name,
|
||||
Comment: miracleActionDescription(override.Name),
|
||||
})
|
||||
overridden[override.Name] = true
|
||||
}
|
||||
|
||||
for _, def := range miracleDefaultBinds {
|
||||
if overridden[def.Action] {
|
||||
continue
|
||||
}
|
||||
bindings = append(bindings, def)
|
||||
}
|
||||
|
||||
for _, custom := range config.CustomActions {
|
||||
mods := make([]string, 0, len(custom.Modifiers))
|
||||
for _, mod := range custom.Modifiers {
|
||||
mods = append(mods, resolveMiracleModifier(mod, config.ActionKey))
|
||||
}
|
||||
|
||||
bindings = append(bindings, MiracleKeyBinding{
|
||||
Mods: mods,
|
||||
Key: miracleKeyCodeToName(custom.Key),
|
||||
Action: custom.Command,
|
||||
Comment: custom.Command,
|
||||
})
|
||||
}
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
||||
func miracleActionDescription(action string) string {
|
||||
switch action {
|
||||
case "terminal":
|
||||
return "Open terminal"
|
||||
case "request_vertical":
|
||||
return "Layout windows vertically"
|
||||
case "request_horizontal":
|
||||
return "Layout windows horizontally"
|
||||
case "select_up":
|
||||
return "Select window above"
|
||||
case "select_down":
|
||||
return "Select window below"
|
||||
case "select_left":
|
||||
return "Select window left"
|
||||
case "select_right":
|
||||
return "Select window right"
|
||||
case "move_up":
|
||||
return "Move window up"
|
||||
case "move_down":
|
||||
return "Move window down"
|
||||
case "move_left":
|
||||
return "Move window left"
|
||||
case "move_right":
|
||||
return "Move window right"
|
||||
case "toggle_resize":
|
||||
return "Toggle resize mode"
|
||||
case "fullscreen":
|
||||
return "Toggle fullscreen"
|
||||
case "quit_active_window":
|
||||
return "Close window"
|
||||
case "quit_compositor":
|
||||
return "Exit compositor"
|
||||
case "toggle_floating":
|
||||
return "Toggle floating"
|
||||
case "toggle_pinned_to_workspace":
|
||||
return "Toggle pinned to workspace"
|
||||
case "toggle_tabbing":
|
||||
return "Toggle tabbing layout"
|
||||
case "toggle_stacking":
|
||||
return "Toggle stacking layout"
|
||||
case "magnifier_on":
|
||||
return "Enable magnifier"
|
||||
case "magnifier_off":
|
||||
return "Disable magnifier"
|
||||
case "magnifier_increase_size":
|
||||
return "Increase magnifier area"
|
||||
case "magnifier_decrease_size":
|
||||
return "Decrease magnifier area"
|
||||
case "magnifier_increase_scale":
|
||||
return "Increase magnifier scale"
|
||||
case "magnifier_decrease_scale":
|
||||
return "Decrease magnifier scale"
|
||||
}
|
||||
|
||||
if num, ok := strings.CutPrefix(action, "select_workspace_"); ok {
|
||||
return "Workspace " + num
|
||||
}
|
||||
if num, ok := strings.CutPrefix(action, "move_to_workspace_"); ok {
|
||||
return "Move to workspace " + num
|
||||
}
|
||||
|
||||
return action
|
||||
}
|
||||
@@ -118,6 +118,9 @@ func (n *NiriProvider) categorizeByAction(action string) string {
|
||||
return "Overview"
|
||||
case action == "quit" ||
|
||||
action == "power-off-monitors" ||
|
||||
action == "power-on-monitors" ||
|
||||
action == "suspend" ||
|
||||
action == "do-screen-transition" ||
|
||||
action == "toggle-keyboard-shortcuts-inhibit" ||
|
||||
strings.Contains(action, "dpms"):
|
||||
return "System"
|
||||
@@ -151,13 +154,16 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
|
||||
}
|
||||
|
||||
bind := keybinds.Keybind{
|
||||
Key: keyStr,
|
||||
Description: kb.Description,
|
||||
Action: rawAction,
|
||||
Subcategory: subcategory,
|
||||
Source: source,
|
||||
HideOnOverlay: kb.HideOnOverlay,
|
||||
CooldownMs: kb.CooldownMs,
|
||||
Key: keyStr,
|
||||
Description: kb.Description,
|
||||
Action: rawAction,
|
||||
Subcategory: subcategory,
|
||||
Source: source,
|
||||
HideOnOverlay: kb.HideOnOverlay,
|
||||
CooldownMs: kb.CooldownMs,
|
||||
AllowWhenLocked: kb.AllowWhenLocked,
|
||||
AllowInhibiting: kb.AllowInhibiting,
|
||||
Repeat: kb.Repeat,
|
||||
}
|
||||
|
||||
if source == "dms" && conflicts != nil {
|
||||
@@ -335,20 +341,18 @@ func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
|
||||
val := arg.ValueString()
|
||||
if val == "" {
|
||||
parts = append(parts, `""`)
|
||||
} else if strings.ContainsAny(val, " \t") {
|
||||
parts = append(parts, `"`+strings.ReplaceAll(val, `"`, `\"`)+`"`)
|
||||
} else {
|
||||
parts = append(parts, val)
|
||||
}
|
||||
}
|
||||
|
||||
if actionNode.Properties != nil {
|
||||
if val, ok := actionNode.Properties.Get("focus"); ok {
|
||||
parts = append(parts, "focus="+val.String())
|
||||
}
|
||||
if val, ok := actionNode.Properties.Get("show-pointer"); ok {
|
||||
parts = append(parts, "show-pointer="+val.String())
|
||||
}
|
||||
if val, ok := actionNode.Properties.Get("write-to-disk"); ok {
|
||||
parts = append(parts, "write-to-disk="+val.String())
|
||||
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} {
|
||||
if val, ok := actionNode.Properties.Get(propName); ok {
|
||||
parts = append(parts, propName+"="+val.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +376,9 @@ func (n *NiriProvider) extractOptions(node *document.Node) map[string]any {
|
||||
if val, ok := node.Properties.Get("allow-when-locked"); ok {
|
||||
opts["allow-when-locked"] = val.String() == "true"
|
||||
}
|
||||
if val, ok := node.Properties.Get("allow-inhibiting"); ok {
|
||||
opts["allow-inhibiting"] = val.String() == "true"
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
@@ -405,6 +412,9 @@ func (n *NiriProvider) buildBindNode(bind *overrideBind) *document.Node {
|
||||
if v, ok := bind.Options["allow-when-locked"]; ok && v == true {
|
||||
node.AddProperty("allow-when-locked", true, "")
|
||||
}
|
||||
if v, ok := bind.Options["allow-inhibiting"]; ok && v == false {
|
||||
node.AddProperty("allow-inhibiting", false, "")
|
||||
}
|
||||
}
|
||||
|
||||
if bind.Description != "" {
|
||||
|
||||
@@ -12,14 +12,17 @@ import (
|
||||
)
|
||||
|
||||
type NiriKeyBinding struct {
|
||||
Mods []string
|
||||
Key string
|
||||
Action string
|
||||
Args []string
|
||||
Description string
|
||||
HideOnOverlay bool
|
||||
CooldownMs int
|
||||
Source string
|
||||
Mods []string
|
||||
Key string
|
||||
Action string
|
||||
Args []string
|
||||
Description string
|
||||
HideOnOverlay bool
|
||||
CooldownMs int
|
||||
AllowWhenLocked bool
|
||||
AllowInhibiting *bool
|
||||
Repeat *bool
|
||||
Source string
|
||||
}
|
||||
|
||||
type NiriSection struct {
|
||||
@@ -269,8 +272,10 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBin
|
||||
args = append(args, arg.ValueString())
|
||||
}
|
||||
if actionNode.Properties != nil {
|
||||
if val, ok := actionNode.Properties.Get("focus"); ok {
|
||||
args = append(args, "focus="+val.String())
|
||||
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} {
|
||||
if val, ok := actionNode.Properties.Get(propName); ok {
|
||||
args = append(args, propName+"="+val.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,6 +283,9 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBin
|
||||
var description string
|
||||
var hideOnOverlay bool
|
||||
var cooldownMs int
|
||||
var allowWhenLocked bool
|
||||
var allowInhibiting *bool
|
||||
var repeat *bool
|
||||
if node.Properties != nil {
|
||||
if val, ok := node.Properties.Get("hotkey-overlay-title"); ok {
|
||||
switch val.ValueString() {
|
||||
@@ -290,17 +298,31 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBin
|
||||
if val, ok := node.Properties.Get("cooldown-ms"); ok {
|
||||
cooldownMs, _ = strconv.Atoi(val.String())
|
||||
}
|
||||
if val, ok := node.Properties.Get("allow-when-locked"); ok {
|
||||
allowWhenLocked = val.String() == "true"
|
||||
}
|
||||
if val, ok := node.Properties.Get("allow-inhibiting"); ok {
|
||||
v := val.String() == "true"
|
||||
allowInhibiting = &v
|
||||
}
|
||||
if val, ok := node.Properties.Get("repeat"); ok {
|
||||
v := val.String() == "true"
|
||||
repeat = &v
|
||||
}
|
||||
}
|
||||
|
||||
return &NiriKeyBinding{
|
||||
Mods: mods,
|
||||
Key: key,
|
||||
Action: action,
|
||||
Args: args,
|
||||
Description: description,
|
||||
HideOnOverlay: hideOnOverlay,
|
||||
CooldownMs: cooldownMs,
|
||||
Source: p.currentSource,
|
||||
Mods: mods,
|
||||
Key: key,
|
||||
Action: action,
|
||||
Args: args,
|
||||
Description: description,
|
||||
HideOnOverlay: hideOnOverlay,
|
||||
CooldownMs: cooldownMs,
|
||||
AllowWhenLocked: allowWhenLocked,
|
||||
AllowInhibiting: allowInhibiting,
|
||||
Repeat: repeat,
|
||||
Source: p.currentSource,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package providers
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
@@ -18,14 +19,21 @@ func NewSwayProvider(configPath string) *SwayProvider {
|
||||
_, scrollEnvSet := os.LookupEnv("SCROLLSOCK")
|
||||
|
||||
if configPath == "" {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
configDir = ""
|
||||
}
|
||||
if scrollEnvSet {
|
||||
configPath = "$HOME/.config/scroll"
|
||||
if configDir != "" {
|
||||
configPath = filepath.Join(configDir, "scroll")
|
||||
}
|
||||
isScroll = true
|
||||
} else {
|
||||
configPath = "$HOME/.config/sway"
|
||||
if configDir != "" {
|
||||
configPath = filepath.Join(configDir, "sway")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Determine isScroll based on the provided config path
|
||||
isScroll = strings.Contains(configPath, "scroll")
|
||||
}
|
||||
|
||||
@@ -36,16 +44,16 @@ func NewSwayProvider(configPath string) *SwayProvider {
|
||||
}
|
||||
|
||||
func (s *SwayProvider) Name() string {
|
||||
if s != nil && s.isScroll {
|
||||
return "scroll"
|
||||
}
|
||||
if s == nil {
|
||||
_, ok := os.LookupEnv("SCROLLSOCK")
|
||||
if ok {
|
||||
if os.Getenv("SCROLLSOCK") != "" {
|
||||
return "scroll"
|
||||
}
|
||||
return "sway"
|
||||
}
|
||||
|
||||
if s.isScroll {
|
||||
return "scroll"
|
||||
}
|
||||
return "sway"
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,13 @@ func TestSwayProviderName(t *testing.T) {
|
||||
|
||||
func TestSwayProviderDefaultPath(t *testing.T) {
|
||||
provider := NewSwayProvider("")
|
||||
if provider.configPath != "$HOME/.config/sway" {
|
||||
t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/sway")
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Skip("UserConfigDir not available")
|
||||
}
|
||||
expected := filepath.Join(configDir, "sway")
|
||||
if provider.configPath != expected {
|
||||
t.Errorf("configPath = %q, want %q", provider.configPath, expected)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package keybinds
|
||||
|
||||
type Keybind struct {
|
||||
Key string `json:"key"`
|
||||
Description string `json:"desc"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Subcategory string `json:"subcat,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
HideOnOverlay bool `json:"hideOnOverlay,omitempty"`
|
||||
CooldownMs int `json:"cooldownMs,omitempty"`
|
||||
Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press
|
||||
Conflict *Keybind `json:"conflict,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Description string `json:"desc"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Subcategory string `json:"subcat,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
HideOnOverlay bool `json:"hideOnOverlay,omitempty"`
|
||||
CooldownMs int `json:"cooldownMs,omitempty"`
|
||||
Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press
|
||||
AllowWhenLocked bool `json:"allowWhenLocked,omitempty"`
|
||||
AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled
|
||||
Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled
|
||||
Conflict *Keybind `json:"conflict,omitempty"`
|
||||
}
|
||||
|
||||
type DMSBindsStatus struct {
|
||||
|
||||
@@ -3,6 +3,7 @@ package matugen
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -10,10 +11,12 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
)
|
||||
|
||||
type ColorMode string
|
||||
@@ -30,6 +33,7 @@ const (
|
||||
TemplateKindTerminal
|
||||
TemplateKindGTK
|
||||
TemplateKindVSCode
|
||||
TemplateKindEmacs
|
||||
)
|
||||
|
||||
type TemplateDef struct {
|
||||
@@ -62,7 +66,7 @@ var templateRegistry = []TemplateDef{
|
||||
{ID: "dgop", Commands: []string{"dgop"}, ConfigFile: "dgop.toml"},
|
||||
{ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true},
|
||||
{ID: "vscode", Kind: TemplateKindVSCode},
|
||||
{ID: "emacs", Commands: []string{"emacs"}, ConfigFile: "emacs.toml"},
|
||||
{ID: "emacs", Commands: []string{"emacs"}, ConfigFile: "emacs.toml", Kind: TemplateKindEmacs},
|
||||
}
|
||||
|
||||
func (c *ColorMode) GTKTheme() string {
|
||||
@@ -75,8 +79,10 @@ func (c *ColorMode) GTKTheme() string {
|
||||
}
|
||||
|
||||
var (
|
||||
matugenVersionOnce sync.Once
|
||||
matugenVersionMu sync.Mutex
|
||||
matugenVersionOK bool
|
||||
matugenSupportsCOE bool
|
||||
matugenIsV4 bool
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
@@ -250,8 +256,22 @@ func buildOnce(opts *Options) error {
|
||||
}
|
||||
}
|
||||
|
||||
refreshGTK(opts.ConfigDir, opts.Mode)
|
||||
signalTerminals()
|
||||
if isDMSGTKActive(opts.ConfigDir) {
|
||||
switch opts.Mode {
|
||||
case ColorModeLight:
|
||||
syncAccentColor(primaryLight)
|
||||
default:
|
||||
syncAccentColor(primaryDark)
|
||||
}
|
||||
refreshGTK(opts.Mode)
|
||||
refreshGTK4()
|
||||
}
|
||||
|
||||
if !opts.ShouldSkipTemplate("qt6ct") && appExists(opts.AppChecker, []string{"qt6ct"}, nil) {
|
||||
refreshQt6ct()
|
||||
}
|
||||
|
||||
signalTerminals(opts)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -316,6 +336,10 @@ output_path = '%s'
|
||||
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
|
||||
case TemplateKindEmacs:
|
||||
if utils.EmacsConfigDir() != "" {
|
||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||
}
|
||||
default:
|
||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||
}
|
||||
@@ -473,6 +497,9 @@ func substituteVars(content, shellDir string) string {
|
||||
result = strings.ReplaceAll(result, "'CONFIG_DIR/", "'"+utils.XDGConfigHome()+"/")
|
||||
result = strings.ReplaceAll(result, "'DATA_DIR/", "'"+utils.XDGDataHome()+"/")
|
||||
result = strings.ReplaceAll(result, "'CACHE_DIR/", "'"+utils.XDGCacheHome()+"/")
|
||||
if emacsDir := utils.EmacsConfigDir(); emacsDir != "" {
|
||||
result = strings.ReplaceAll(result, "'EMACS_DIR/", "'"+emacsDir+"/")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -493,67 +520,160 @@ func extractTOMLSection(content, startMarker, endMarker string) string {
|
||||
return content[startIdx : startIdx+endIdx]
|
||||
}
|
||||
|
||||
func checkMatugenVersion() {
|
||||
matugenVersionOnce.Do(func() {
|
||||
cmd := exec.Command("matugen", "--version")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
versionStr := strings.TrimSpace(string(output))
|
||||
versionStr = strings.TrimPrefix(versionStr, "matugen ")
|
||||
|
||||
parts := strings.Split(versionStr, ".")
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
major, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
minor, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
matugenSupportsCOE = major > 3 || (major == 3 && minor >= 1)
|
||||
if matugenSupportsCOE {
|
||||
log.Infof("Matugen %s supports --continue-on-error", versionStr)
|
||||
}
|
||||
})
|
||||
type matugenFlags struct {
|
||||
supportsCOE bool
|
||||
isV4 bool
|
||||
}
|
||||
|
||||
func runMatugen(args []string) error {
|
||||
checkMatugenVersion()
|
||||
func detectMatugenVersion() (matugenFlags, error) {
|
||||
matugenVersionMu.Lock()
|
||||
defer matugenVersionMu.Unlock()
|
||||
|
||||
if matugenSupportsCOE {
|
||||
args = append([]string{"--continue-on-error"}, args...)
|
||||
if matugenVersionOK {
|
||||
return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil
|
||||
}
|
||||
|
||||
return detectMatugenVersionLocked()
|
||||
}
|
||||
|
||||
func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) {
|
||||
matugenVersionMu.Lock()
|
||||
defer matugenVersionMu.Unlock()
|
||||
|
||||
matugenVersionOK = false
|
||||
flags, err := detectMatugenVersionLocked()
|
||||
if err != nil {
|
||||
return old, false
|
||||
}
|
||||
changed := flags.supportsCOE != old.supportsCOE || flags.isV4 != old.isV4
|
||||
return flags, changed
|
||||
}
|
||||
|
||||
func detectMatugenVersionLocked() (matugenFlags, error) {
|
||||
cmd := exec.Command("matugen", "--version")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err)
|
||||
}
|
||||
|
||||
versionStr := strings.TrimSpace(string(output))
|
||||
versionStr = strings.TrimPrefix(versionStr, "matugen ")
|
||||
|
||||
parts := strings.Split(versionStr, ".")
|
||||
if len(parts) < 2 {
|
||||
return matugenFlags{}, fmt.Errorf("unexpected matugen version format: %q", versionStr)
|
||||
}
|
||||
|
||||
major, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return matugenFlags{}, fmt.Errorf("failed to parse matugen major version %q: %w", parts[0], err)
|
||||
}
|
||||
|
||||
minor, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return matugenFlags{}, fmt.Errorf("failed to parse matugen minor version %q: %w", parts[1], err)
|
||||
}
|
||||
|
||||
matugenSupportsCOE = major > 3 || (major == 3 && minor >= 1)
|
||||
matugenIsV4 = major >= 4
|
||||
matugenVersionOK = true
|
||||
|
||||
if matugenSupportsCOE {
|
||||
log.Infof("Matugen %s supports --continue-on-error", versionStr)
|
||||
}
|
||||
if matugenIsV4 {
|
||||
log.Infof("Matugen %s: using v4 flags", versionStr)
|
||||
}
|
||||
return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil
|
||||
}
|
||||
|
||||
func buildMatugenArgs(baseArgs []string, flags matugenFlags) []string {
|
||||
args := make([]string, 0, len(baseArgs)+4)
|
||||
if flags.supportsCOE {
|
||||
args = append(args, "--continue-on-error")
|
||||
}
|
||||
args = append(args, baseArgs...)
|
||||
if flags.isV4 {
|
||||
args = append(args, "--source-color-index", "0")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func runMatugen(baseArgs []string) error {
|
||||
flags, err := detectMatugenVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := buildMatugenArgs(baseArgs, flags)
|
||||
cmd := exec.Command("matugen", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
runErr := cmd.Run()
|
||||
if runErr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Warnf("Matugen failed (v4=%v): %v", flags.isV4, runErr)
|
||||
|
||||
newFlags, changed := redetectMatugenVersion(flags)
|
||||
if !changed {
|
||||
return runErr
|
||||
}
|
||||
|
||||
log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4)
|
||||
args = buildMatugenArgs(baseArgs, newFlags)
|
||||
retryCmd := exec.Command("matugen", args...)
|
||||
retryCmd.Stdout = os.Stdout
|
||||
retryCmd.Stderr = os.Stderr
|
||||
return retryCmd.Run()
|
||||
}
|
||||
|
||||
func runMatugenDryRun(opts *Options) (string, error) {
|
||||
var args []string
|
||||
switch opts.Kind {
|
||||
case "hex":
|
||||
args = []string{"color", "hex", opts.Value}
|
||||
default:
|
||||
args = []string{opts.Kind, opts.Value}
|
||||
}
|
||||
args = append(args, "-m", "dark", "-t", opts.MatugenType, "--json", "hex", "--dry-run")
|
||||
|
||||
cmd := exec.Command("matugen", args...)
|
||||
output, err := cmd.Output()
|
||||
flags, err := detectMatugenVersion()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
output, dryErr := execDryRun(opts, flags)
|
||||
if dryErr == nil {
|
||||
return output, nil
|
||||
}
|
||||
|
||||
log.Warnf("Matugen dry-run failed (v4=%v): %v", flags.isV4, dryErr)
|
||||
|
||||
newFlags, changed := redetectMatugenVersion(flags)
|
||||
if !changed {
|
||||
return "", dryErr
|
||||
}
|
||||
|
||||
log.Warnf("Matugen version changed (v4: %v -> %v), retrying dry-run", flags.isV4, newFlags.isV4)
|
||||
return execDryRun(opts, newFlags)
|
||||
}
|
||||
|
||||
func execDryRun(opts *Options, flags matugenFlags) (string, error) {
|
||||
var baseArgs []string
|
||||
switch opts.Kind {
|
||||
case "hex":
|
||||
baseArgs = []string{"color", "hex", opts.Value}
|
||||
default:
|
||||
baseArgs = []string{opts.Kind, opts.Value}
|
||||
}
|
||||
baseArgs = append(baseArgs, "-m", "dark", "-t", opts.MatugenType, "--json", "hex", "--dry-run")
|
||||
if flags.isV4 {
|
||||
baseArgs = append(baseArgs, "--source-color-index", "0", "--old-json-output")
|
||||
}
|
||||
|
||||
cmd := exec.Command("matugen", baseArgs...)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if stderr.Len() > 0 {
|
||||
return "", fmt.Errorf("matugen %v failed (v4=%v): %s", baseArgs, flags.isV4, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return "", fmt.Errorf("matugen %v failed (v4=%v): %w", baseArgs, flags.isV4, err)
|
||||
}
|
||||
return strings.ReplaceAll(string(output), "\n", ""), nil
|
||||
}
|
||||
|
||||
@@ -617,40 +737,73 @@ func generateDank16Variants(primaryDark, primaryLight, surface string, mode Colo
|
||||
return dank16.GenerateVariantJSON(variantColors)
|
||||
}
|
||||
|
||||
func refreshGTK(configDir string, mode ColorMode) {
|
||||
func isDMSGTKActive(configDir string) bool {
|
||||
gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
|
||||
|
||||
info, err := os.Lstat(gtkCSS)
|
||||
if err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
shouldRun := false
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
target, err := os.Readlink(gtkCSS)
|
||||
if err == nil && strings.Contains(target, "dank-colors.css") {
|
||||
shouldRun = true
|
||||
}
|
||||
} else {
|
||||
data, err := os.ReadFile(gtkCSS)
|
||||
if err == nil && strings.Contains(string(data), "dank-colors.css") {
|
||||
shouldRun = true
|
||||
}
|
||||
return err == nil && strings.Contains(target, "dank-colors.css")
|
||||
}
|
||||
|
||||
if !shouldRun {
|
||||
return
|
||||
}
|
||||
|
||||
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "").Run()
|
||||
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()).Run()
|
||||
data, err := os.ReadFile(gtkCSS)
|
||||
return err == nil && strings.Contains(string(data), "dank-colors.css")
|
||||
}
|
||||
|
||||
func signalTerminals() {
|
||||
signalByName("kitty", syscall.SIGUSR1)
|
||||
signalByName("ghostty", syscall.SIGUSR2)
|
||||
signalByName(".kitty-wrapped", syscall.SIGUSR1)
|
||||
signalByName(".ghostty-wrappe", syscall.SIGUSR2)
|
||||
func refreshGTK(mode ColorMode) {
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", ""); err != nil {
|
||||
log.Warnf("Failed to reset gtk-theme: %v", err)
|
||||
}
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()); err != nil {
|
||||
log.Warnf("Failed to set gtk-theme: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshGTK4() {
|
||||
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
current := strings.Trim(output, "'")
|
||||
|
||||
var toggle string
|
||||
if current == "prefer-dark" {
|
||||
toggle = "default"
|
||||
} else {
|
||||
toggle = "prefer-dark"
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshQt6ct() {
|
||||
confPath := filepath.Join(utils.XDGConfigHome(), "qt6ct", "qt6ct.conf")
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(confPath, now, now); err != nil {
|
||||
log.Warnf("Failed to touch qt6ct.conf: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func signalTerminals(opts *Options) {
|
||||
if !opts.ShouldSkipTemplate("kitty") && appExists(opts.AppChecker, []string{"kitty"}, nil) {
|
||||
signalByName("kitty", syscall.SIGUSR1)
|
||||
signalByName(".kitty-wrapped", syscall.SIGUSR1)
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("ghostty") && appExists(opts.AppChecker, []string{"ghostty"}, nil) {
|
||||
signalByName("ghostty", syscall.SIGUSR2)
|
||||
signalByName(".ghostty-wrappe", syscall.SIGUSR2)
|
||||
}
|
||||
}
|
||||
|
||||
func signalByName(name string, sig syscall.Signal) {
|
||||
@@ -679,8 +832,59 @@ func syncColorScheme(mode ColorMode) {
|
||||
scheme = "default"
|
||||
}
|
||||
|
||||
if err := exec.Command("gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme).Run(); err != nil {
|
||||
exec.Command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", "'"+scheme+"'").Run()
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil {
|
||||
log.Warnf("Failed to sync color-scheme: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var adwaitaAccents = []struct {
|
||||
name string
|
||||
colors []colorful.Color
|
||||
}{
|
||||
{"blue", hexColors("#3f8ae5", "#438de6", "#a4caee")},
|
||||
{"green", hexColors("#26a269", "#39ac76", "#81d5ad")},
|
||||
{"orange", hexColors("#f17738", "#ff7800", "#ffc994")},
|
||||
{"pink", hexColors("#e4358a", "#e64392", "#f9b3d5")},
|
||||
{"purple", hexColors("#954ab5", "#9c46b9", "#d099d6")},
|
||||
{"red", hexColors("#e84053", "#e01b24", "#f2a1a5")},
|
||||
{"slate", hexColors("#557b9f", "#6a8daf", "#b4c6d6")},
|
||||
{"teal", hexColors("#129eb0", "#2190a4", "#7bdff4")},
|
||||
{"yellow", hexColors("#cbac10", "#d4b411", "#f5c211")},
|
||||
}
|
||||
|
||||
func hexColors(hexes ...string) []colorful.Color {
|
||||
out := make([]colorful.Color, len(hexes))
|
||||
for i, h := range hexes {
|
||||
out[i], _ = colorful.Hex(h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func closestAdwaitaAccent(primaryHex string) string {
|
||||
c, err := colorful.Hex(primaryHex)
|
||||
if err != nil {
|
||||
return "blue"
|
||||
}
|
||||
|
||||
best := "blue"
|
||||
bestDist := math.MaxFloat64
|
||||
for _, a := range adwaitaAccents {
|
||||
for _, ref := range a.colors {
|
||||
d := c.DistanceCIEDE2000(ref)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
best = a.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func syncAccentColor(primaryHex string) {
|
||||
accent := closestAdwaitaAccent(primaryHex)
|
||||
log.Infof("Setting GNOME accent color: %s", accent)
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil {
|
||||
log.Warnf("Failed to set accent-color: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,6 +909,8 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
|
||||
detected = true
|
||||
case tmpl.Kind == TemplateKindVSCode:
|
||||
detected = checkVSCodeExtension(homeDir)
|
||||
case tmpl.Kind == TemplateKindEmacs:
|
||||
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != ""
|
||||
default:
|
||||
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ const (
|
||||
notifyDest = "org.freedesktop.Notifications"
|
||||
notifyPath = "/org/freedesktop/Notifications"
|
||||
notifyInterface = "org.freedesktop.Notifications"
|
||||
|
||||
maxSummaryLen = 29
|
||||
maxBodyLen = 80
|
||||
)
|
||||
|
||||
type Notification struct {
|
||||
@@ -39,6 +42,13 @@ func Send(n Notification) error {
|
||||
n.Timeout = 5000
|
||||
}
|
||||
|
||||
if len(n.Summary) > maxSummaryLen {
|
||||
n.Summary = n.Summary[:maxSummaryLen-3] + "..."
|
||||
}
|
||||
if len(n.Body) > maxBodyLen {
|
||||
n.Body = n.Body[:maxBodyLen-3] + "..."
|
||||
}
|
||||
|
||||
var actions []string
|
||||
if n.FilePath != "" {
|
||||
actions = []string{
|
||||
|
||||
@@ -27,6 +27,7 @@ type Plugin struct {
|
||||
Distro []string `json:"distro"`
|
||||
Screenshot string `json:"screenshot,omitempty"`
|
||||
RequiresDMS string `json:"requires_dms,omitempty"`
|
||||
Featured bool `json:"featured,omitempty"`
|
||||
}
|
||||
|
||||
type GitClient interface {
|
||||
|
||||
@@ -67,6 +67,9 @@ func FilterByCapability(capability string, plugins []Plugin) []Plugin {
|
||||
|
||||
func SortByFirstParty(plugins []Plugin) []Plugin {
|
||||
sort.SliceStable(plugins, func(i, j int) bool {
|
||||
if plugins[i].Featured != plugins[j].Featured {
|
||||
return plugins[i].Featured
|
||||
}
|
||||
isFirstPartyI := strings.HasPrefix(plugins[i].Repo, "https://github.com/AvengeMedia")
|
||||
isFirstPartyJ := strings.HasPrefix(plugins[j].Repo, "https://github.com/AvengeMedia")
|
||||
if isFirstPartyI != isFirstPartyJ {
|
||||
|
||||
@@ -258,7 +258,7 @@ func (i *ExtWorkspaceManagerV1) Dispatch(opcode uint32, fd int, data []byte) {
|
||||
l := 0
|
||||
objectID := client.Uint32(data[l : l+4])
|
||||
proxy := i.Context().GetProxy(objectID)
|
||||
if proxy != nil {
|
||||
if proxy != nil && !proxy.IsZombie() {
|
||||
e.WorkspaceGroup = proxy.(*ExtWorkspaceGroupHandleV1)
|
||||
} else {
|
||||
groupHandle := &ExtWorkspaceGroupHandleV1{}
|
||||
@@ -278,7 +278,7 @@ func (i *ExtWorkspaceManagerV1) Dispatch(opcode uint32, fd int, data []byte) {
|
||||
l := 0
|
||||
objectID := client.Uint32(data[l : l+4])
|
||||
proxy := i.Context().GetProxy(objectID)
|
||||
if proxy != nil {
|
||||
if proxy != nil && !proxy.IsZombie() {
|
||||
e.Workspace = proxy.(*ExtWorkspaceHandleV1)
|
||||
} else {
|
||||
wsHandle := &ExtWorkspaceHandleV1{}
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
CompositorNiri
|
||||
CompositorDWL
|
||||
CompositorScroll
|
||||
CompositorMiracle
|
||||
)
|
||||
|
||||
var detectedCompositor Compositor = -1
|
||||
@@ -34,6 +35,7 @@ func DetectCompositor() Compositor {
|
||||
niriSocket := os.Getenv("NIRI_SOCKET")
|
||||
swaySocket := os.Getenv("SWAYSOCK")
|
||||
scrollSocket := os.Getenv("SCROLLSOCK")
|
||||
miracleSocket := os.Getenv("MIRACLESOCK")
|
||||
|
||||
switch {
|
||||
case niriSocket != "":
|
||||
@@ -46,7 +48,11 @@ func DetectCompositor() Compositor {
|
||||
detectedCompositor = CompositorScroll
|
||||
return detectedCompositor
|
||||
}
|
||||
|
||||
case miracleSocket != "":
|
||||
if _, err := os.Stat(miracleSocket); err == nil {
|
||||
detectedCompositor = CompositorMiracle
|
||||
return detectedCompositor
|
||||
}
|
||||
case swaySocket != "":
|
||||
if _, err := os.Stat(swaySocket); err == nil {
|
||||
detectedCompositor = CompositorSway
|
||||
@@ -260,6 +266,25 @@ func getScrollFocusedMonitor() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func getMiracleFocusedMonitor() string {
|
||||
output, err := exec.Command("miraclemsg", "-t", "get_workspaces").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var workspaces []swayWorkspace
|
||||
if err := json.Unmarshal(output, &workspaces); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, ws := range workspaces {
|
||||
if ws.Focused {
|
||||
return ws.Output
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type niriWorkspace struct {
|
||||
Output string `json:"output"`
|
||||
IsFocused bool `json:"is_focused"`
|
||||
@@ -407,6 +432,8 @@ func GetFocusedMonitor() string {
|
||||
return getSwayFocusedMonitor()
|
||||
case CompositorScroll:
|
||||
return getScrollFocusedMonitor()
|
||||
case CompositorMiracle:
|
||||
return getMiracleFocusedMonitor()
|
||||
case CompositorNiri:
|
||||
return getNiriFocusedMonitor()
|
||||
case CompositorDWL:
|
||||
|
||||
@@ -108,7 +108,7 @@ func NewRegionSelector(s *Screenshoter) *RegionSelector {
|
||||
screenshoter: s,
|
||||
outputs: make(map[uint32]*WaylandOutput),
|
||||
preCapture: make(map[*WaylandOutput]*PreCapture),
|
||||
showCapturedCursor: true,
|
||||
showCapturedCursor: s.config.Cursor == CursorOn,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -453,10 +453,7 @@ func (s *Screenshoter) blitBuffer(dst, src *ShmBuffer, dstX, dstY int, yInverted
|
||||
}
|
||||
|
||||
func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult, error) {
|
||||
cursor := int32(0)
|
||||
if s.config.IncludeCursor {
|
||||
cursor = 1
|
||||
}
|
||||
cursor := int32(s.config.Cursor)
|
||||
|
||||
frame, err := s.screencopy.CaptureOutput(cursor, output.wlOutput)
|
||||
if err != nil {
|
||||
@@ -624,10 +621,7 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
|
||||
}
|
||||
}
|
||||
|
||||
cursor := int32(0)
|
||||
if s.config.IncludeCursor {
|
||||
cursor = 1
|
||||
}
|
||||
cursor := int32(s.config.Cursor)
|
||||
|
||||
frame, err := s.screencopy.CaptureOutputRegion(cursor, output.wlOutput, localX, localY, w, h)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,11 +3,11 @@ package screenshot
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
type ThemeColors struct {
|
||||
@@ -83,12 +83,11 @@ func getColorsFilePath() string {
|
||||
}
|
||||
|
||||
func isLightMode() bool {
|
||||
out, err := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme").Output()
|
||||
scheme, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
scheme := strings.TrimSpace(string(out))
|
||||
switch scheme {
|
||||
case "'prefer-light'", "'default'":
|
||||
return true
|
||||
|
||||
@@ -19,6 +19,13 @@ const (
|
||||
FormatPPM
|
||||
)
|
||||
|
||||
type CursorMode int
|
||||
|
||||
const (
|
||||
CursorOff CursorMode = iota
|
||||
CursorOn
|
||||
)
|
||||
|
||||
type Region struct {
|
||||
X int32 `json:"x"`
|
||||
Y int32 `json:"y"`
|
||||
@@ -42,29 +49,29 @@ type Output struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Mode Mode
|
||||
OutputName string
|
||||
IncludeCursor bool
|
||||
Format Format
|
||||
Quality int
|
||||
OutputDir string
|
||||
Filename string
|
||||
Clipboard bool
|
||||
SaveFile bool
|
||||
Notify bool
|
||||
Stdout bool
|
||||
Mode Mode
|
||||
OutputName string
|
||||
Cursor CursorMode
|
||||
Format Format
|
||||
Quality int
|
||||
OutputDir string
|
||||
Filename string
|
||||
Clipboard bool
|
||||
SaveFile bool
|
||||
Notify bool
|
||||
Stdout bool
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Mode: ModeRegion,
|
||||
IncludeCursor: false,
|
||||
Format: FormatPNG,
|
||||
Quality: 90,
|
||||
OutputDir: "",
|
||||
Filename: "",
|
||||
Clipboard: true,
|
||||
SaveFile: true,
|
||||
Notify: true,
|
||||
Mode: ModeRegion,
|
||||
Cursor: CursorOff,
|
||||
Format: FormatPNG,
|
||||
Quality: 90,
|
||||
OutputDir: "",
|
||||
Filename: "",
|
||||
Clipboard: true,
|
||||
SaveFile: true,
|
||||
Notify: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,12 @@ func (m *Manager) Rescan() {
|
||||
}
|
||||
}
|
||||
|
||||
if m.sysfsReady && m.sysfsBackend != nil {
|
||||
if err := m.sysfsBackend.Rescan(); err != nil {
|
||||
log.Debugf("Sysfs rescan failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
m.updateState()
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,10 @@ func shouldSuppressDevice(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *SysfsBackend) Rescan() error {
|
||||
return b.scanDevices()
|
||||
}
|
||||
|
||||
func (b *SysfsBackend) GetDevices() ([]Device, error) {
|
||||
devices := make([]Device, 0)
|
||||
|
||||
|
||||
@@ -146,9 +146,16 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.TouchEntry(uint64(id)); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
if entry.Pinned {
|
||||
if err := m.CreateHistoryEntryFromPinned(entry); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := m.TouchEntry(uint64(id)); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"})
|
||||
|
||||
@@ -232,8 +232,15 @@ func (m *Manager) setupDataDeviceSync() {
|
||||
return
|
||||
}
|
||||
|
||||
prevOffer := m.currentOffer
|
||||
m.currentOffer = offer
|
||||
|
||||
if prevOffer != nil && prevOffer != offer {
|
||||
m.offerMutex.Lock()
|
||||
delete(m.offerMimeTypes, prevOffer)
|
||||
m.offerMutex.Unlock()
|
||||
}
|
||||
|
||||
m.offerMutex.RLock()
|
||||
mimes := m.offerMimeTypes[offer]
|
||||
m.offerMutex.RUnlock()
|
||||
@@ -388,6 +395,10 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
|
||||
if extractHash(v) != hash {
|
||||
continue
|
||||
}
|
||||
entry, err := decodeEntry(v)
|
||||
if err == nil && entry.Pinned {
|
||||
continue
|
||||
}
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -583,20 +594,26 @@ func (m *Manager) uriListPreview(data []byte) (string, bool) {
|
||||
uris = strings.Split(text, "\n")
|
||||
}
|
||||
|
||||
if len(uris) > 1 {
|
||||
return fmt.Sprintf("[[ %d files ]]", len(uris)), false
|
||||
}
|
||||
|
||||
if len(uris) == 1 && strings.HasPrefix(uris[0], "file://") {
|
||||
filePath := strings.TrimPrefix(uris[0], "file://")
|
||||
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil || info.IsDir() {
|
||||
return m.textPreview(data), false
|
||||
}
|
||||
|
||||
cfg := m.getConfig()
|
||||
if info.Size() <= cfg.MaxEntrySize {
|
||||
if imgData, err := os.ReadFile(filePath); err == nil {
|
||||
if config, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData)); err == nil {
|
||||
return fmt.Sprintf("[[ file %s %s %dx%d ]]", filepath.Base(filePath), imgFmt, config.Width, config.Height), true
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), false
|
||||
}
|
||||
}
|
||||
|
||||
if len(uris) > 1 {
|
||||
return fmt.Sprintf("[[ %d files ]]", len(uris)), false
|
||||
return fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), false
|
||||
}
|
||||
|
||||
return m.textPreview(data), false
|
||||
@@ -619,6 +636,11 @@ func (m *Manager) tryReadImageFromURI(data []byte) ([]byte, string, bool) {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
cfg := m.getConfig()
|
||||
if info.Size() > cfg.MaxEntrySize {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
imgData, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, "", false
|
||||
@@ -842,6 +864,62 @@ func (m *Manager) TouchEntry(id uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
|
||||
if m.db == nil {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
// Create a new unpinned entry with the same data
|
||||
newEntry := Entry{
|
||||
Data: pinnedEntry.Data,
|
||||
MimeType: pinnedEntry.MimeType,
|
||||
Size: pinnedEntry.Size,
|
||||
Timestamp: time.Now(),
|
||||
IsImage: pinnedEntry.IsImage,
|
||||
Preview: pinnedEntry.Preview,
|
||||
Pinned: false,
|
||||
}
|
||||
|
||||
if err := m.storeEntryWithoutDedup(newEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.updateState()
|
||||
m.notifySubscribers()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) storeEntryWithoutDedup(entry Entry) error {
|
||||
if m.db == nil {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
entry.Hash = computeHash(entry.Data)
|
||||
|
||||
return m.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("clipboard"))
|
||||
|
||||
id, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry.ID = id
|
||||
|
||||
encoded, err := encodeEntry(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := b.Put(itob(id), encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.trimLengthInTx(b)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) ClearHistory() {
|
||||
if m.db == nil {
|
||||
return
|
||||
@@ -1419,6 +1497,37 @@ func (m *Manager) PinEntry(id uint64) error {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
entryToPin, err := m.GetEntry(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var hashExists bool
|
||||
if err := m.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("clipboard"))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
c := b.Cursor()
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
entry, err := decodeEntry(v)
|
||||
if err != nil || !entry.Pinned {
|
||||
continue
|
||||
}
|
||||
if entry.Hash == entryToPin.Hash {
|
||||
hashExists = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hashExists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check pinned count
|
||||
cfg := m.getConfig()
|
||||
pinnedCount := 0
|
||||
@@ -1443,7 +1552,7 @@ func (m *Manager) PinEntry(id uint64) error {
|
||||
return fmt.Errorf("maximum pinned entries reached (%d)", cfg.MaxPinned)
|
||||
}
|
||||
|
||||
err := m.db.Update(func(tx *bolt.Tx) error {
|
||||
err = m.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("clipboard"))
|
||||
v := b.Get(itob(id))
|
||||
if v == nil {
|
||||
@@ -1615,6 +1724,8 @@ func (m *Manager) CopyFile(filePath string) error {
|
||||
m.updateState()
|
||||
m.notifySubscribers()
|
||||
|
||||
_, imgMime, imgErr := image.DecodeConfig(bytes.NewReader(fileData))
|
||||
|
||||
m.post(func() {
|
||||
if m.dataControlMgr == nil || m.dataDevice == nil {
|
||||
log.Error("Data control manager or device not initialized")
|
||||
@@ -1638,6 +1749,11 @@ func (m *Manager) CopyFile(filePath string) error {
|
||||
{"text/plain", []byte(filePath)},
|
||||
}
|
||||
|
||||
if imgErr == nil {
|
||||
imgMimeType := "image/" + imgMime
|
||||
offers = append(offers, offer{imgMimeType, fileData})
|
||||
}
|
||||
|
||||
offerData := make(map[string][]byte)
|
||||
for _, o := range offers {
|
||||
if err := source.Offer(o.mime); err != nil {
|
||||
|
||||
@@ -276,9 +276,7 @@ func (m *Manager) UnsubscribeClient(clientID string) {
|
||||
})
|
||||
|
||||
for _, subID := range toDelete {
|
||||
if err := m.Unsubscribe(subID); err != nil {
|
||||
log.Warnf("dbus: failed to unsubscribe %s: %v", subID, err)
|
||||
}
|
||||
_ = m.Unsubscribe(subID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package freedesktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
@@ -107,22 +105,8 @@ func (m *Manager) GetUserIconFile(username string) (string, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) SetIconTheme(iconTheme string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
check := exec.CommandContext(ctx, "gsettings", "writable", "org.gnome.desktop.interface", "icon-theme")
|
||||
if err := check.Run(); err == nil {
|
||||
cmd := exec.CommandContext(ctx, "gsettings", "set", "org.gnome.desktop.interface", "icon-theme", iconTheme)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("gsettings set failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "icon-theme", iconTheme); err != nil {
|
||||
return fmt.Errorf("failed to set icon theme: %w", err)
|
||||
}
|
||||
|
||||
checkDconf := exec.CommandContext(ctx, "dconf", "write", "/org/gnome/desktop/interface/icon-theme", fmt.Sprintf("'%s'", iconTheme))
|
||||
if err := checkDconf.Run(); err != nil {
|
||||
return fmt.Errorf("both gsettings and dconf unavailable or failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,4 +16,8 @@ const (
|
||||
dbusScreensaverPath = "/ScreenSaver"
|
||||
dbusScreensaverPath2 = "/org/freedesktop/ScreenSaver"
|
||||
dbusScreensaverInterface = "org.freedesktop.ScreenSaver"
|
||||
|
||||
dbusGnomeScreensaverName = "org.gnome.ScreenSaver"
|
||||
dbusGnomeScreensaverPath = "/org/gnome/ScreenSaver"
|
||||
dbusGnomeScreensaverInterface = "org.gnome.ScreenSaver"
|
||||
)
|
||||
|
||||
@@ -191,6 +191,12 @@ func (m *Manager) Close() {
|
||||
return true
|
||||
})
|
||||
|
||||
m.screensaverSubscribers.Range(func(key string, ch chan ScreensaverState) bool {
|
||||
close(ch)
|
||||
m.screensaverSubscribers.Delete(key)
|
||||
return true
|
||||
})
|
||||
|
||||
if m.systemConn != nil {
|
||||
m.systemConn.Close()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package freedesktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -15,6 +16,51 @@ type screensaverHandler struct {
|
||||
manager *Manager
|
||||
}
|
||||
|
||||
func screensaverIntrospectIface(ifaceName string) introspect.Interface {
|
||||
return introspect.Interface{
|
||||
Name: ifaceName,
|
||||
Methods: []introspect.Method{
|
||||
{
|
||||
Name: "Inhibit",
|
||||
Args: []introspect.Arg{
|
||||
{Name: "application_name", Type: "s", Direction: "in"},
|
||||
{Name: "reason_for_inhibit", Type: "s", Direction: "in"},
|
||||
{Name: "cookie", Type: "u", Direction: "out"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "UnInhibit",
|
||||
Args: []introspect.Arg{
|
||||
{Name: "cookie", Type: "u", Direction: "in"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GetActive",
|
||||
Args: []introspect.Arg{
|
||||
{Name: "active", Type: "b", Direction: "out"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "SetActive",
|
||||
Args: []introspect.Arg{
|
||||
{Name: "active", Type: "b", Direction: "in"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Lock",
|
||||
},
|
||||
},
|
||||
Signals: []introspect.Signal{
|
||||
{
|
||||
Name: "ActiveChanged",
|
||||
Args: []introspect.Arg{
|
||||
{Name: "new_value", Type: "b"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) initializeScreensaver() error {
|
||||
if m.sessionConn == nil {
|
||||
m.stateMutex.Lock()
|
||||
@@ -23,66 +69,71 @@ func (m *Manager) initializeScreensaver() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
reply, err := m.sessionConn.RequestName(dbusScreensaverName, dbus.NameFlagDoNotQueue)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to request screensaver name: %v", err)
|
||||
m.stateMutex.Lock()
|
||||
m.state.Screensaver.Available = false
|
||||
m.stateMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
if reply != dbus.RequestNameReplyPrimaryOwner {
|
||||
log.Warnf("Screensaver name already owned by another process")
|
||||
m.stateMutex.Lock()
|
||||
m.state.Screensaver.Available = false
|
||||
m.stateMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
handler := &screensaverHandler{manager: m}
|
||||
|
||||
if err := m.sessionConn.Export(handler, dbusScreensaverPath, dbusScreensaverInterface); err != nil {
|
||||
log.Warnf("Failed to export screensaver on %s: %v", dbusScreensaverPath, err)
|
||||
m.screensaverFreedesktopClaimed = m.claimScreensaverName(handler,
|
||||
dbusScreensaverName, dbusScreensaverInterface, dbusScreensaverPath, dbusScreensaverPath2)
|
||||
m.screensaverGnomeClaimed = m.claimScreensaverName(handler,
|
||||
dbusGnomeScreensaverName, dbusGnomeScreensaverInterface, dbusGnomeScreensaverPath)
|
||||
|
||||
if !m.screensaverFreedesktopClaimed && !m.screensaverGnomeClaimed {
|
||||
log.Warn("No screensaver interface could be claimed")
|
||||
m.stateMutex.Lock()
|
||||
m.state.Screensaver.Available = false
|
||||
m.stateMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := m.sessionConn.Export(handler, dbusScreensaverPath2, dbusScreensaverInterface); err != nil {
|
||||
log.Warnf("Failed to export screensaver on %s: %v", dbusScreensaverPath2, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
introNode := &introspect.Node{
|
||||
Name: dbusScreensaverPath,
|
||||
Interfaces: []introspect.Interface{
|
||||
introspect.IntrospectData,
|
||||
{Name: dbusScreensaverInterface},
|
||||
},
|
||||
}
|
||||
if err := m.sessionConn.Export(introspect.NewIntrospectable(introNode), dbusScreensaverPath, "org.freedesktop.DBus.Introspectable"); err != nil {
|
||||
log.Warnf("Failed to export introspectable on %s: %v", dbusScreensaverPath, err)
|
||||
}
|
||||
|
||||
introNode2 := &introspect.Node{
|
||||
Name: dbusScreensaverPath2,
|
||||
Interfaces: []introspect.Interface{
|
||||
introspect.IntrospectData,
|
||||
{Name: dbusScreensaverInterface},
|
||||
},
|
||||
}
|
||||
if err := m.sessionConn.Export(introspect.NewIntrospectable(introNode2), dbusScreensaverPath2, "org.freedesktop.DBus.Introspectable"); err != nil {
|
||||
log.Warnf("Failed to export introspectable on %s: %v", dbusScreensaverPath2, err)
|
||||
}
|
||||
|
||||
go m.watchPeerDisconnects()
|
||||
|
||||
m.stateMutex.Lock()
|
||||
m.state.Screensaver.Available = true
|
||||
m.state.Screensaver.Active = false
|
||||
m.state.Screensaver.Inhibited = false
|
||||
m.state.Screensaver.Inhibitors = []ScreensaverInhibitor{}
|
||||
m.stateMutex.Unlock()
|
||||
|
||||
log.Info("Screensaver inhibit listener initialized")
|
||||
log.Info("Screensaver listener initialized")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface string, paths ...dbus.ObjectPath) bool {
|
||||
reply, err := m.sessionConn.RequestName(name, dbus.NameFlagDoNotQueue)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to request screensaver name %s: %v", name, err)
|
||||
return false
|
||||
}
|
||||
if reply != dbus.RequestNameReplyPrimaryOwner {
|
||||
log.Warnf("Screensaver name %s already owned by another process", name)
|
||||
return false
|
||||
}
|
||||
if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {
|
||||
log.Warnf("Failed to export screensaver on %s: %v", name, err)
|
||||
return false
|
||||
}
|
||||
log.Infof("Claimed %s on session bus", name)
|
||||
return true
|
||||
}
|
||||
|
||||
// exportScreensaverOnPaths exports the handler and introspection on the given
|
||||
// paths under the specified interface name.
|
||||
func (m *Manager) exportScreensaverOnPaths(handler *screensaverHandler, ifaceName string, paths ...dbus.ObjectPath) error {
|
||||
iface := screensaverIntrospectIface(ifaceName)
|
||||
for _, path := range paths {
|
||||
if err := m.sessionConn.Export(handler, path, ifaceName); err != nil {
|
||||
return fmt.Errorf("export handler on %s: %w", path, err)
|
||||
}
|
||||
node := &introspect.Node{
|
||||
Name: string(path),
|
||||
Interfaces: []introspect.Interface{
|
||||
introspect.IntrospectData,
|
||||
iface,
|
||||
},
|
||||
}
|
||||
if err := m.sessionConn.Export(introspect.NewIntrospectable(node), path, "org.freedesktop.DBus.Introspectable"); err != nil {
|
||||
log.Warnf("Failed to export introspectable on %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -248,3 +299,51 @@ func (m *Manager) NotifyScreensaverSubscribers() {
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (h *screensaverHandler) GetActive() (bool, *dbus.Error) {
|
||||
h.manager.stateMutex.RLock()
|
||||
active := h.manager.state.Screensaver.Active
|
||||
h.manager.stateMutex.RUnlock()
|
||||
return active, nil
|
||||
}
|
||||
|
||||
func (h *screensaverHandler) SetActive(active bool) *dbus.Error {
|
||||
h.manager.SetScreenLockActive(active)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *screensaverHandler) Lock() *dbus.Error {
|
||||
h.manager.SetScreenLockActive(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetScreenLockActive(active bool) {
|
||||
m.stateMutex.Lock()
|
||||
changed := m.state.Screensaver.Active != active
|
||||
m.state.Screensaver.Active = active
|
||||
m.stateMutex.Unlock()
|
||||
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("Screen lock active changed: %v", active)
|
||||
defer m.NotifyScreensaverSubscribers()
|
||||
|
||||
if m.sessionConn == nil {
|
||||
return
|
||||
}
|
||||
if m.screensaverFreedesktopClaimed {
|
||||
if err := m.sessionConn.Emit(dbusScreensaverPath, dbusScreensaverInterface+".ActiveChanged", active); err != nil {
|
||||
log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusScreensaverPath, err)
|
||||
}
|
||||
if err := m.sessionConn.Emit(dbusScreensaverPath2, dbusScreensaverInterface+".ActiveChanged", active); err != nil {
|
||||
log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusScreensaverPath2, err)
|
||||
}
|
||||
}
|
||||
if m.screensaverGnomeClaimed {
|
||||
if err := m.sessionConn.Emit(dbusGnomeScreensaverPath, dbusGnomeScreensaverInterface+".ActiveChanged", active); err != nil {
|
||||
log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusGnomeScreensaverPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
core/internal/server/freedesktop/screensaver_test.go
Normal file
102
core/internal/server/freedesktop/screensaver_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package freedesktop
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSetScreenLockActive_ChangesState(t *testing.T) {
|
||||
manager := &Manager{
|
||||
state: &FreedeskState{
|
||||
Screensaver: ScreensaverState{Available: true},
|
||||
},
|
||||
stateMutex: sync.RWMutex{},
|
||||
}
|
||||
|
||||
assert.False(t, manager.GetScreensaverState().Active)
|
||||
|
||||
manager.SetScreenLockActive(true)
|
||||
assert.True(t, manager.GetScreensaverState().Active)
|
||||
|
||||
manager.SetScreenLockActive(false)
|
||||
assert.False(t, manager.GetScreensaverState().Active)
|
||||
}
|
||||
|
||||
func TestSetScreenLockActive_NoChangeNoDuplicate(t *testing.T) {
|
||||
ch := make(chan ScreensaverState, 64)
|
||||
manager := &Manager{
|
||||
state: &FreedeskState{
|
||||
Screensaver: ScreensaverState{Available: true, Active: false},
|
||||
},
|
||||
stateMutex: sync.RWMutex{},
|
||||
}
|
||||
manager.screensaverSubscribers.Store("test", ch)
|
||||
defer manager.screensaverSubscribers.Delete("test")
|
||||
|
||||
// Setting to same value should not notify
|
||||
manager.SetScreenLockActive(false)
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("should not have received notification for no-change")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// Expected: no notification
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetScreenLockActive_NotifiesSubscribers(t *testing.T) {
|
||||
ch := make(chan ScreensaverState, 64)
|
||||
manager := &Manager{
|
||||
state: &FreedeskState{
|
||||
Screensaver: ScreensaverState{Available: true, Active: false},
|
||||
},
|
||||
stateMutex: sync.RWMutex{},
|
||||
}
|
||||
manager.screensaverSubscribers.Store("test", ch)
|
||||
defer manager.screensaverSubscribers.Delete("test")
|
||||
|
||||
manager.SetScreenLockActive(true)
|
||||
|
||||
select {
|
||||
case state := <-ch:
|
||||
assert.True(t, state.Active)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout waiting for subscriber notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetScreenLockActive_NilSessionConn(t *testing.T) {
|
||||
manager := &Manager{
|
||||
state: &FreedeskState{
|
||||
Screensaver: ScreensaverState{Available: true},
|
||||
},
|
||||
stateMutex: sync.RWMutex{},
|
||||
}
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
manager.SetScreenLockActive(true)
|
||||
})
|
||||
assert.True(t, manager.GetScreensaverState().Active)
|
||||
}
|
||||
|
||||
func TestGetActive_ReturnsCurrentState(t *testing.T) {
|
||||
manager := &Manager{
|
||||
state: &FreedeskState{
|
||||
Screensaver: ScreensaverState{Available: true, Active: true},
|
||||
},
|
||||
stateMutex: sync.RWMutex{},
|
||||
}
|
||||
|
||||
handler := &screensaverHandler{manager: manager}
|
||||
active, dbusErr := handler.GetActive()
|
||||
assert.Nil(t, dbusErr)
|
||||
assert.True(t, active)
|
||||
}
|
||||
|
||||
func TestScreensaverState_ActiveDefaultsFalse(t *testing.T) {
|
||||
state := ScreensaverState{}
|
||||
assert.False(t, state.Active)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type ScreensaverInhibitor struct {
|
||||
|
||||
type ScreensaverState struct {
|
||||
Available bool `json:"available"`
|
||||
Active bool `json:"active"`
|
||||
Inhibited bool `json:"inhibited"`
|
||||
Inhibitors []ScreensaverInhibitor `json:"inhibitors"`
|
||||
}
|
||||
@@ -50,14 +51,16 @@ type FreedeskState struct {
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
state *FreedeskState
|
||||
stateMutex sync.RWMutex
|
||||
systemConn *dbus.Conn
|
||||
sessionConn *dbus.Conn
|
||||
accountsObj dbus.BusObject
|
||||
settingsObj dbus.BusObject
|
||||
currentUID uint64
|
||||
subscribers syncmap.Map[string, chan FreedeskState]
|
||||
screensaverSubscribers syncmap.Map[string, chan ScreensaverState]
|
||||
screensaverCookieCounter uint32
|
||||
state *FreedeskState
|
||||
stateMutex sync.RWMutex
|
||||
systemConn *dbus.Conn
|
||||
sessionConn *dbus.Conn
|
||||
accountsObj dbus.BusObject
|
||||
settingsObj dbus.BusObject
|
||||
currentUID uint64
|
||||
subscribers syncmap.Map[string, chan FreedeskState]
|
||||
screensaverSubscribers syncmap.Map[string, chan ScreensaverState]
|
||||
screensaverCookieCounter uint32
|
||||
screensaverFreedesktopClaimed bool
|
||||
screensaverGnomeClaimed bool
|
||||
}
|
||||
|
||||
@@ -32,8 +32,10 @@ type SecretAgent struct {
|
||||
backend *NetworkManagerBackend
|
||||
}
|
||||
|
||||
type nmVariantMap map[string]dbus.Variant
|
||||
type nmSettingMap map[string]nmVariantMap
|
||||
type (
|
||||
nmVariantMap map[string]dbus.Variant
|
||||
nmSettingMap map[string]nmVariantMap
|
||||
)
|
||||
|
||||
const introspectXML = `
|
||||
<node>
|
||||
@@ -122,7 +124,7 @@ func (a *SecretAgent) GetSecrets(
|
||||
|
||||
connType, displayName, vpnSvc := readConnTypeAndName(conn)
|
||||
ssid := readSSID(conn)
|
||||
fields := fieldsNeeded(settingName, hints)
|
||||
fields := fieldsNeeded(settingName, hints, conn)
|
||||
vpnPasswordFlags := readVPNPasswordFlags(conn, settingName)
|
||||
|
||||
log.Infof("[SecretAgent] connType=%s, name=%s, vpnSvc=%s, fields=%v, flags=%d, vpnPasswordFlags=%d", connType, displayName, vpnSvc, fields, flags, vpnPasswordFlags)
|
||||
@@ -218,8 +220,16 @@ func (a *SecretAgent) GetSecrets(
|
||||
out[settingName] = nmVariantMap{}
|
||||
return out, nil
|
||||
} else if passwordFlags&NM_SETTING_SECRET_FLAG_AGENT_OWNED != 0 {
|
||||
log.Warnf("[SecretAgent] Secrets are agent-owned but we don't store secrets (flags=%d) - returning NoSecrets error", passwordFlags)
|
||||
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
||||
switch settingName {
|
||||
case "802-11-wireless-security":
|
||||
fields = []string{"psk"}
|
||||
case "802-1x":
|
||||
fields = infer8021xFields(conn)
|
||||
default:
|
||||
log.Warnf("[SecretAgent] Agent-owned secrets for unhandled setting %s (flags=%d)", settingName, passwordFlags)
|
||||
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
||||
}
|
||||
log.Infof("[SecretAgent] Agent-owned secrets, inferred fields: %v", fields)
|
||||
} else {
|
||||
log.Infof("[SecretAgent] No secrets needed, using system stored secrets (flags=%d)", passwordFlags)
|
||||
out := nmSettingMap{}
|
||||
@@ -300,6 +310,63 @@ func (a *SecretAgent) GetSecrets(
|
||||
return out, nil
|
||||
}
|
||||
a.backend.cachedVPNCredsMu.Unlock()
|
||||
|
||||
a.backend.cachedGPSamlMu.Lock()
|
||||
cachedGPSaml := a.backend.cachedGPSamlCookie
|
||||
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
|
||||
|
||||
return buildGPSamlSecretsResponse(settingName, cachedGPSaml.Cookie, cachedGPSaml.Host, cachedGPSaml.Fingerprint), nil
|
||||
}
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
if len(fields) == 1 && fields[0] == "gp-saml" {
|
||||
gateway := ""
|
||||
protocol := ""
|
||||
if vpnSettings, ok := conn["vpn"]; ok {
|
||||
if dataVariant, ok := vpnSettings["data"]; ok {
|
||||
if dataMap, ok := dataVariant.Value().(map[string]string); ok {
|
||||
if gw, ok := dataMap["gateway"]; ok {
|
||||
gateway = gw
|
||||
}
|
||||
if proto, ok := dataMap["protocol"]; ok && proto != "" {
|
||||
protocol = proto
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if protocol != "gp" {
|
||||
return nil, dbus.MakeFailedError(fmt.Errorf("gp-saml auth only supported for GlobalProtect (protocol=gp), got: %s", protocol))
|
||||
}
|
||||
|
||||
log.Infof("[SecretAgent] Starting GlobalProtect SAML authentication for gateway=%s", gateway)
|
||||
|
||||
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer samlCancel()
|
||||
|
||||
authResult, err := a.backend.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
|
||||
if err != nil {
|
||||
log.Warnf("[SecretAgent] GlobalProtect SAML authentication failed: %v", err)
|
||||
return nil, dbus.MakeFailedError(fmt.Errorf("GlobalProtect SAML authentication failed: %w", err))
|
||||
}
|
||||
|
||||
log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager")
|
||||
|
||||
a.backend.cachedGPSamlMu.Lock()
|
||||
a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{
|
||||
ConnectionUUID: connUuid,
|
||||
Cookie: authResult.Cookie,
|
||||
Host: authResult.Host,
|
||||
User: authResult.User,
|
||||
Fingerprint: authResult.Fingerprint,
|
||||
}
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
return buildGPSamlSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
@@ -418,8 +485,19 @@ func (a *SecretAgent) GetSecrets(
|
||||
log.Infof("[SecretAgent] Cached PKCS11 PIN for potential re-request")
|
||||
}
|
||||
case "802-1x":
|
||||
out[settingName] = sec
|
||||
log.Infof("[SecretAgent] Returning 802-1x enterprise secrets with %d fields", len(sec))
|
||||
secretsOnly := nmVariantMap{}
|
||||
for k, v := range reply.Secrets {
|
||||
switch k {
|
||||
case "password", "private-key-password", "phase2-private-key-password", "pin":
|
||||
secretsOnly[k] = dbus.MakeVariant(v)
|
||||
}
|
||||
}
|
||||
out[settingName] = secretsOnly
|
||||
|
||||
if identity, ok := reply.Secrets["identity"]; ok && identity != "" {
|
||||
a.save8021xIdentity(path, identity)
|
||||
}
|
||||
log.Infof("[SecretAgent] Returning 802-1x enterprise secrets with %d fields", len(secretsOnly))
|
||||
default:
|
||||
out[settingName] = sec
|
||||
}
|
||||
@@ -434,63 +512,6 @@ func (a *SecretAgent) GetSecrets(
|
||||
}
|
||||
a.backend.pendingVPNSaveMu.Unlock()
|
||||
log.Infof("[SecretAgent] Queued credentials persist for after connection succeeds")
|
||||
} else if reply.Save && settingName != "vpn" {
|
||||
// Non-VPN save logic
|
||||
go func() {
|
||||
log.Infof("[SecretAgent] Persisting secrets with Update2: path=%s, setting=%s", path, settingName)
|
||||
|
||||
connObj := a.conn.Object("org.freedesktop.NetworkManager", path)
|
||||
var existingSettings map[string]map[string]dbus.Variant
|
||||
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
|
||||
log.Warnf("[SecretAgent] GetSettings failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
settings := make(map[string]map[string]dbus.Variant)
|
||||
if connSection, ok := existingSettings["connection"]; ok {
|
||||
settings["connection"] = connSection
|
||||
}
|
||||
|
||||
switch settingName {
|
||||
case "802-11-wireless-security":
|
||||
wifiSec, ok := existingSettings["802-11-wireless-security"]
|
||||
if !ok {
|
||||
wifiSec = make(map[string]dbus.Variant)
|
||||
}
|
||||
wifiSec["psk-flags"] = dbus.MakeVariant(uint32(0))
|
||||
|
||||
if psk, ok := reply.Secrets["psk"]; ok {
|
||||
wifiSec["psk"] = dbus.MakeVariant(psk)
|
||||
log.Infof("[SecretAgent] Updated WiFi settings: psk-flags=0")
|
||||
}
|
||||
settings["802-11-wireless-security"] = wifiSec
|
||||
|
||||
case "802-1x":
|
||||
dot1x, ok := existingSettings["802-1x"]
|
||||
if !ok {
|
||||
dot1x = make(map[string]dbus.Variant)
|
||||
}
|
||||
dot1x["password-flags"] = dbus.MakeVariant(uint32(0))
|
||||
|
||||
if password, ok := reply.Secrets["password"]; ok {
|
||||
dot1x["password"] = dbus.MakeVariant(password)
|
||||
log.Infof("[SecretAgent] Updated 802.1x settings: password-flags=0")
|
||||
}
|
||||
settings["802-1x"] = dot1x
|
||||
}
|
||||
|
||||
// Call Update2 with correct signature:
|
||||
// Update2(IN settings, IN flags, IN args) -> OUT result
|
||||
// flags: 0x1 = to-disk
|
||||
var result map[string]dbus.Variant
|
||||
err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
|
||||
settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result)
|
||||
if err != nil {
|
||||
log.Warnf("[SecretAgent] Update2(to-disk) failed: %v", err)
|
||||
} else {
|
||||
log.Infof("[SecretAgent] Successfully persisted secrets to disk for %s", settingName)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return out, nil
|
||||
@@ -523,6 +544,35 @@ func (a *SecretAgent) Introspect() (string, *dbus.Error) {
|
||||
return introspectXML, nil
|
||||
}
|
||||
|
||||
func (a *SecretAgent) save8021xIdentity(path dbus.ObjectPath, identity string) {
|
||||
connObj := a.conn.Object("org.freedesktop.NetworkManager", path)
|
||||
var existing map[string]map[string]dbus.Variant
|
||||
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existing); err != nil {
|
||||
log.Warnf("[SecretAgent] Failed to get settings for identity save: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
settings := make(map[string]map[string]dbus.Variant)
|
||||
if connSection, ok := existing["connection"]; ok {
|
||||
settings["connection"] = connSection
|
||||
}
|
||||
|
||||
dot1x, ok := existing["802-1x"]
|
||||
if !ok {
|
||||
dot1x = make(map[string]dbus.Variant)
|
||||
}
|
||||
dot1x["identity"] = dbus.MakeVariant(identity)
|
||||
settings["802-1x"] = dot1x
|
||||
|
||||
var result map[string]dbus.Variant
|
||||
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
|
||||
settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil {
|
||||
log.Warnf("[SecretAgent] Failed to save 802.1x identity: %v", err)
|
||||
return
|
||||
}
|
||||
log.Infof("[SecretAgent] Saved 802.1x identity to connection profile")
|
||||
}
|
||||
|
||||
func readSSID(conn map[string]nmVariantMap) string {
|
||||
if w, ok := conn["802-11-wireless"]; ok {
|
||||
if v, ok := w["ssid"]; ok {
|
||||
@@ -564,12 +614,15 @@ func readConnTypeAndName(conn map[string]nmVariantMap) (string, string, string)
|
||||
return connType, name, svc
|
||||
}
|
||||
|
||||
func fieldsNeeded(setting string, hints []string) []string {
|
||||
func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap) []string {
|
||||
switch setting {
|
||||
case "802-11-wireless-security":
|
||||
return []string{"psk"}
|
||||
case "802-1x":
|
||||
return []string{"identity", "password"}
|
||||
if len(hints) > 0 {
|
||||
return hints
|
||||
}
|
||||
return infer8021xFields(conn)
|
||||
case "vpn":
|
||||
return hints
|
||||
default:
|
||||
@@ -577,6 +630,41 @@ func fieldsNeeded(setting string, hints []string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func infer8021xFields(conn map[string]nmVariantMap) []string {
|
||||
dot1x, ok := conn["802-1x"]
|
||||
if !ok {
|
||||
return []string{"identity", "password"}
|
||||
}
|
||||
|
||||
var fields []string
|
||||
|
||||
if v, ok := dot1x["identity"]; ok {
|
||||
if id, ok := v.Value().(string); ok && id != "" {
|
||||
// identity already stored, don't ask again
|
||||
} else {
|
||||
fields = append(fields, "identity")
|
||||
}
|
||||
} else {
|
||||
fields = append(fields, "identity")
|
||||
}
|
||||
|
||||
var eapMethods []string
|
||||
if v, ok := dot1x["eap"]; ok {
|
||||
if methods, ok := v.Value().([]string); ok {
|
||||
eapMethods = methods
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(eapMethods) > 0 && eapMethods[0] == "tls":
|
||||
fields = append(fields, "private-key-password")
|
||||
default:
|
||||
fields = append(fields, "password")
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func buildFieldsInfo(setting string, fields []string, vpnService string) []FieldInfo {
|
||||
result := make([]FieldInfo, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
@@ -630,12 +718,25 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
|
||||
|
||||
switch {
|
||||
case strings.Contains(vpnService, "openconnect"):
|
||||
protocol := dataMap["protocol"]
|
||||
authType := dataMap["authtype"]
|
||||
userCert := dataMap["usercert"]
|
||||
if authType == "cert" && strings.HasPrefix(userCert, "pkcs11:") {
|
||||
username := dataMap["username"]
|
||||
|
||||
if authType == "cert" && strings.HasPrefix(dataMap["usercert"], "pkcs11:") {
|
||||
return []string{"key_pass"}
|
||||
}
|
||||
if dataMap["username"] == "" {
|
||||
|
||||
if needsExternalBrowserAuth(protocol, authType, username, dataMap) {
|
||||
switch protocol {
|
||||
case "gp":
|
||||
log.Infof("[SecretAgent] GlobalProtect SAML auth detected")
|
||||
return []string{"gp-saml"}
|
||||
default:
|
||||
log.Infof("[SecretAgent] External browser auth detected for protocol '%s' but only GlobalProtect (gp) SAML is currently supported, falling back to credentials", protocol)
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
fields = []string{"username", "password"}
|
||||
}
|
||||
case strings.Contains(vpnService, "openvpn"):
|
||||
@@ -654,8 +755,31 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
|
||||
return fields
|
||||
}
|
||||
|
||||
func needsExternalBrowserAuth(protocol, authType, username string, data map[string]string) bool {
|
||||
if method, ok := data["saml-auth-method"]; ok {
|
||||
if method == "REDIRECT" || method == "POST" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if authType != "" && authType != "password" && authType != "cert" {
|
||||
return true
|
||||
}
|
||||
|
||||
switch protocol {
|
||||
case "gp":
|
||||
if authType == "" && username == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func vpnFieldMeta(field, vpnService string) (label string, isSecret bool) {
|
||||
switch field {
|
||||
case "gp-saml":
|
||||
return "GlobalProtect SAML/SSO", false
|
||||
case "key_pass":
|
||||
return "PIN", true
|
||||
case "password":
|
||||
@@ -756,3 +880,18 @@ func reasonFromFlags(flags uint32) string {
|
||||
}
|
||||
return "required"
|
||||
}
|
||||
|
||||
func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
|
||||
out := nmSettingMap{}
|
||||
vpnSec := nmVariantMap{}
|
||||
|
||||
secrets := map[string]string{
|
||||
"cookie": cookie,
|
||||
"gateway": host,
|
||||
"gwcert": fingerprint,
|
||||
}
|
||||
vpnSec["secrets"] = dbus.MakeVariant(secrets)
|
||||
|
||||
out[settingName] = vpnSec
|
||||
return out
|
||||
}
|
||||
|
||||
355
core/internal/server/network/agent_networkmanager_test.go
Normal file
355
core/internal/server/network/agent_networkmanager_test.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNeedsExternalBrowserAuth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
protocol string
|
||||
authType string
|
||||
username string
|
||||
data map[string]string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "GP with saml-auth-method REDIRECT",
|
||||
protocol: "gp",
|
||||
authType: "password",
|
||||
username: "user",
|
||||
data: map[string]string{"saml-auth-method": "REDIRECT"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GP with saml-auth-method POST",
|
||||
protocol: "gp",
|
||||
authType: "password",
|
||||
username: "user",
|
||||
data: map[string]string{"saml-auth-method": "POST"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GP with no authtype and no username",
|
||||
protocol: "gp",
|
||||
authType: "",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GP with username and password authtype",
|
||||
protocol: "gp",
|
||||
authType: "password",
|
||||
username: "john",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "GP with username but no authtype",
|
||||
protocol: "gp",
|
||||
authType: "",
|
||||
username: "john",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "GP with authtype but no username - should detect SAML",
|
||||
protocol: "gp",
|
||||
authType: "",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "pulse with SAML",
|
||||
protocol: "pulse",
|
||||
authType: "",
|
||||
username: "",
|
||||
data: map[string]string{"saml-auth-method": "REDIRECT"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "fortinet with non-password authtype",
|
||||
protocol: "fortinet",
|
||||
authType: "saml",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "anyconnect with cert",
|
||||
protocol: "anyconnect",
|
||||
authType: "cert",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "anyconnect with password",
|
||||
protocol: "anyconnect",
|
||||
authType: "password",
|
||||
username: "user",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty protocol",
|
||||
protocol: "",
|
||||
authType: "",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "GP with cert authtype",
|
||||
protocol: "gp",
|
||||
authType: "cert",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := needsExternalBrowserAuth(tt.protocol, tt.authType, tt.username, tt.data)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGPSamlSecretsResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settingName string
|
||||
cookie string
|
||||
host string
|
||||
fingerprint string
|
||||
}{
|
||||
{
|
||||
name: "all fields populated",
|
||||
settingName: "vpn",
|
||||
cookie: "authcookie=abc123&portal=GATE",
|
||||
host: "vpn.example.com",
|
||||
fingerprint: "pin-sha256:ABCD1234",
|
||||
},
|
||||
{
|
||||
name: "empty fingerprint",
|
||||
settingName: "vpn",
|
||||
cookie: "authcookie=xyz",
|
||||
host: "10.0.0.1",
|
||||
fingerprint: "",
|
||||
},
|
||||
{
|
||||
name: "complex cookie with special chars",
|
||||
settingName: "vpn",
|
||||
cookie: "authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100",
|
||||
host: "connect.seclore.com",
|
||||
fingerprint: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildGPSamlSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
|
||||
|
||||
assert.NotNil(t, result)
|
||||
assert.Contains(t, result, tt.settingName)
|
||||
|
||||
vpnSec := result[tt.settingName]
|
||||
assert.NotNil(t, vpnSec)
|
||||
|
||||
secretsVariant, ok := vpnSec["secrets"]
|
||||
assert.True(t, ok, "secrets key should exist")
|
||||
|
||||
secrets, ok := secretsVariant.Value().(map[string]string)
|
||||
assert.True(t, ok, "secrets should be map[string]string")
|
||||
|
||||
assert.Equal(t, tt.cookie, secrets["cookie"])
|
||||
assert.Equal(t, tt.host, secrets["gateway"])
|
||||
assert.Equal(t, tt.fingerprint, secrets["gwcert"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVpnFieldMeta_GPSaml(t *testing.T) {
|
||||
label, isSecret := vpnFieldMeta("gp-saml", "org.freedesktop.NetworkManager.openconnect")
|
||||
|
||||
assert.Equal(t, "GlobalProtect SAML/SSO", label)
|
||||
assert.False(t, isSecret, "gp-saml should not be marked as secret")
|
||||
}
|
||||
|
||||
func TestVpnFieldMeta_StandardFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
field string
|
||||
vpnService string
|
||||
expectedLabel string
|
||||
expectedSecret bool
|
||||
}{
|
||||
{
|
||||
field: "username",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
expectedLabel: "Username",
|
||||
expectedSecret: false,
|
||||
},
|
||||
{
|
||||
field: "password",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
expectedLabel: "Password",
|
||||
expectedSecret: true,
|
||||
},
|
||||
{
|
||||
field: "key_pass",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
expectedLabel: "PIN",
|
||||
expectedSecret: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.field, func(t *testing.T) {
|
||||
label, isSecret := vpnFieldMeta(tt.field, tt.vpnService)
|
||||
assert.Equal(t, tt.expectedLabel, label)
|
||||
assert.Equal(t, tt.expectedSecret, isSecret)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferVPNFields_GPSaml(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vpnService string
|
||||
dataMap map[string]string
|
||||
expectedLen int
|
||||
shouldHave []string
|
||||
}{
|
||||
{
|
||||
name: "GP with no authtype and no username - should require SAML",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"gp-saml"},
|
||||
},
|
||||
{
|
||||
name: "GP with saml-auth-method REDIRECT",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
"saml-auth-method": "REDIRECT",
|
||||
"username": "john",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"gp-saml"},
|
||||
},
|
||||
{
|
||||
name: "GP with saml-auth-method POST",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
"saml-auth-method": "POST",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"gp-saml"},
|
||||
},
|
||||
{
|
||||
name: "GP with username and password authtype - should use credentials",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
"authtype": "password",
|
||||
"username": "john",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"password"},
|
||||
},
|
||||
{
|
||||
name: "GP with username but no authtype - password only",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
"username": "john",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"password"},
|
||||
},
|
||||
{
|
||||
name: "GP with PKCS11 cert",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
"authtype": "cert",
|
||||
"usercert": "pkcs11:model=PKCS%2315%20emulated;manufacturer=piv_II",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"key_pass"},
|
||||
},
|
||||
{
|
||||
name: "non-GP protocol (anyconnect)",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "anyconnect",
|
||||
"gateway": "vpn.example.com",
|
||||
},
|
||||
expectedLen: 2,
|
||||
shouldHave: []string{"username", "password"},
|
||||
},
|
||||
{
|
||||
name: "OpenVPN with username",
|
||||
vpnService: "org.freedesktop.NetworkManager.openvpn",
|
||||
dataMap: map[string]string{
|
||||
"connection-type": "password",
|
||||
"username": "john",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"password"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Convert dataMap to nmVariantMap
|
||||
vpnSettings := make(nmVariantMap)
|
||||
vpnSettings["data"] = dbus.MakeVariant(tt.dataMap)
|
||||
vpnSettings["service-type"] = dbus.MakeVariant(tt.vpnService)
|
||||
|
||||
conn := make(map[string]nmVariantMap)
|
||||
conn["vpn"] = vpnSettings
|
||||
|
||||
fields := inferVPNFields(conn, tt.vpnService)
|
||||
|
||||
assert.Len(t, fields, tt.expectedLen, "unexpected number of fields")
|
||||
if len(tt.shouldHave) > 0 {
|
||||
for _, expected := range tt.shouldHave {
|
||||
assert.Contains(t, fields, expected, "should contain field: %s", expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNmVariantMap(t *testing.T) {
|
||||
// Test that nmVariantMap and nmSettingMap work correctly
|
||||
settingMap := make(nmSettingMap)
|
||||
variantMap := make(nmVariantMap)
|
||||
|
||||
variantMap["test-key"] = dbus.MakeVariant("test-value")
|
||||
settingMap["test-setting"] = variantMap
|
||||
|
||||
assert.Contains(t, settingMap, "test-setting")
|
||||
assert.Contains(t, settingMap["test-setting"], "test-key")
|
||||
|
||||
value := settingMap["test-setting"]["test-key"].Value()
|
||||
assert.Equal(t, "test-value", value)
|
||||
}
|
||||
@@ -69,12 +69,14 @@ type NetworkManagerBackend struct {
|
||||
lastFailedTime int64
|
||||
failedMutex sync.RWMutex
|
||||
|
||||
pendingVPNSave *pendingVPNCredentials
|
||||
pendingVPNSaveMu sync.Mutex
|
||||
cachedVPNCreds *cachedVPNCredentials
|
||||
cachedVPNCredsMu sync.Mutex
|
||||
cachedPKCS11PIN *cachedPKCS11PIN
|
||||
cachedPKCS11Mu sync.Mutex
|
||||
pendingVPNSave *pendingVPNCredentials
|
||||
pendingVPNSaveMu sync.Mutex
|
||||
cachedVPNCreds *cachedVPNCredentials
|
||||
cachedVPNCredsMu sync.Mutex
|
||||
cachedPKCS11PIN *cachedPKCS11PIN
|
||||
cachedPKCS11Mu sync.Mutex
|
||||
cachedGPSamlCookie *cachedGPSamlCookie
|
||||
cachedGPSamlMu sync.Mutex
|
||||
|
||||
onStateChange func()
|
||||
}
|
||||
@@ -97,6 +99,14 @@ type cachedPKCS11PIN struct {
|
||||
PIN string
|
||||
}
|
||||
|
||||
type cachedGPSamlCookie struct {
|
||||
ConnectionUUID string
|
||||
Cookie string
|
||||
Host string
|
||||
User string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) {
|
||||
var nm gonetworkmanager.NetworkManager
|
||||
var err error
|
||||
|
||||
203
core/internal/server/network/backend_networkmanager_gp_saml.go
Normal file
203
core/internal/server/network/backend_networkmanager_gp_saml.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
type gpSamlAuthResult struct {
|
||||
Cookie string
|
||||
Host string
|
||||
User string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui.
|
||||
// Only supports protocol=gp. Other protocols need their own implementations.
|
||||
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*gpSamlAuthResult, error) {
|
||||
if gateway == "" {
|
||||
return nil, fmt.Errorf("GP SAML auth: gateway is empty")
|
||||
}
|
||||
if protocol != "gp" {
|
||||
return nil, fmt.Errorf("only GlobalProtect (protocol=gp) SAML is supported, got: %s", protocol)
|
||||
}
|
||||
|
||||
log.Infof("[GP-SAML] Starting GlobalProtect SAML authentication with gp-saml-gui for gateway=%s", gateway)
|
||||
|
||||
gpSamlPath, err := exec.LookPath("gp-saml-gui")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GlobalProtect SAML requires gp-saml-gui (install: pip install gp-saml-gui): %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--gateway",
|
||||
"--allow-insecure-crypto",
|
||||
gateway,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, gpSamlPath, args...)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GP SAML auth: failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GP SAML auth: failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("GP SAML auth: failed to start gp-saml-gui: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
for scanner.Scan() {
|
||||
log.Debugf("[GP-SAML] gp-saml-gui: %s", scanner.Text())
|
||||
}
|
||||
}()
|
||||
|
||||
result := &gpSamlAuthResult{Host: gateway}
|
||||
var allOutput []string
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
allOutput = append(allOutput, line)
|
||||
log.Infof("[GP-SAML] stdout: %s", line)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(line, "COOKIE="):
|
||||
result.Cookie = unshellQuote(strings.TrimPrefix(line, "COOKIE="))
|
||||
case strings.HasPrefix(line, "HOST="):
|
||||
result.Host = unshellQuote(strings.TrimPrefix(line, "HOST="))
|
||||
case strings.HasPrefix(line, "USER="):
|
||||
result.User = unshellQuote(strings.TrimPrefix(line, "USER="))
|
||||
case strings.HasPrefix(line, "FINGERPRINT="):
|
||||
result.Fingerprint = unshellQuote(strings.TrimPrefix(line, "FINGERPRINT="))
|
||||
default:
|
||||
parseGPSamlFromCommandLine(line, result)
|
||||
}
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("GP SAML auth timed out or was cancelled: %w", ctx.Err())
|
||||
}
|
||||
if result.Cookie == "" {
|
||||
return nil, fmt.Errorf("GP SAML auth failed: %w (output: %s)", err, strings.Join(allOutput, "\n"))
|
||||
}
|
||||
log.Warnf("[GP-SAML] gp-saml-gui exited with error but cookie was captured: %v", err)
|
||||
}
|
||||
|
||||
if result.Cookie == "" {
|
||||
return nil, fmt.Errorf("GP SAML auth: no cookie in gp-saml-gui output")
|
||||
}
|
||||
|
||||
log.Infof("[GP-SAML] Got prelogin-cookie from gp-saml-gui, converting to openconnect cookie via --authenticate")
|
||||
|
||||
// Convert prelogin-cookie to full openconnect cookie format
|
||||
ocResult, err := convertGPPreloginCookie(ctx, gateway, result.Cookie, result.User)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GP SAML auth: failed to convert prelogin-cookie: %w", err)
|
||||
}
|
||||
|
||||
result.Cookie = ocResult.Cookie
|
||||
result.Host = ocResult.Host
|
||||
result.Fingerprint = ocResult.Fingerprint
|
||||
|
||||
log.Infof("[GP-SAML] Authentication successful: user=%s, host=%s, cookie_len=%d, has_fingerprint=%v",
|
||||
result.User, result.Host, len(result.Cookie), result.Fingerprint != "")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) {
|
||||
ocPath, err := exec.LookPath("openconnect")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openconnect not found: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--protocol=gp",
|
||||
"--usergroup=gateway:prelogin-cookie",
|
||||
"--user=" + user,
|
||||
"--passwd-on-stdin",
|
||||
"--allow-insecure-crypto",
|
||||
"--authenticate",
|
||||
gateway,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ocPath, args...)
|
||||
cmd.Stdin = strings.NewReader(preloginCookie)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
result := &gpSamlAuthResult{}
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "COOKIE="):
|
||||
result.Cookie = unshellQuote(strings.TrimPrefix(line, "COOKIE="))
|
||||
case strings.HasPrefix(line, "HOST="):
|
||||
result.Host = unshellQuote(strings.TrimPrefix(line, "HOST="))
|
||||
case strings.HasPrefix(line, "FINGERPRINT="):
|
||||
result.Fingerprint = unshellQuote(strings.TrimPrefix(line, "FINGERPRINT="))
|
||||
case strings.HasPrefix(line, "CONNECT_URL="):
|
||||
connectURL := unshellQuote(strings.TrimPrefix(line, "CONNECT_URL="))
|
||||
if connectURL != "" && result.Host == "" {
|
||||
result.Host = connectURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.Cookie == "" {
|
||||
return nil, fmt.Errorf("no COOKIE in openconnect --authenticate output: %s", string(output))
|
||||
}
|
||||
|
||||
log.Infof("[GP-SAML] openconnect --authenticate: cookie_len=%d, host=%s, fingerprint=%s",
|
||||
len(result.Cookie), result.Host, result.Fingerprint)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func unshellQuote(s string) string {
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '\'' && s[len(s)-1] == '\'') ||
|
||||
(s[0] == '"' && s[len(s)-1] == '"') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) {
|
||||
if !strings.Contains(line, "openconnect") {
|
||||
return
|
||||
}
|
||||
|
||||
for _, part := range strings.Fields(line) {
|
||||
switch {
|
||||
case strings.HasPrefix(part, "--cookie="):
|
||||
if result.Cookie == "" {
|
||||
result.Cookie = strings.TrimPrefix(part, "--cookie=")
|
||||
}
|
||||
case strings.HasPrefix(part, "--servercert="):
|
||||
if result.Fingerprint == "" {
|
||||
result.Fingerprint = strings.TrimPrefix(part, "--servercert=")
|
||||
}
|
||||
case strings.HasPrefix(part, "--user="):
|
||||
if result.User == "" {
|
||||
result.User = strings.TrimPrefix(part, "--user=")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUnshellQuote(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single quoted",
|
||||
input: "'hello world'",
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "double quoted",
|
||||
input: `"hello world"`,
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "unquoted",
|
||||
input: "hello",
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
name: "empty single quotes",
|
||||
input: "''",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty double quotes",
|
||||
input: `""`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "single quote only",
|
||||
input: "'",
|
||||
expected: "'",
|
||||
},
|
||||
{
|
||||
name: "mismatched quotes",
|
||||
input: "'hello\"",
|
||||
expected: "'hello\"",
|
||||
},
|
||||
{
|
||||
name: "with special chars",
|
||||
input: "'cookie=abc123&user=john'",
|
||||
expected: "cookie=abc123&user=john",
|
||||
},
|
||||
{
|
||||
name: "complex cookie",
|
||||
input: `'authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100'`,
|
||||
expected: "authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := unshellQuote(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGPSamlFromCommandLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
initialResult *gpSamlAuthResult
|
||||
expectedCookie string
|
||||
expectedUser string
|
||||
expectedFP string
|
||||
}{
|
||||
{
|
||||
name: "full openconnect command",
|
||||
line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john",
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "AUTH123",
|
||||
expectedUser: "john",
|
||||
expectedFP: "pin-sha256:ABC",
|
||||
},
|
||||
{
|
||||
name: "with equals signs in cookie",
|
||||
line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane",
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "authcookie=xyz123&portal=GATE",
|
||||
expectedUser: "jane",
|
||||
expectedFP: "",
|
||||
},
|
||||
{
|
||||
name: "non-openconnect line",
|
||||
line: "some other output",
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "",
|
||||
expectedUser: "",
|
||||
expectedFP: "",
|
||||
},
|
||||
{
|
||||
name: "preserves existing values",
|
||||
line: "openconnect --user=newuser",
|
||||
initialResult: &gpSamlAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
|
||||
expectedCookie: "existing",
|
||||
expectedUser: "newuser",
|
||||
expectedFP: "existing-fp",
|
||||
},
|
||||
{
|
||||
name: "only updates empty fields",
|
||||
line: "openconnect --cookie=NEW --user=NEW",
|
||||
initialResult: &gpSamlAuthResult{Cookie: "OLD"},
|
||||
expectedCookie: "OLD",
|
||||
expectedUser: "NEW",
|
||||
expectedFP: "",
|
||||
},
|
||||
{
|
||||
name: "real gp-saml-gui output",
|
||||
line: "openconnect --protocol=gp --user=john.doe@example.com --os=linux-64 --usergroup=gateway:prelogin-cookie --passwd-on-stdin",
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "",
|
||||
expectedUser: "john.doe@example.com",
|
||||
expectedFP: "",
|
||||
},
|
||||
{
|
||||
name: "with server cert flag",
|
||||
line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com",
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "",
|
||||
expectedUser: "",
|
||||
expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.initialResult
|
||||
parseGPSamlFromCommandLine(tt.line, result)
|
||||
|
||||
assert.Equal(t, tt.expectedCookie, result.Cookie, "cookie mismatch")
|
||||
assert.Equal(t, tt.expectedUser, result.User, "user mismatch")
|
||||
assert.Equal(t, tt.expectedFP, result.Fingerprint, "fingerprint mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
|
||||
// Simulate gp-saml-gui output with command line suggestion
|
||||
lines := []string{
|
||||
"",
|
||||
"SAML REDIRECT",
|
||||
"Got SAML Login URL",
|
||||
"POST to ACS endpoint...",
|
||||
"Got 'prelogin-cookie': 'FAKE_cookie_12345'",
|
||||
"openconnect --protocol=gp --user=john.doe@example.com --usergroup=gateway:prelogin-cookie --passwd-on-stdin vpn.example.com",
|
||||
"",
|
||||
}
|
||||
|
||||
result := &gpSamlAuthResult{}
|
||||
for _, line := range lines {
|
||||
parseGPSamlFromCommandLine(line, result)
|
||||
}
|
||||
|
||||
assert.Equal(t, "john.doe@example.com", result.User)
|
||||
assert.Empty(t, result.Cookie, "cookie should not be parsed from command line")
|
||||
assert.Empty(t, result.Fingerprint)
|
||||
}
|
||||
@@ -212,32 +212,28 @@ func (b *NetworkManagerBackend) updateWiFiState() error {
|
||||
}
|
||||
}
|
||||
|
||||
var forgetSSID string
|
||||
|
||||
b.stateMutex.Lock()
|
||||
defer b.stateMutex.Unlock()
|
||||
|
||||
wasConnecting = b.state.IsConnecting
|
||||
connectingSSID = b.state.ConnectingSSID
|
||||
|
||||
if wasConnecting && connectingSSID != "" {
|
||||
if connected && ssid == connectingSSID {
|
||||
switch {
|
||||
case connected && ssid == connectingSSID:
|
||||
log.Infof("[updateWiFiState] Connection successful: %s", ssid)
|
||||
b.state.IsConnecting = false
|
||||
b.state.ConnectingSSID = ""
|
||||
b.state.LastError = ""
|
||||
} else if failed || (disconnected && !connected) {
|
||||
case failed || (disconnected && !connected):
|
||||
log.Warnf("[updateWiFiState] Connection failed: SSID=%s, state=%d", connectingSSID, state)
|
||||
b.state.IsConnecting = false
|
||||
b.state.ConnectingSSID = ""
|
||||
b.state.LastError = reasonCode
|
||||
|
||||
// If user cancelled, delete the connection profile that was just created
|
||||
if reasonCode == errdefs.ErrUserCanceled {
|
||||
log.Infof("[updateWiFiState] User cancelled authentication, removing connection profile for %s", connectingSSID)
|
||||
b.stateMutex.Unlock()
|
||||
if err := b.ForgetWiFiNetwork(connectingSSID); err != nil {
|
||||
log.Warnf("[updateWiFiState] Failed to remove cancelled connection: %v", err)
|
||||
}
|
||||
b.stateMutex.Lock()
|
||||
forgetSSID = connectingSSID
|
||||
}
|
||||
|
||||
b.failedMutex.Lock()
|
||||
@@ -254,6 +250,15 @@ func (b *NetworkManagerBackend) updateWiFiState() error {
|
||||
b.state.WiFiBSSID = bssid
|
||||
b.state.WiFiSignal = signal
|
||||
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
if forgetSSID != "" {
|
||||
log.Infof("[updateWiFiState] User cancelled authentication, removing connection profile for %s", forgetSSID)
|
||||
if err := b.ForgetWiFiNetwork(forgetSSID); err != nil {
|
||||
log.Warnf("[updateWiFiState] Failed to remove cancelled connection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -304,6 +304,51 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
||||
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
|
||||
return err
|
||||
}
|
||||
case "gp_saml":
|
||||
gateway := vpnData["gateway"]
|
||||
protocol := vpnData["protocol"]
|
||||
if protocol != "gp" {
|
||||
return fmt.Errorf("GlobalProtect SAML authentication only supported for protocol=gp, got: %s", protocol)
|
||||
}
|
||||
|
||||
log.Infof("[ConnectVPN] GlobalProtect SAML/SSO authentication required for %s (gateway=%s)", connName, gateway)
|
||||
|
||||
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
authResult, err := b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
|
||||
samlCancel()
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(errMsg, "not installed"):
|
||||
return fmt.Errorf("gp-saml-gui is not installed (required for GlobalProtect SAML/SSO VPN)")
|
||||
case strings.Contains(errMsg, "timed out") || strings.Contains(errMsg, "cancelled"):
|
||||
return fmt.Errorf("GlobalProtect SAML authentication timed out — please try again")
|
||||
case strings.Contains(errMsg, "no cookie"):
|
||||
return fmt.Errorf("GlobalProtect SAML login did not complete — browser was closed before authentication finished")
|
||||
case strings.Contains(errMsg, "convert prelogin-cookie"):
|
||||
return fmt.Errorf("GlobalProtect VPN authentication succeeded but cookie exchange failed: %w", err)
|
||||
default:
|
||||
return fmt.Errorf("GlobalProtect SAML authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = &cachedGPSamlCookie{
|
||||
ConnectionUUID: targetUUID,
|
||||
Cookie: authResult.Cookie,
|
||||
Host: authResult.Host,
|
||||
User: authResult.User,
|
||||
Fingerprint: authResult.Fingerprint,
|
||||
}
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
|
||||
if err := targetConn.ClearSecrets(); err != nil {
|
||||
log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err)
|
||||
} else {
|
||||
log.Infof("[ConnectVPN] Cleared stale stored secrets for %s", connName)
|
||||
}
|
||||
|
||||
log.Infof("[ConnectVPN] GlobalProtect SAML cookie cached for %s, proceeding with activation", connName)
|
||||
}
|
||||
|
||||
b.stateMutex.Lock()
|
||||
@@ -339,6 +384,16 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(serviceType, "openconnect"):
|
||||
protocol := data["protocol"]
|
||||
if needsExternalBrowserAuth(protocol, data["authtype"], data["username"], data) {
|
||||
switch protocol {
|
||||
case "gp":
|
||||
return "gp_saml"
|
||||
default:
|
||||
log.Infof("[VPN] External browser auth detected for protocol '%s' but only GlobalProtect (gp) is currently supported", protocol)
|
||||
}
|
||||
}
|
||||
case strings.Contains(serviceType, "openvpn"):
|
||||
connType := data["connection-type"]
|
||||
username := data["username"]
|
||||
@@ -412,16 +467,6 @@ func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkma
|
||||
}
|
||||
data["username"] = username
|
||||
|
||||
if reply.Save && password != "" {
|
||||
data["password-flags"] = "0"
|
||||
secs := make(map[string]string)
|
||||
secs["password"] = password
|
||||
vpn["secrets"] = dbus.MakeVariant(secs)
|
||||
log.Infof("[ConnectVPN] Saving username and password to vpn.data")
|
||||
} else {
|
||||
log.Infof("[ConnectVPN] Saving username to vpn.data (password will be prompted)")
|
||||
}
|
||||
|
||||
vpn["data"] = dbus.MakeVariant(data)
|
||||
settings["vpn"] = vpn
|
||||
|
||||
@@ -432,7 +477,7 @@ func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkma
|
||||
}
|
||||
log.Infof("[ConnectVPN] Username saved to connection")
|
||||
|
||||
if password != "" && !reply.Save {
|
||||
if password != "" {
|
||||
b.cachedVPNCredsMu.Lock()
|
||||
b.cachedVPNCreds = &cachedVPNCredentials{
|
||||
ConnectionUUID: targetUUID,
|
||||
@@ -614,11 +659,7 @@ func (b *NetworkManagerBackend) ClearVPNCredentials(uuidOrName string) error {
|
||||
dataMap["password-flags"] = "1"
|
||||
vpnSettings["data"] = dataMap
|
||||
}
|
||||
|
||||
vpnSettings["password-flags"] = uint32(1)
|
||||
}
|
||||
|
||||
settings["vpn-secrets"] = make(map[string]any)
|
||||
}
|
||||
|
||||
if err := conn.Update(settings); err != nil {
|
||||
@@ -684,10 +725,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
||||
b.state.LastError = ""
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
// Clear cached PKCS11 PIN on success
|
||||
// Clear cached PKCS11 PIN and SAML cookie on success
|
||||
b.cachedPKCS11Mu.Lock()
|
||||
b.cachedPKCS11PIN = nil
|
||||
b.cachedPKCS11Mu.Unlock()
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = nil
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
|
||||
b.pendingVPNSaveMu.Lock()
|
||||
pending := b.pendingVPNSave
|
||||
@@ -706,10 +750,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
||||
b.state.LastError = "VPN connection failed"
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
// Clear cached PKCS11 PIN on failure
|
||||
// Clear cached PKCS11 PIN and SAML cookie on failure
|
||||
b.cachedPKCS11Mu.Lock()
|
||||
b.cachedPKCS11PIN = nil
|
||||
b.cachedPKCS11Mu.Unlock()
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = nil
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -723,10 +770,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
||||
b.state.LastError = "VPN connection failed"
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
// Clear cached PKCS11 PIN
|
||||
// Clear cached PKCS11 PIN and SAML cookie
|
||||
b.cachedPKCS11Mu.Lock()
|
||||
b.cachedPKCS11PIN = nil
|
||||
b.cachedPKCS11Mu.Unlock()
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = nil
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ func HandleList(conn net.Conn, req models.Request) {
|
||||
Dependencies: p.Dependencies,
|
||||
Installed: installed,
|
||||
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
|
||||
Featured: p.Featured,
|
||||
RequiresDMS: p.RequiresDMS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type PluginInfo struct {
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
Installed bool `json:"installed,omitempty"`
|
||||
FirstParty bool `json:"firstParty,omitempty"`
|
||||
Featured bool `json:"featured,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
HasUpdate bool `json:"hasUpdate,omitempty"`
|
||||
RequiresDMS string `json:"requires_dms,omitempty"`
|
||||
|
||||
@@ -227,6 +227,9 @@ func handleClipboardSetConfig(conn net.Conn, req models.Request) {
|
||||
if v, ok := models.Get[bool](req, "disabled"); ok {
|
||||
cfg.Disabled = v
|
||||
}
|
||||
if v, ok := models.Get[float64](req, "maxPinned"); ok {
|
||||
cfg.MaxPinned = int(v)
|
||||
}
|
||||
|
||||
if err := clipboard.SaveConfig(cfg); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
|
||||
@@ -1516,7 +1516,11 @@ func Start(printDocs bool) error {
|
||||
}
|
||||
}()
|
||||
|
||||
loginctlReady := make(chan struct{})
|
||||
freedesktopReady := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(loginctlReady)
|
||||
if err := InitializeLoginctlManager(); err != nil {
|
||||
log.Warnf("Loginctl manager unavailable: %v", err)
|
||||
} else {
|
||||
@@ -1525,6 +1529,7 @@ func Start(printDocs bool) error {
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer close(freedesktopReady)
|
||||
if err := InitializeFreedeskManager(); err != nil {
|
||||
log.Warnf("Freedesktop manager unavailable: %v", err)
|
||||
} else if freedesktopManager != nil {
|
||||
@@ -1533,6 +1538,31 @@ func Start(printDocs bool) error {
|
||||
}
|
||||
}()
|
||||
|
||||
// Bridge loginctl lock state to the freedesktop/gnome screensaver
|
||||
// ActiveChanged signal so apps like Bitwarden can detect screen lock.
|
||||
go func() {
|
||||
<-loginctlReady
|
||||
<-freedesktopReady
|
||||
|
||||
if loginctlManager == nil || freedesktopManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ch := loginctlManager.Subscribe("dms-lock-bridge")
|
||||
defer loginctlManager.Unsubscribe("dms-lock-bridge")
|
||||
|
||||
initial := loginctlManager.GetState()
|
||||
lastLocked := initial.Locked
|
||||
freedesktopManager.SetScreenLockActive(lastLocked)
|
||||
|
||||
for state := range ch {
|
||||
if state.Locked != lastLocked {
|
||||
lastLocked = state.Locked
|
||||
freedesktopManager.SetScreenLockActive(lastLocked)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := InitializeWaylandManager(); err != nil {
|
||||
log.Warnf("Wayland manager unavailable: %v", err)
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ func TestCleanupStaleSockets(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_RUNTIME_DIR", tempDir)
|
||||
|
||||
staleSocket := filepath.Join(tempDir, "danklinux-999999.sock")
|
||||
staleSocket := filepath.Join(tempDir, "danklinux-4194305.sock")
|
||||
err := os.WriteFile(staleSocket, []byte{}, 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -92,21 +92,13 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
registry, err := themes.NewRegistry()
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
allThemes, err := registry.List()
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
themeMap := make(map[string]themes.Theme)
|
||||
for _, t := range allThemes {
|
||||
themeMap[t.ID] = t
|
||||
if registry, err := themes.NewRegistry(); err == nil {
|
||||
if allThemes, err := registry.List(); err == nil {
|
||||
for _, t := range allThemes {
|
||||
themeMap[t.ID] = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]ThemeInfo, 0, len(installedIDs))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
@@ -18,3 +20,18 @@ func IsDBusServiceAvailable(busName string) bool {
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
func IsDBusServiceActivatable(busName string) bool {
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
obj := conn.Object("org.freedesktop.DBus", "/org/freedesktop/DBus")
|
||||
var activatable []string
|
||||
if err := obj.Call("org.freedesktop.DBus.ListActivatableNames", 0).Store(&activatable); err != nil {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(activatable, busName)
|
||||
}
|
||||
|
||||
37
core/internal/utils/group.go
Normal file
37
core/internal/utils/group.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func HasGroup(groupName string) bool {
|
||||
return HasGroupIn(groupName, "/etc/group")
|
||||
}
|
||||
|
||||
func HasGroupIn(groupName, path string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return HasGroupData(groupName, string(data))
|
||||
}
|
||||
|
||||
func HasGroupData(groupName, data string) bool {
|
||||
prefix := groupName + ":"
|
||||
for line := range strings.SplitSeq(data, "\n") {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func FindGroupData(data string, candidates ...string) (string, bool) {
|
||||
for _, candidate := range candidates {
|
||||
if HasGroupData(candidate, data) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
142
core/internal/utils/group_test.go
Normal file
142
core/internal/utils/group_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
const testGroupData = `root:x:0:brltty,root
|
||||
sys:x:3:bin,testuser
|
||||
mem:x:8:
|
||||
ftp:x:11:
|
||||
mail:x:12:
|
||||
log:x:19:
|
||||
smmsp:x:25:
|
||||
proc:x:26:
|
||||
games:x:50:
|
||||
lock:x:54:
|
||||
network:x:90:
|
||||
floppy:x:94:
|
||||
scanner:x:96:
|
||||
power:x:98:
|
||||
nobody:x:65534:
|
||||
adm:x:999:daemon
|
||||
wheel:x:998:testuser
|
||||
utmp:x:997:
|
||||
audio:x:996:brltty
|
||||
disk:x:995:
|
||||
input:x:994:brltty,testuser,greeter
|
||||
kmem:x:993:
|
||||
kvm:x:992:libvirt-qemu,qemu,testuser
|
||||
lp:x:991:cups,testuser
|
||||
optical:x:990:
|
||||
render:x:989:
|
||||
sgx:x:988:
|
||||
storage:x:987:
|
||||
tty:x:5:brltty
|
||||
uucp:x:986:brltty
|
||||
video:x:985:cosmic-greeter,greeter,testuser
|
||||
users:x:984:
|
||||
groups:x:983:
|
||||
systemd-journal:x:982:
|
||||
rfkill:x:981:
|
||||
bin:x:1:daemon
|
||||
daemon:x:2:bin
|
||||
http:x:33:
|
||||
dbus:x:81:
|
||||
systemd-coredump:x:980:
|
||||
systemd-network:x:979:
|
||||
systemd-oom:x:978:
|
||||
systemd-journal-remote:x:977:
|
||||
systemd-resolve:x:976:
|
||||
systemd-timesync:x:975:
|
||||
tss:x:974:
|
||||
uuidd:x:973:
|
||||
alpm:x:972:
|
||||
polkitd:x:102:
|
||||
testuser:x:1000:
|
||||
avahi:x:971:
|
||||
git:x:970:
|
||||
nvidia-persistenced:x:143:
|
||||
i2c:x:969:testuser
|
||||
seat:x:968:
|
||||
rtkit:x:133:
|
||||
brlapi:x:967:brltty
|
||||
gdm:x:120:
|
||||
brltty:x:966:
|
||||
colord:x:965:
|
||||
flatpak:x:964:
|
||||
geoclue:x:963:testuser
|
||||
gnome-remote-desktop:x:962:
|
||||
saned:x:961:
|
||||
usbmux:x:140:
|
||||
cosmic-greeter:x:960:
|
||||
greeter:x:959:testuser
|
||||
openvpn:x:958:
|
||||
nm-openvpn:x:957:
|
||||
named:x:40:
|
||||
_talkd:x:956:
|
||||
keyd:x:955:
|
||||
cups:x:209:testuser
|
||||
docker:x:954:testuser
|
||||
mysql:x:953:
|
||||
radicale:x:952:
|
||||
onepassword:x:1001:
|
||||
nixbld:x:951:nixbld01,nixbld02,nixbld03,nixbld04,nixbld05,nixbld06,nixbld07,nixbld08,nixbld09,nixbld10
|
||||
virtlogin:x:940:
|
||||
libvirt:x:939:testuser
|
||||
libvirt-qemu:x:938:
|
||||
qemu:x:937:
|
||||
dnsmasq:x:936:
|
||||
clock:x:935:
|
||||
dms-greeter:x:1002:greeter,testuser
|
||||
pcscd:x:934:
|
||||
test:x:1003:
|
||||
empower:x:933:
|
||||
`
|
||||
|
||||
func TestHasGroupData(t *testing.T) {
|
||||
tests := []struct {
|
||||
group string
|
||||
want bool
|
||||
}{
|
||||
{"greeter", true},
|
||||
{"root", true},
|
||||
{"docker", true},
|
||||
{"cosmic-greeter", true},
|
||||
{"dms-greeter", true},
|
||||
{"nonexistent", false},
|
||||
{"greet", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := HasGroupData(tt.group, testGroupData); got != tt.want {
|
||||
t.Errorf("HasGroupData(%q) = %v, want %v", tt.group, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindGroupData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
candidates []string
|
||||
wantGroup string
|
||||
wantFound bool
|
||||
}{
|
||||
{"first match wins", []string{"greeter", "greetd", "_greeter"}, "greeter", true},
|
||||
{"fallback to second", []string{"greetd", "greeter"}, "greeter", true},
|
||||
{"none found", []string{"_greetd", "greetd"}, "", false},
|
||||
{"single match", []string{"docker"}, "docker", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, found := FindGroupData(testGroupData, tt.candidates...)
|
||||
if got != tt.wantGroup || found != tt.wantFound {
|
||||
t.Errorf("%s: FindGroupData(%v) = (%q, %v), want (%q, %v)",
|
||||
tt.name, tt.candidates, got, found, tt.wantGroup, tt.wantFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasGroupDataEmpty(t *testing.T) {
|
||||
if HasGroupData("greeter", "") {
|
||||
t.Error("expected false for empty data")
|
||||
}
|
||||
}
|
||||
31
core/internal/utils/gsettings.go
Normal file
31
core/internal/utils/gsettings.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func dconfPath(schema, key string) string {
|
||||
return "/" + strings.ReplaceAll(schema, ".", "/") + "/" + key
|
||||
}
|
||||
|
||||
// GsettingsGet reads a gsettings value, falling back to dconf read.
|
||||
func GsettingsGet(schema, key string) (string, error) {
|
||||
if out, err := exec.Command("gsettings", "get", schema, key).Output(); err == nil {
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("gsettings/dconf get failed for %s %s: %w", schema, key, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// GsettingsSet writes a gsettings value, falling back to dconf write.
|
||||
func GsettingsSet(schema, key, value string) error {
|
||||
if err := exec.Command("gsettings", "set", schema, key, value).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
return exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run()
|
||||
}
|
||||
@@ -38,6 +38,22 @@ func XDGConfigHome() string {
|
||||
return filepath.Join(home, ".config")
|
||||
}
|
||||
|
||||
func EmacsConfigDir() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
emacsD := filepath.Join(home, ".emacs.d")
|
||||
if info, err := os.Stat(emacsD); err == nil && info.IsDir() {
|
||||
return emacsD
|
||||
}
|
||||
|
||||
xdgEmacs := filepath.Join(XDGConfigHome(), "emacs")
|
||||
if info, err := os.Stat(xdgEmacs); err == nil && info.IsDir() {
|
||||
return xdgEmacs
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func ExpandPath(path string) (string, error) {
|
||||
expanded := os.ExpandEnv(path)
|
||||
expanded = filepath.Clean(expanded)
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
<service name="download_url">
|
||||
<param name="protocol">https</param>
|
||||
<param name="host">github.com</param>
|
||||
<param name="path">/AvengeMedia/DankMaterialShell/archive/refs/tags/v1.0.3.tar.gz</param>
|
||||
<param name="path">/AvengeMedia/DankMaterialShell/archive/refs/tags/v1.2.3.tar.gz</param>
|
||||
<param name="filename">dms-source.tar.gz</param>
|
||||
</service>
|
||||
<!-- Download amd64 binary -->
|
||||
<service name="download_url">
|
||||
<param name="protocol">https</param>
|
||||
<param name="host">github.com</param>
|
||||
<param name="path">/AvengeMedia/DankMaterialShell/releases/download/v1.0.3/dms-distropkg-amd64.gz</param>
|
||||
<param name="path">/AvengeMedia/DankMaterialShell/releases/download/v1.2.3/dms-distropkg-amd64.gz</param>
|
||||
</service>
|
||||
<!-- Download arm64 binary -->
|
||||
<service name="download_url">
|
||||
<param name="protocol">https</param>
|
||||
<param name="host">github.com</param>
|
||||
<param name="path">/AvengeMedia/DankMaterialShell/releases/download/v1.0.3/dms-distropkg-arm64.gz</param>
|
||||
<param name="path">/AvengeMedia/DankMaterialShell/releases/download/v1.2.3/dms-distropkg-arm64.gz</param>
|
||||
</service>
|
||||
</services>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
dms (1.0.3db1) unstable; urgency=medium
|
||||
dms (1.2.3db1) stable; urgency=medium
|
||||
|
||||
* Update to v1.0.3 stable release
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ in
|
||||
"hyprland"
|
||||
"sway"
|
||||
"labwc"
|
||||
"mango"
|
||||
"scroll"
|
||||
"miracle"
|
||||
];
|
||||
description = "Compositor to run greeter in";
|
||||
};
|
||||
|
||||
@@ -50,5 +50,6 @@ in
|
||||
|
||||
services.power-profiles-daemon.enable = lib.mkDefault true;
|
||||
services.accounts-daemon.enable = lib.mkDefault true;
|
||||
security.polkit.enable = lib.mkDefault true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<services>
|
||||
<!-- Git source and vendoring -->
|
||||
<service name="tar_scm" mode="disabled">
|
||||
<param name="scm">git</param>
|
||||
<param name="url">https://github.com/AvengeMedia/DankMaterialShell.git</param>
|
||||
<param name="revision">master</param>
|
||||
<param name="filename">dms-git-source</param>
|
||||
</service>
|
||||
<service name="recompress" mode="disabled">
|
||||
<param name="file">*.tar</param>
|
||||
<param name="compression">gz</param>
|
||||
</service>
|
||||
<!-- Binary downloads removed - building from source
|
||||
<service name="download_url">
|
||||
<param name="protocol">https</param>
|
||||
<param name="host">github.com</param>
|
||||
<param name="path">/AvengeMedia/DankMaterialShell/releases/latest/download/dms-distropkg-amd64.gz</param>
|
||||
</service>
|
||||
<service name="download_url">
|
||||
<param name="protocol">https</param>
|
||||
<param name="host">github.com</param>
|
||||
<param name="path">/AvengeMedia/DankMaterialShell/releases/latest/download/dms-distropkg-arm64.gz</param>
|
||||
</service>
|
||||
-->
|
||||
</services>
|
||||
@@ -3,7 +3,7 @@
|
||||
%global debug_package %{nil}
|
||||
|
||||
Name: dms
|
||||
Version: 1.0.3
|
||||
Version: 1.2.3
|
||||
Release: 1%{?dist}
|
||||
Summary: DankMaterialShell - Material 3 inspired shell for Wayland compositors
|
||||
|
||||
|
||||
@@ -147,6 +147,48 @@ check_obs_version_exists() {
|
||||
return 1
|
||||
}
|
||||
|
||||
update_debian_dms_service() {
|
||||
local service_path="$1"
|
||||
if [[ -z "$service_path" || ! -f "$service_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "$CHANGELOG_VERSION" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Extract base version (e.g., 1.2.3 from 1.2.3db3 or 1.2.3-1)
|
||||
local base_version
|
||||
base_version=$(echo "$CHANGELOG_VERSION" | sed -E 's/^([0-9]+(\.[0-9]+)*).*/\1/')
|
||||
if [[ -z "$base_version" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sed -i "s|/archive/refs/tags/v[0-9][^\"]*\.tar\.gz|/archive/refs/tags/v${base_version}.tar.gz|" "$service_path"
|
||||
sed -i "s|/releases/download/v[0-9][^\"]*/dms-distropkg-amd64\.gz|/releases/download/v${base_version}/dms-distropkg-amd64.gz|" "$service_path"
|
||||
sed -i "s|/releases/download/v[0-9][^\"]*/dms-distropkg-arm64\.gz|/releases/download/v${base_version}/dms-distropkg-arm64.gz|" "$service_path"
|
||||
}
|
||||
|
||||
update_opensuse_git_spec() {
|
||||
local spec_path="$1"
|
||||
if [[ -z "$spec_path" || ! -f "$spec_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$CHANGELOG_VERSION" ]]; then
|
||||
echo " Updating OpenSUSE spec to version $CHANGELOG_VERSION"
|
||||
sed -i "s/^Version:.*/Version: $CHANGELOG_VERSION/" "$spec_path"
|
||||
|
||||
# Update changelog in spec file
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' "$spec_path")
|
||||
{
|
||||
echo "$LOCAL_SPEC_HEAD"
|
||||
echo "%changelog"
|
||||
echo "* $DATE_STR Avenge Media <AvengeMedia.US@gmail.com> - ${CHANGELOG_VERSION}-1"
|
||||
echo "- Git snapshot (commit $COMMIT_COUNT: $COMMIT_HASH)"
|
||||
} > "$spec_path"
|
||||
fi
|
||||
}
|
||||
|
||||
# Handle "all" option
|
||||
if [[ "$PACKAGE" == "all" ]]; then
|
||||
echo "==> Uploading all packages"
|
||||
@@ -263,7 +305,10 @@ if [[ -d "distro/debian/$PACKAGE/debian" ]]; then
|
||||
if [[ "$PACKAGE" == *"-git" ]]; then
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "1.0.2")
|
||||
BASE_VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)
|
||||
if [[ -z "$BASE_VERSION" ]]; then
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "1.0.2")
|
||||
fi
|
||||
CHANGELOG_VERSION="${BASE_VERSION}+git${COMMIT_COUNT}.${COMMIT_HASH}"
|
||||
echo " - Generated git snapshot version: $CHANGELOG_VERSION"
|
||||
else
|
||||
@@ -284,6 +329,11 @@ if [[ -d "distro/debian/$PACKAGE/debian" ]]; then
|
||||
echo " - Applied rebuild suffix: $CHANGELOG_VERSION"
|
||||
fi
|
||||
|
||||
# Keep Debian dms _service in sync with changelog version
|
||||
if [[ "$PACKAGE" == "dms" ]] && [[ -f "distro/debian/$PACKAGE/_service" ]]; then
|
||||
update_debian_dms_service "distro/debian/$PACKAGE/_service"
|
||||
fi
|
||||
|
||||
# Check if this version already exists in OBS
|
||||
if [[ -n "$CHANGELOG_VERSION" ]]; then
|
||||
if [[ -z "$REBUILD_RELEASE" ]]; then
|
||||
@@ -327,6 +377,10 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ -f "distro/opensuse/$PACKAGE.spec" ]];
|
||||
echo " - Copying $PACKAGE.spec for OpenSUSE"
|
||||
cp "distro/opensuse/$PACKAGE.spec" "$WORK_DIR/"
|
||||
|
||||
if [[ "$PACKAGE" == *"-git" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
|
||||
update_opensuse_git_spec "$WORK_DIR/$PACKAGE.spec"
|
||||
fi
|
||||
|
||||
if [[ -f "$WORK_DIR/.osc/$PACKAGE.spec" ]]; then
|
||||
NEW_VERSION=$(grep "^Version:" "$WORK_DIR/$PACKAGE.spec" | awk '{print $2}' | head -1)
|
||||
NEW_RELEASE=$(grep "^Release:" "$WORK_DIR/$PACKAGE.spec" | sed 's/^Release:[[:space:]]*//' | sed 's/%{?dist}//' | head -1)
|
||||
@@ -607,11 +661,7 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
||||
|
||||
case "$PACKAGE" in
|
||||
dms)
|
||||
if [[ -n "$CHANGELOG_VERSION" ]]; then
|
||||
DMS_VERSION="$CHANGELOG_VERSION"
|
||||
else
|
||||
DMS_VERSION=$(grep "^Version:" "$REPO_ROOT/distro/opensuse/$PACKAGE.spec" | sed 's/^Version:[[:space:]]*//' | head -1)
|
||||
fi
|
||||
DMS_VERSION=$(grep "^Version:" "$REPO_ROOT/distro/opensuse/$PACKAGE.spec" | sed 's/^Version:[[:space:]]*//' | head -1)
|
||||
EXPECTED_DIR="DankMaterialShell-${DMS_VERSION}"
|
||||
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
|
||||
cp -r "$SOURCE_DIR" "$EXPECTED_DIR"
|
||||
@@ -662,18 +712,7 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
||||
# Copy and update OpenSUSE spec file with the correct version (for -git packages)
|
||||
cp "distro/opensuse/$PACKAGE.spec" "$WORK_DIR/"
|
||||
if [[ "$PACKAGE" == *"-git" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
|
||||
echo " Updating OpenSUSE spec to version $CHANGELOG_VERSION"
|
||||
sed -i "s/^Version:.*/Version: $CHANGELOG_VERSION/" "$WORK_DIR/$PACKAGE.spec"
|
||||
|
||||
# Update changelog in spec file
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' "$WORK_DIR/$PACKAGE.spec")
|
||||
{
|
||||
echo "$LOCAL_SPEC_HEAD"
|
||||
echo "%changelog"
|
||||
echo "* $DATE_STR Avenge Media <AvengeMedia.US@gmail.com> - ${CHANGELOG_VERSION}-1"
|
||||
echo "- Git snapshot (commit $COMMIT_COUNT: $COMMIT_HASH)"
|
||||
} > "$WORK_DIR/$PACKAGE.spec"
|
||||
update_opensuse_git_spec "$WORK_DIR/$PACKAGE.spec"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -853,6 +892,15 @@ if [[ -n "$OBS_FILES" ]]; then
|
||||
((DELETED_COUNT++)) || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Remove service-generated download_url artifacts so new ones are created
|
||||
for old_file in $(echo "$OBS_FILES" | grep -oP '(?<=name=")_service:download_url:[^"]+(?=")' || true); do
|
||||
echo " - Deleting old service artifact: $old_file"
|
||||
if osc api -X DELETE "/source/$OBS_PROJECT/$PACKAGE/$old_file" 2>/dev/null; then
|
||||
((DELETED_COUNT++)) || true
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $DELETED_COUNT -gt 0 ]]; then
|
||||
echo " ✓ Deleted $DELETED_COUNT old tarball(s) from server"
|
||||
else
|
||||
@@ -885,6 +933,10 @@ find . -maxdepth 1 -type f \( -name "*.dsc" -o -name "*.spec" \) -exec grep -l "
|
||||
rm -f "$conflicted_file"
|
||||
done
|
||||
|
||||
if [[ "$UPLOAD_DEBIAN" == false ]]; then
|
||||
rm -f ./*.dsc ./*.dsc.* ./*.spec.* ./*.mine ./*.new ./*.orig _service 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Ensure we're STILL in WORK_DIR before running osc commands
|
||||
cd "$WORK_DIR" || {
|
||||
echo "ERROR: Cannot cd to WORK_DIR: $WORK_DIR"
|
||||
|
||||
13
docs/IPC.md
13
docs/IPC.md
@@ -533,6 +533,16 @@ File browser controls for selecting wallpapers and profile images.
|
||||
- `profile` - Opens profile image file browser in Pictures directory
|
||||
- Both browsers support common image formats (jpg, jpeg, png, bmp, gif, webp)
|
||||
|
||||
### Target: `color-picker`
|
||||
Color picker modal control.
|
||||
|
||||
**Functions:**
|
||||
- `open` - Show color picker modal
|
||||
- `close` - Hide color picker modal
|
||||
- `closeInstant` - Hide color picker modal without animation
|
||||
- `toggle` - Toggle color picker modal visibility
|
||||
- `toggleInstant` - Toggle color picker modal visibility without animation on hide
|
||||
|
||||
### Target: `hypr`
|
||||
Hyprland-specific controls including keybinds cheatsheet and workspace overview (Hyprland only).
|
||||
|
||||
@@ -610,6 +620,9 @@ dms ipc call dankdash wallpaper
|
||||
dms ipc call file browse wallpaper
|
||||
dms ipc call file browse profile
|
||||
|
||||
# Open color picker
|
||||
dms ipc call color-picker toggle
|
||||
|
||||
# Show Hyprland keybinds cheatsheet (Hyprland only)
|
||||
dms ipc call hypr toggleBinds
|
||||
dms ipc call hypr openBinds
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
sonnet
|
||||
qtmultimedia
|
||||
qtimageformats
|
||||
kimageformats
|
||||
];
|
||||
in
|
||||
{
|
||||
@@ -79,7 +80,7 @@
|
||||
inherit version;
|
||||
pname = "dms-shell";
|
||||
src = ./core;
|
||||
vendorHash = "sha256-vsfCgpilOHzJbTaJjJfMK/cSvtyFYJsPDjY4m3iuoFg=";
|
||||
vendorHash = "sha256-cVUJXgzYMRSM0od1xzDVkMTdxHu3OIQX2bQ8AJbGQ1Q=";
|
||||
|
||||
subPackages = [ "cmd/dms" ];
|
||||
|
||||
@@ -180,7 +181,7 @@
|
||||
buildInputs =
|
||||
with pkgs;
|
||||
[
|
||||
go_1_24
|
||||
go_1_25
|
||||
gopls
|
||||
delve
|
||||
go-tools
|
||||
@@ -188,6 +189,7 @@
|
||||
|
||||
prek
|
||||
uv # for prek
|
||||
shellcheck
|
||||
|
||||
# Nix development tools
|
||||
nixd
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# CLAUDE.md
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to AI coding assistants.
|
||||
|
||||
## AI Guidance
|
||||
|
||||
* Ignore GEMINI.md and GEMINI-*.md files
|
||||
* After receiving tool results, carefully reflect on their quality and determine optimal next steps before proceeding. Use your thinking to plan and iterate based on this new information, and then take the best next action.
|
||||
* For maximum efficiency, whenever you need to perform multiple independent operations, invoke all relevant tools simultaneously rather than sequentially.
|
||||
* Before you finish, please verify your solution
|
||||
@@ -13,7 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
* ALWAYS prefer editing an existing file to creating a new one.
|
||||
* NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
* When you update or modify core context files, also update markdown documentation and memory bank
|
||||
* When asked to commit changes, exclude CLAUDE.md and CLAUDE-*.md referenced memory bank system files from any commits.
|
||||
* When asked to commit changes, exclude AGENTS.md and AGENTS-*.md referenced memory bank system files from any commits.
|
||||
|
||||
## Memory Bank System
|
||||
|
||||
@@ -21,61 +20,245 @@ This project uses a structured memory bank system with specialized context files
|
||||
|
||||
### Core Context Files
|
||||
|
||||
* **CLAUDE-activeContext.md** - Current session state, goals, and progress (if exists)
|
||||
* **CLAUDE-patterns.md** - Established code patterns and conventions (if exists)
|
||||
* **CLAUDE-decisions.md** - Architecture decisions and rationale (if exists)
|
||||
* **CLAUDE-troubleshooting.md** - Common issues and proven solutions (if exists)
|
||||
* **CLAUDE-config-variables.md** - Configuration variables reference (if exists)
|
||||
* **CLAUDE-temp.md** - Temporary scratch pad (only read when referenced)
|
||||
* **AGENTS-activeContext.md** - Current session state, goals, and progress (if exists)
|
||||
* **AGENTS-patterns.md** - Established code patterns and conventions (if exists)
|
||||
* **AGENTS-decisions.md** - Architecture decisions and rationale (if exists)
|
||||
* **AGENTS-troubleshooting.md** - Common issues and proven solutions (if exists)
|
||||
* **AGENTS-config-variables.md** - Configuration variables reference (if exists)
|
||||
* **AGENTS-temp.md** - Temporary scratch pad (only read when referenced)
|
||||
|
||||
**Important:** Always reference the active context file first to understand what's currently being worked on and maintain session continuity.
|
||||
|
||||
### Memory Bank System Backups
|
||||
|
||||
When asked to backup Memory Bank System files, you will copy the core context files above and @.claude settings directory to directory @/path/to/backup-directory. If files already exist in the backup directory, you will overwrite them.
|
||||
When asked to backup Memory Bank System files, you will copy the core context files above and @.agents settings directory to directory @/path/to/backup-directory. If files already exist in the backup directory, you will overwrite them.
|
||||
|
||||
## Project Overview
|
||||
|
||||
## Project Overview
|
||||
DankMaterialShell is a complete desktop environment for Wayland compositors, built as a **monorepo** with two main components:
|
||||
|
||||
This is a Quickshell-based desktop shell implementation with Material Design 3 dark theme. The shell provides a complete desktop environment experience with panels, widgets, and system integration services.
|
||||
**1. Go Backend (core/)** - System integration, IPC server, and CLI tools (~118,000 lines)
|
||||
**2. QML Frontend (quickshell/)** - UI layer consuming the backend's IPC API
|
||||
|
||||
**Architecture**: Modular design with clean separation between UI components (Modules), system services (Services), and shared utilities (Common).
|
||||
**Architecture**: The Go backend provides all system integration via IPC (Inter-Process Communication), while QML services act as thin wrappers that communicate with the backend. This separation allows for robust system integration while maintaining a reactive, modern UI.
|
||||
|
||||
**Compositor Support**: Originally designed for niri, now also fully compatible with Hyprland. Both compositors are supported with their own configuration examples and keybind formats.
|
||||
**Compositor Support**: Niri, Hyprland, MangoWC, Sway, labwc, Scroll (6 compositors supported)
|
||||
**Distribution Support**: Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (6 distributions supported)
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **QML (Qt Modeling Language)** - Primary language for all UI components
|
||||
- **Quickshell Framework** - QML-based framework for building desktop shells
|
||||
### Backend (core/)
|
||||
- **Go 1.24+** - System integration and backend services
|
||||
- **Wayland Protocols** - Display management, screenshots, clipboard, workspaces
|
||||
- **D-Bus** - Bluetooth, NetworkManager, systemd-logind, desktop portals
|
||||
- **IPC Server** - Unix socket JSON API for QML ↔ Go communication
|
||||
- **CLI Tools** - `dms` command with 20+ subcommands, `dankinstall` TUI installer
|
||||
|
||||
### Frontend (quickshell/)
|
||||
- **QML (Qt Modeling Language)** - UI components and visual presentation
|
||||
- **Quickshell Framework** - QML-based desktop shell framework
|
||||
- **Qt/QtQuick** - UI rendering and controls
|
||||
- **Wayland** - Display server protocol
|
||||
- **Matugen** - Dynamic theming system for wallpaper-based colors and system app theming
|
||||
- **Matugen** - Dynamic theming system for wallpaper-based colors
|
||||
|
||||
## Development Commands
|
||||
|
||||
Since this is a Quickshell-based project without traditional build configuration files, development typically involves:
|
||||
### Backend (Go)
|
||||
|
||||
```bash
|
||||
# Run the shell (requires Quickshell to be installed)
|
||||
cd core/
|
||||
|
||||
# Build
|
||||
make # Build dms CLI (bin/dms)
|
||||
make dankinstall # Build installer (bin/dankinstall)
|
||||
make test # Run tests
|
||||
make dist # Build distribution binaries (no update/greeter features)
|
||||
|
||||
# Install
|
||||
sudo make install # Install to /usr/local/bin/dms
|
||||
|
||||
# Development
|
||||
gofmt -w . # Format Go code
|
||||
go mod tidy # Clean up dependencies
|
||||
golangci-lint run # Run linter
|
||||
|
||||
# Run dms CLI
|
||||
./bin/dms run # Start shell via dms daemon
|
||||
./bin/dms ipc <cmd> # Send IPC command to running shell
|
||||
./bin/dms --help # View all commands
|
||||
```
|
||||
|
||||
### Frontend (QML)
|
||||
|
||||
```bash
|
||||
cd quickshell/
|
||||
|
||||
# Run the shell (requires dms backend running or use 'dms run')
|
||||
quickshell -p shell.qml
|
||||
|
||||
# Or use the shorthand
|
||||
qs -p .
|
||||
|
||||
# Run with verbose output for debugging
|
||||
qs -v -p shell.qml
|
||||
qs -p . # Shorthand
|
||||
qs -v -p shell.qml # Verbose debugging
|
||||
|
||||
# Code formatting and linting
|
||||
qmlfmt -t 4 -i 4 -b 250 -w /path/to/file.qml # Format a QML file (requires qmlfmt, do not use qmlformat)
|
||||
qmllint **/*.qml # Lint all QML files for syntax errors
|
||||
qmlfmt -t 4 -i 4 -b 250 -w /path/to/file.qml # Format QML (don't use qmlformat)
|
||||
qmllint **/*.qml # Lint all QML files
|
||||
./qmlformat-all.sh # Format all QML files
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Modular Structure
|
||||
### Monorepo Structure
|
||||
|
||||
The shell follows a clean modular architecture reduced from 4,830 lines to ~250 lines in shell.qml:
|
||||
The project is organized as a monorepo with clear separation between backend and frontend:
|
||||
|
||||
```
|
||||
DankMaterialShell/
|
||||
├── core/ # Go backend (~118,000 lines)
|
||||
│ ├── cmd/ # Binary entrypoints
|
||||
│ │ ├── dms/ # Main CLI with 20+ commands
|
||||
│ │ └── dankinstall/# TUI installer
|
||||
│ ├── internal/ # System integration packages (23 packages)
|
||||
│ │ ├── clipboard/ # Clipboard history (ext-data-control-v1)
|
||||
│ │ ├── colorpicker/# Native Wayland color picker
|
||||
│ │ ├── screenshot/ # Screen capture functionality
|
||||
│ │ ├── brightness/ # DDC/CI & backlight control
|
||||
│ │ ├── bluez/ # Bluetooth D-Bus integration
|
||||
│ │ ├── config/ # Configuration management
|
||||
│ │ ├── dank16/ # Terminal color scheme generator
|
||||
│ │ ├── deps/ # Dependency detection
|
||||
│ │ ├── distros/ # Distribution-specific installers (6 distros)
|
||||
│ │ ├── greeter/ # Display manager greeter
|
||||
│ │ ├── keybinds/ # Compositor keybind management
|
||||
│ │ ├── matugen/ # Matugen integration
|
||||
│ │ ├── notify/ # Notification daemon
|
||||
│ │ ├── plugins/ # Plugin registry & management
|
||||
│ │ ├── screenshot/ # Screenshot utilities
|
||||
│ │ ├── server/ # IPC server with 15+ submodules
|
||||
│ │ ├── themes/ # Theme registry
|
||||
│ │ ├── wayland/ # Wayland protocol handlers
|
||||
│ │ └── windowrules/# Window rules management
|
||||
│ ├── pkg/ # Shared packages
|
||||
│ │ ├── go-wayland/ # Wayland client library
|
||||
│ │ ├── dbusutil/ # D-Bus utilities
|
||||
│ │ ├── ipp/ # Internet Printing Protocol
|
||||
│ │ └── syncmap/ # Thread-safe map
|
||||
│ └── go.mod # Go module definition
|
||||
├── quickshell/ # QML frontend (UI layer) - see "QML Frontend Architecture" below
|
||||
│ ├── shell.qml # Main entry point
|
||||
│ ├── Services/ # IPC client wrappers
|
||||
│ ├── Modules/ # UI components
|
||||
│ ├── Widgets/ # Reusable controls
|
||||
│ ├── Modals/ # Full-screen overlays
|
||||
│ └── Common/ # Shared resources
|
||||
├── distro/ # Distribution packaging
|
||||
│ ├── arch/ # AUR packages
|
||||
│ ├── fedora/ # RPM specs
|
||||
│ ├── debian/ # Debian packaging
|
||||
│ ├── ubuntu/ # Ubuntu PPAs
|
||||
│ ├── opensuse/ # OBS packaging
|
||||
│ └── nix/ # NixOS modules
|
||||
└── flake.nix # Nix flake
|
||||
```
|
||||
|
||||
### Go Backend Architecture
|
||||
|
||||
The backend provides all system integration through these key components:
|
||||
|
||||
#### 1. IPC Server (`internal/server/`)
|
||||
|
||||
JSON-based RPC over Unix socket (`/tmp/dms-ipc-<uid>.sock`) with 15+ submodules:
|
||||
|
||||
- **apppicker/** - Application search and launch
|
||||
- **bluez/** - Bluetooth device management
|
||||
- **brightness/** - Display and monitor brightness
|
||||
- **browser/** - Web browser integration
|
||||
- **clipboard/** - Clipboard history and persistence
|
||||
- **cups/** - Printer management
|
||||
- **dbus/** - Generic D-Bus interface access
|
||||
- **dwl/** - dwl/MangoWC compositor integration
|
||||
- **evdev/** - Keyboard input device monitoring
|
||||
- **extworkspace/** - Workspace protocol integration
|
||||
- **freedesktop/** - Desktop portal integration
|
||||
- **loginctl/** - systemd-logind (power, sessions, inhibitors)
|
||||
- **network/** - Network management (multi-backend)
|
||||
- **params/** - IPC parameter validation
|
||||
- **plugins/** - Plugin lifecycle management
|
||||
- **thememode/** - Dark/light mode synchronization
|
||||
- **themes/** - Theme registry operations
|
||||
- **wayland/** - Night mode, gamma control, output management
|
||||
- **wlcontext/** - Wayland connection management
|
||||
- **wlroutput/** - wlr-output-management protocol
|
||||
|
||||
#### 2. CLI Commands (`cmd/dms/`)
|
||||
|
||||
The `dms` CLI provides 20+ commands:
|
||||
|
||||
```bash
|
||||
dms run [-d] # Start shell (daemon mode)
|
||||
dms restart / kill # Manage shell process
|
||||
dms ipc <command> [args] # Send IPC commands
|
||||
dms brightness [list|set] # Display brightness control
|
||||
dms color pick [--rgb|--hsv] # Native color picker
|
||||
dms clipboard [list|clear] # Clipboard management
|
||||
dms screenshot [area|output] # Take screenshots
|
||||
dms notify send <msg> # Send notifications
|
||||
dms dpms [on|off] # Display power management
|
||||
dms keybinds [reload|list] # Keybind management
|
||||
dms windowrules [add|remove] # Window rules management
|
||||
dms matugen [generate|reload] # Theme generation
|
||||
dms dank16 [generate] # Terminal theme generation
|
||||
dms config [get|set] # Configuration management
|
||||
dms features # Show available features
|
||||
dms doctor # System diagnostics
|
||||
dms plugins [browse|install] # Plugin management
|
||||
dms update [check] # Update DMS and deps
|
||||
dms greeter [install|enable] # Greeter management
|
||||
```
|
||||
|
||||
#### 3. Wayland Integration (`internal/wayland/`, `internal/proto/`)
|
||||
|
||||
Native Wayland protocol implementations (as client):
|
||||
|
||||
- `wlr-gamma-control-unstable-v1` - Night mode color temperature
|
||||
- `wlr-screencopy-unstable-v1` - Screenshots and color picker
|
||||
- `wlr-layer-shell-unstable-v1` - Overlay surfaces
|
||||
- `wlr-output-management-unstable-v1` - Display configuration
|
||||
- `wlr-output-power-management-unstable-v1` - DPMS control
|
||||
- `ext-data-control-v1` - Clipboard history
|
||||
- `ext-workspace-v1` - Workspace integration
|
||||
- `dwl-ipc-unstable-v2` - dwl/MangoWC IPC
|
||||
- `keyboard-shortcuts-inhibit-unstable-v1` - Shortcut inhibition
|
||||
- `wp-viewporter` - Fractional scaling support
|
||||
|
||||
#### 4. D-Bus Integration (`internal/server/bluez/`, `internal/server/network/`, etc.)
|
||||
|
||||
**Client interfaces** (consuming external services):
|
||||
- `org.bluez` - Bluetooth with pairing agent
|
||||
- `org.freedesktop.NetworkManager` - Network management
|
||||
- `net.connman.iwd` - iwd Wi-Fi backend
|
||||
- `org.freedesktop.network1` - systemd-networkd
|
||||
- `org.freedesktop.login1` - Session control, inhibitors, brightness
|
||||
- `org.freedesktop.Accounts` - User account info
|
||||
- `org.freedesktop.portal.Desktop` - Desktop appearance settings
|
||||
- CUPS via IPP - Printer management
|
||||
|
||||
**Server interfaces** (implementing services):
|
||||
- `org.freedesktop.ScreenSaver` - Screensaver inhibition for media playback
|
||||
|
||||
#### 5. Distribution Support (`internal/distros/`)
|
||||
|
||||
`dankinstall` TUI installer with full support for:
|
||||
|
||||
- **Arch Linux** - pacman + AUR (yay/paru)
|
||||
- **Fedora** - dnf + COPR
|
||||
- **Debian** - apt + OBS repos
|
||||
- **Ubuntu** - apt + PPAs
|
||||
- **openSUSE** - zypper + OBS
|
||||
- **Gentoo** - emerge + GURU overlay + USE flags
|
||||
|
||||
Each distro has custom package mappings, dependency detection, and installation logic.
|
||||
|
||||
### QML Frontend Architecture
|
||||
|
||||
The frontend follows a clean modular architecture with shell.qml reduced to ~250 lines:
|
||||
|
||||
```
|
||||
shell.qml # Main entry point (minimal orchestration)
|
||||
@@ -137,11 +320,13 @@ shell.qml # Main entry point (minimal orchestration)
|
||||
- `Theme.qml` - Material Design 3 theme singleton with consistent colors, spacing, fonts
|
||||
- `Utilities.js` - Shared functions for workspace parsing, notifications, menu handling
|
||||
|
||||
3. **Services/** - System integration singletons
|
||||
3. **Services/** - IPC client wrappers (20 singletons)
|
||||
- **Pattern**: All services use `Singleton` type with `id: root`
|
||||
- **Independence**: No cross-service dependencies
|
||||
- **Architecture**: Thin QML wrappers that communicate with Go backend via IPC
|
||||
- **Examples**: AudioService, NetworkService, BluetoothService, DisplayService, WeatherService, NotificationService, CalendarService, BatteryService, NiriService, MprisController
|
||||
- Services handle system commands, state management, and hardware integration
|
||||
- Services expose properties and functions that send IPC requests to the Go backend
|
||||
- The Go backend handles all actual system integration (D-Bus, Wayland, hardware control)
|
||||
- QML services receive IPC responses and update their properties for reactive UI binding
|
||||
|
||||
4. **Modules/** - UI components (93 files)
|
||||
- **TopBar/**: Panel components with workspace switching, system indicators, media controls
|
||||
@@ -227,6 +412,24 @@ shell.qml # Main entry point (minimal orchestration)
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Internationalization (I18n)
|
||||
|
||||
When adding user-facing strings, wrap them in `I18n.tr()` with context:
|
||||
|
||||
```qml
|
||||
import qs.Common
|
||||
|
||||
Text {
|
||||
text: I18n.tr("Hello World", "Hello world greeting that appears on the lock screen")
|
||||
}
|
||||
```
|
||||
|
||||
**Best practices:**
|
||||
- Keep new terms to a minimum - reuse existing translations when possible
|
||||
- Check `quickshell/translations/en.json` for existing terms
|
||||
- Example: Use "Autoconnect" instead of "Auto-connect" if it's already translated
|
||||
- Provide clear context for translators in the second parameter
|
||||
|
||||
### QML Style Guidelines
|
||||
|
||||
1. **Structure and Formatting**:
|
||||
@@ -274,26 +477,199 @@ shell.qml # Main entry point (minimal orchestration)
|
||||
|
||||
### Import Guidelines
|
||||
|
||||
1. **Standard Import Order**:
|
||||
```qml
|
||||
import QtQuick
|
||||
import QtQuick.Controls // If needed
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import Quickshell.Io // For Process, FileView
|
||||
import qs.Common // For Theme, utilities
|
||||
import qs.Services // For service access
|
||||
import qs.Widgets // For reusable widgets (DankIcon, etc.)
|
||||
```
|
||||
#### QML Import Order
|
||||
|
||||
2. **Service Dependencies**:
|
||||
- Services should NOT import other services
|
||||
- Modules and Widgets can import and use services via property bindings
|
||||
- Use `Theme.propertyName` for consistent styling
|
||||
- Use `DankIcon { name: "icon_name" }` for all icons instead of manual Text components
|
||||
```qml
|
||||
import QtQuick
|
||||
import QtQuick.Controls // If needed
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import Quickshell.Io // For Process, FileView
|
||||
import qs.Common // For Theme, utilities
|
||||
import qs.Services // For service access
|
||||
import qs.Widgets // For reusable widgets (DankIcon, etc.)
|
||||
```
|
||||
|
||||
#### Go Import Order
|
||||
|
||||
Follow standard Go conventions:
|
||||
|
||||
```go
|
||||
import (
|
||||
// Standard library
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
// External dependencies
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
// Internal packages
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
```
|
||||
|
||||
**Service Dependencies:**
|
||||
- QML Services should NOT import other QML services
|
||||
- Modules and Widgets can import and use services via property bindings
|
||||
- Use `Theme.propertyName` for consistent styling
|
||||
- Use `DankIcon { name: "icon_name" }` for all icons instead of manual Text components
|
||||
|
||||
### Go Backend Code Conventions
|
||||
|
||||
#### 1. Package Structure
|
||||
|
||||
- **cmd/** - Binary entrypoints only, minimal logic
|
||||
- **internal/** - Implementation packages (not importable by external projects)
|
||||
- **pkg/** - Shared packages (potentially importable)
|
||||
- Each package should have a clear, single responsibility
|
||||
|
||||
#### 2. Error Handling
|
||||
|
||||
```go
|
||||
// Always wrap errors with context
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to D-Bus: %w", err)
|
||||
}
|
||||
|
||||
// Use custom error types for specific error handling
|
||||
if errors.Is(err, errdefs.ErrNotFound) {
|
||||
// Handle specific error
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. IPC Handler Pattern
|
||||
|
||||
All server modules should follow this pattern:
|
||||
|
||||
```go
|
||||
package mymodule
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
// State, connections, etc.
|
||||
}
|
||||
|
||||
func NewManager() (*Manager, error) {
|
||||
// Initialize
|
||||
return &Manager{}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) HandleRequest(req models.Request) models.Response {
|
||||
switch req.Method {
|
||||
case "list":
|
||||
return m.handleList(req)
|
||||
case "action":
|
||||
return m.handleAction(req)
|
||||
default:
|
||||
return models.ErrorResponse(req.ID, "unknown method")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) handleAction(req models.Request) models.Response {
|
||||
// Extract and validate parameters
|
||||
param, err := params.String(req.Params, "name")
|
||||
if err != nil {
|
||||
return models.ErrorResponse(req.ID, err.Error())
|
||||
}
|
||||
|
||||
// Perform action
|
||||
result, err := m.doSomething(param)
|
||||
if err != nil {
|
||||
return models.ErrorResponse(req.ID, err.Error())
|
||||
}
|
||||
|
||||
return models.SuccessResponse(req.ID, result)
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. D-Bus Integration
|
||||
|
||||
```go
|
||||
// Use context for cancellation
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Always check for D-Bus availability
|
||||
if !dbusutil.ServiceExists(conn, "org.bluez") {
|
||||
return fmt.Errorf("bluetooth service not available")
|
||||
}
|
||||
|
||||
// Handle signals properly with channels
|
||||
signals := make(chan *dbus.Signal, 10)
|
||||
conn.Signal(signals)
|
||||
defer conn.RemoveSignal(signals)
|
||||
```
|
||||
|
||||
#### 5. Wayland Protocol Integration
|
||||
|
||||
```go
|
||||
// Check protocol availability before use
|
||||
if registry.GetGammaControl() == nil {
|
||||
return errdefs.ErrNotSupported
|
||||
}
|
||||
|
||||
// Clean up Wayland resources
|
||||
defer output.Destroy()
|
||||
defer surface.Destroy()
|
||||
```
|
||||
|
||||
#### 6. Testing
|
||||
|
||||
```go
|
||||
// Use table-driven tests
|
||||
func TestManager_HandleRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request models.Request
|
||||
want models.Response
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
request: models.Request{
|
||||
ID: "1",
|
||||
Method: "list",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := NewManager()
|
||||
got := m.HandleRequest(tt.request)
|
||||
// Assertions
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Use mocks for external dependencies (see internal/mocks/)
|
||||
```
|
||||
|
||||
#### 7. Logging
|
||||
|
||||
```go
|
||||
import "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
|
||||
// Use appropriate log levels
|
||||
log.Debug("Processing request", "method", req.Method)
|
||||
log.Info("Service started", "address", addr)
|
||||
log.Warn("Feature unavailable", "reason", "missing dependency")
|
||||
log.Error("Failed to connect", "error", err)
|
||||
log.Fatal("Critical failure", "error", err) // Only for unrecoverable errors
|
||||
```
|
||||
|
||||
### Component Development Patterns
|
||||
|
||||
#### QML Frontend Patterns
|
||||
|
||||
1. **Code Reuse - Search Before Writing**:
|
||||
- **ALWAYS** search the codebase for existing functions before writing new ones
|
||||
- Use `Grep` or `Glob` tools to find existing implementations (e.g., search for "getWifiIcon", "getDeviceIcon")
|
||||
@@ -353,24 +729,78 @@ shell.qml # Main entry point (minimal orchestration)
|
||||
|
||||
The shell uses Quickshell's `Variants` pattern for multi-monitor support:
|
||||
- Each connected monitor gets its own top bar instance
|
||||
- Workspace switchers are compositor-aware (Niri and Hyprland)
|
||||
- Workspace switchers are compositor-aware (6 compositors supported)
|
||||
- Monitors are automatically detected by screen name (DP-1, DP-2, etc.)
|
||||
- **Niri**: Workspaces are dynamically synchronized with Niri's per-output workspaces
|
||||
- **Hyprland**: Integrates with Hyprland's workspace system and multi-monitor handling
|
||||
- **Niri**: Workspaces dynamically synchronized with per-output workspaces
|
||||
- **Hyprland**: Integrates with Hyprland's workspace system
|
||||
- **MangoWC**: Uses dwl-ipc-unstable-v2 for tag management
|
||||
- **Sway/labwc/Scroll**: Standard i3 IPC integration
|
||||
|
||||
## IPC Communication Model
|
||||
|
||||
### QML ↔ Go Backend Communication
|
||||
|
||||
The shell uses a Unix socket-based IPC system for all system integration:
|
||||
|
||||
1. **Go Backend** (`core/internal/server/`) runs an IPC server on `/tmp/dms-ipc-<uid>.sock`
|
||||
2. **QML Services** send JSON-RPC requests to the backend
|
||||
3. **Backend** handles system integration (D-Bus, Wayland, hardware) and responds
|
||||
4. **QML Services** receive responses and update properties for UI reactivity
|
||||
|
||||
**Example Flow:**
|
||||
```
|
||||
User clicks WiFi network in UI
|
||||
↓
|
||||
QML NetworkService.connectNetwork(ssid, password)
|
||||
↓
|
||||
IPC Request: {"method": "network.connect", "params": {...}}
|
||||
↓
|
||||
Go Backend: internal/server/network/ handles D-Bus to NetworkManager
|
||||
↓
|
||||
IPC Response: {"result": {"success": true}}
|
||||
↓
|
||||
QML Service updates properties → UI updates reactively
|
||||
```
|
||||
|
||||
**Why this architecture?**
|
||||
- **Separation of concerns**: UI (QML) vs system integration (Go)
|
||||
- **Type safety**: Go provides compile-time safety for system APIs
|
||||
- **Performance**: Go handles expensive operations without blocking UI
|
||||
- **Robustness**: Backend crashes don't crash the UI, and vice versa
|
||||
- **Testing**: Backend can be tested independently of UI
|
||||
|
||||
**Development implications:**
|
||||
- QML Services should be **thin wrappers** - minimal logic, just IPC calls
|
||||
- System integration logic belongs in Go backend packages
|
||||
- When adding features, implement backend first, then QML wrapper
|
||||
- Use `dms ipc <command>` CLI to test backend functionality independently
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Testing and Validation
|
||||
|
||||
When modifying the shell:
|
||||
|
||||
**QML Frontend:**
|
||||
1. **Test changes**: `qs -p .` (automatic reload on file changes)
|
||||
2. **Code quality**: Run `./qmlformat-all.sh` or `qmlformat -i **/*.qml` and `qmllint **/*.qml` to ensure proper formatting and syntax
|
||||
2. **Code quality**: Run `./qmlformat-all.sh` or `qmlformat -i **/*.qml` and `qmllint **/*.qml`
|
||||
3. **Performance**: Ensure animations remain smooth (60 FPS target)
|
||||
4. **Theming**: Use `Theme.propertyName` for Material Design 3 consistency
|
||||
5. **Wayland compatibility**: Test on Wayland session
|
||||
6. **Multi-monitor**: Verify behavior with multiple displays
|
||||
7. **Compositor compatibility**: Test on both Niri and Hyprland when possible
|
||||
8. **Feature detection**: Test on systems with/without required tools
|
||||
|
||||
**Go Backend:**
|
||||
1. **Build**: `cd core && make` to build dms CLI
|
||||
2. **Tests**: `make test` to run Go unit tests (add appropriate test coverage for new code)
|
||||
3. **Linting**: `gofmt -w .`, `go mod tidy`, and `golangci-lint run`
|
||||
4. **IPC testing**: Use `dms ipc <command>` to test backend functionality
|
||||
5. **Rebuild**: After backend changes, rebuild with `make` and restart shell
|
||||
|
||||
**Integration:**
|
||||
1. **Full test**: `dms restart` to restart both backend and frontend
|
||||
2. **Wayland compatibility**: Test on Wayland session
|
||||
3. **Multi-monitor**: Verify behavior with multiple displays
|
||||
4. **Compositor compatibility**: Test on Niri, Hyprland, MangoWC, Sway, labwc, Scroll when possible
|
||||
5. **Distribution compatibility**: Test installation on Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo
|
||||
6. **Feature detection**: Test on systems with/without required tools
|
||||
|
||||
### Adding New Modules
|
||||
|
||||
@@ -413,6 +843,47 @@ When modifying the shell:
|
||||
|
||||
### Adding New Services
|
||||
|
||||
**Important**: Most system integration should be done in the Go backend, with QML services as thin IPC wrappers.
|
||||
|
||||
#### Step 1: Implement Go Backend
|
||||
|
||||
1. **Create backend package**:
|
||||
```bash
|
||||
mkdir -p core/internal/server/newsystem
|
||||
```
|
||||
|
||||
2. **Implement backend logic** (`core/internal/server/newsystem/manager.go`):
|
||||
```go
|
||||
package newsystem
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
// State and D-Bus connections
|
||||
}
|
||||
|
||||
func NewManager() (*Manager, error) {
|
||||
// Initialize D-Bus connections, Wayland protocols, etc.
|
||||
return &Manager{}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) HandleRequest(req models.Request) models.Response {
|
||||
// Handle IPC requests
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add IPC handler** in `core/internal/server/router.go`:
|
||||
```go
|
||||
newsystemMgr, _ := newsystem.NewManager()
|
||||
router["newsystem"] = newsystemMgr.HandleRequest
|
||||
```
|
||||
|
||||
4. **Test backend**: `dms ipc newsystem.action '{"param": "value"}'`
|
||||
|
||||
#### Step 2: Create QML Wrapper
|
||||
|
||||
1. **Create service**:
|
||||
```qml
|
||||
// Services/NewService.qml
|
||||
@@ -429,14 +900,24 @@ When modifying the shell:
|
||||
property type currentValue: defaultValue
|
||||
|
||||
function performAction(param) {
|
||||
// Implementation
|
||||
// Send IPC request to Go backend
|
||||
ipcClient.send("newsystem.action", {param: param})
|
||||
}
|
||||
|
||||
// Handle IPC responses to update properties
|
||||
Connections {
|
||||
target: IPCClient
|
||||
function onResponse(method, data) {
|
||||
if (method === "newsystem.status") {
|
||||
currentValue = data.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use in modules**:
|
||||
```qml
|
||||
// In module files
|
||||
property alias serviceValue: NewService.currentValue
|
||||
|
||||
SomeControl {
|
||||
@@ -642,29 +1123,76 @@ Daemon plugins run invisibly in the background without any UI components. They'r
|
||||
|
||||
### Debugging Common Issues
|
||||
|
||||
1. **Import errors**: Check import paths
|
||||
#### QML Frontend Issues
|
||||
|
||||
1. **Import errors**: Check import paths in qmldir files
|
||||
2. **Singleton conflicts**: Ensure services use `Singleton` type with `id: root`
|
||||
3. **Property binding issues**: Use property aliases for reactive updates
|
||||
4. **Process failures**: Check system tool availability and command syntax
|
||||
5. **Theme inconsistencies**: Always use `Theme.propertyName` instead of hardcoded values
|
||||
4. **Theme inconsistencies**: Always use `Theme.propertyName` instead of hardcoded values
|
||||
5. **IPC communication failures**: Check if `dms run` backend is running
|
||||
|
||||
#### Go Backend Issues
|
||||
|
||||
1. **IPC not responding**:
|
||||
- Check if socket exists: `ls -la /tmp/dms-ipc-$(id -u).sock`
|
||||
- Test with CLI: `dms ipc test.ping`
|
||||
- Check logs: `journalctl --user -u dms.service -f`
|
||||
|
||||
2. **D-Bus errors**:
|
||||
- Verify service availability: `busctl --user list | grep org.bluez`
|
||||
- Test D-Bus call: `busctl --user introspect org.bluez /`
|
||||
- Check permissions: User must be in required groups (video, input, etc.)
|
||||
|
||||
3. **Wayland protocol errors**:
|
||||
- Check compositor support: Different compositors support different protocols
|
||||
- Use `dms features` to see available features
|
||||
- Enable debug output: `WAYLAND_DEBUG=1 dms run`
|
||||
|
||||
4. **Build failures**:
|
||||
- Update Go: Requires Go 1.24+
|
||||
- Clean build: `cd core && make clean && make`
|
||||
- Check dependencies: `go mod download`
|
||||
|
||||
5. **Process failures**:
|
||||
- Check system tool availability: `which <tool>`
|
||||
- Verify PATH: `echo $PATH`
|
||||
- Check command syntax in logs
|
||||
|
||||
### Best Practices Summary
|
||||
|
||||
#### General
|
||||
- **Code Reuse**: ALWAYS search existing codebase before writing new functions - avoid duplication at all costs
|
||||
- **No Comments**: Code should be self-documenting - comments indicate poor naming/structure
|
||||
- **No Comments**: Code should be self-documenting - comments indicate poor naming/structure (applies to both QML and Go)
|
||||
- **Modularity**: Keep components focused and independent
|
||||
- **Testing**: Write tests for Go backend, test QML changes with live reload
|
||||
|
||||
#### QML Frontend
|
||||
- **Reusability**: Create reusable components for common patterns using Widgets/
|
||||
- **Responsiveness**: Use property bindings for reactive UI
|
||||
- **Robustness**: Implement feature detection and graceful degradation
|
||||
- **Consistency**: Follow Material Design 3 principles via Theme singleton
|
||||
- **Performance**: Minimize expensive operations and use appropriate data structures
|
||||
- **Icon Management**: Use `DankIcon` for all icons instead of manual Text components
|
||||
- **Widget System**: Leverage existing widgets (DankSlider, DankToggle, etc.) for consistency
|
||||
- **NO WRAPPER HELL**: Avoid creating unnecessary wrapper functions - bind directly to underlying APIs for better reactivity and performance
|
||||
- **Function Discovery**: Use grep/search tools to find existing utility functions before implementing new ones
|
||||
- **Modern QML Patterns**: Leverage new widgets like DankTextField, DankDropdown, CachingImage
|
||||
- **Structured Organization**: Follow the established Services/Modules/Widgets/Modals separation
|
||||
- **Plugin System**: For user extensions, create plugins instead of modifying core modules - see docs/PLUGINS.md
|
||||
|
||||
#### Go Backend
|
||||
- **System Integration First**: Implement backend functionality before QML wrappers
|
||||
- **Error Handling**: Always wrap errors with context using `fmt.Errorf` with `%w`
|
||||
- **IPC Pattern**: Follow established IPC handler patterns for consistency
|
||||
- **Feature Detection**: Check for system capabilities and fail gracefully
|
||||
- **Robustness**: Implement feature detection and graceful degradation
|
||||
- **D-Bus Best Practices**: Use contexts, check service availability, handle signals properly
|
||||
- **Wayland Best Practices**: Clean up resources, check protocol availability
|
||||
- **Testing**: Write table-driven tests, use mocks for external dependencies
|
||||
|
||||
#### Architecture
|
||||
- **Separation of Concerns**: UI (QML) vs system integration (Go)
|
||||
- **Thin QML Wrappers**: Services should only handle IPC communication, not business logic
|
||||
- **Backend-First Development**: Implement and test backend via CLI before adding UI
|
||||
- **Function Discovery**: Use grep/search tools to find existing utility functions before implementing new ones
|
||||
- **Plugin System**: For user extensions, create plugins instead of modifying core modules
|
||||
|
||||
### Common Widget Patterns
|
||||
|
||||
@@ -10,6 +10,7 @@ Singleton {
|
||||
id: root
|
||||
|
||||
property var appUsageRanking: {}
|
||||
property bool _saving: false
|
||||
|
||||
Component.onCompleted: {
|
||||
loadSettings();
|
||||
@@ -59,7 +60,9 @@ Singleton {
|
||||
}
|
||||
|
||||
appUsageRanking = currentRanking;
|
||||
_saving = true;
|
||||
saveSettings();
|
||||
_saving = false;
|
||||
}
|
||||
|
||||
function getRankedApps() {
|
||||
@@ -97,7 +100,9 @@ Singleton {
|
||||
|
||||
if (hasChanges) {
|
||||
appUsageRanking = currentRanking;
|
||||
_saving = true;
|
||||
saveSettings();
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +114,8 @@ Singleton {
|
||||
blockWrites: true
|
||||
watchChanges: true
|
||||
onLoaded: {
|
||||
if (root._saving)
|
||||
return;
|
||||
parseSettings(settingsFile.text());
|
||||
}
|
||||
onLoadFailed: error => {}
|
||||
|
||||
@@ -178,6 +178,33 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function loadLauncherCache() {
|
||||
try {
|
||||
var content = launcherCacheFile.text();
|
||||
if (content && content.trim())
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
console.warn("CacheData: Failed to parse launcher cache:", e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveLauncherCache(sections) {
|
||||
if (_loading)
|
||||
return;
|
||||
launcherCacheFile.setText(JSON.stringify(sections));
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: launcherCacheFile
|
||||
|
||||
path: isGreeterMode ? "" : _stateDir + "/DankMaterialShell/launcher_cache.json"
|
||||
blockLoading: true
|
||||
blockWrites: true
|
||||
atomicWrites: true
|
||||
watchChanges: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: cacheFile
|
||||
|
||||
|
||||
9
quickshell/Common/DankAnim.qml
Normal file
9
quickshell/Common/DankAnim.qml
Normal file
@@ -0,0 +1,9 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
// Reusable NumberAnimation wrapper
|
||||
NumberAnimation {
|
||||
duration: Theme.expressiveDurations.normal
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Theme.expressiveCurves.standard
|
||||
}
|
||||
9
quickshell/Common/DankColorAnim.qml
Normal file
9
quickshell/Common/DankColorAnim.qml
Normal file
@@ -0,0 +1,9 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
// Reusable ColorAnimation wrapper
|
||||
ColorAnimation {
|
||||
duration: Theme.expressiveDurations.normal
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Theme.expressiveCurves.standard
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user