mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-12 23:32:50 -04:00
Compare commits
271 Commits
d60e70f9cc
...
v1.4.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f64bb8031 | |||
| eea7d12c0b | |||
| 85173126f4 | |||
| 222187d8a6 | |||
| bef3f65f63 | |||
| bff83fe563 | |||
| cbf00d133a | |||
| 347f06b758 | |||
| 9070903512 | |||
| e9d030f6d8 | |||
| fbf9e6d1b9 | |||
| e803812344 | |||
| 9a64f2acf0 | |||
| c647eafadc | |||
| 720ec07d13 | |||
| 4b4334e611 | |||
| b69a96e80b | |||
| 1e6a73fd60 | |||
| 60b6280750 | |||
| 9e079f8a4b | |||
| 62c2e858ef | |||
| 78357d45bb | |||
| 3ff9564c9b | |||
| b0989cecad | |||
| 47be6a1033 | |||
| 31b415b086 | |||
| 7156e1e299 | |||
| c72c9bfb08 | |||
| 73c75fcc2c | |||
| 2ff42eba41 | |||
| 9f13465cd7 | |||
| 366a98e0cc | |||
| 31aeb8dc4b | |||
| c4e7f3d62f | |||
| a1d13f276a | |||
| dbf132d633 | |||
| 59451890f1 | |||
| e633c9e039 | |||
| 6c1fff2df1 | |||
| 3891d125d1 | |||
| 997011e008 | |||
| 2504396435 | |||
| d206723b36 | |||
| a0ec3d59b8 | |||
| 17ef08aa58 | |||
| 57279d1c53 | |||
| 8b003ac9cd | |||
| 0ea10b0ad2 | |||
| 2db4c9daa0 | |||
| 363964e90b | |||
| a7b49eba70 | |||
| 4ae334f60f | |||
| 86c0064ff9 | |||
| 5a6b52f07f | |||
| 5aaa56853f | |||
| 35913c22f5 | |||
| d7b560573c | |||
| 02a274ebe2 | |||
| fc7b61c20b | |||
| 5880043f56 | |||
| fee3b7f2a7 | |||
| c0b0339fca | |||
| 26c1e62204 | |||
| 7b2d4dbe30 | |||
| 78c5d46c6b | |||
| 3fb85df504 | |||
| 227dd24726 | |||
| ae6a656899 | |||
| a4055e0f01 | |||
| 6d98c229ef | |||
| 71d93ad85e | |||
| 4ec21fcd3d | |||
| 0a2fe03fee | |||
| 4f4745609b | |||
| a69cd515fb | |||
| 06c4b97a6b | |||
| a6cf71a190 | |||
| 21750156dc | |||
| f9b737f543 | |||
| 246b59f3b9 | |||
| dcda81ea64 | |||
| 9909b665cd | |||
| 4bcd786be3 | |||
| 64c9222000 | |||
| 12acf2dd51 | |||
| fea97b4aad | |||
| c6d398eeac | |||
| 7a74be83d7 | |||
| 67a6427418 | |||
| 18b20d3225 | |||
| 8a76885fb6 | |||
| 69b1e61ab7 | |||
| 3f24cf37ca | |||
| 01218f34cb | |||
| 9da58d8296 | |||
| af0038e634 | |||
| 05c312b9eb | |||
| 89d5c958c4 | |||
| e4d86ad595 | |||
| 532b54a028 | |||
| 504d027c3f | |||
| e8f95f4533 | |||
| b83256c83a | |||
| 8e2cd21be8 | |||
| c5413608da | |||
| 586bcad442 | |||
| 3b3d10f730 | |||
| 4834891b36 | |||
| f60e65aecb | |||
| 01387b0123 | |||
| 1476658c23 | |||
| 7861c6e316 | |||
| d2247d7b24 | |||
| 2ff78d4a02 | |||
| 785243ce5f | |||
| 0e1b868384 | |||
| 2b08e800e8 | |||
| 74e4f8ea1e | |||
| 9c58569b4c | |||
| 29de677e00 | |||
| fae4944845 | |||
| 07a0ac4b7d | |||
| b2d8f4d73b | |||
| fe58c45233 | |||
| 3ea4e389eb | |||
| 7276f295fc | |||
| 93ed96a789 | |||
| bea325e94c | |||
| 2f8f1c30ad | |||
| f859a14173 | |||
| 153f39da48 | |||
| e4accdd1c7 | |||
| a2c89e0a8c | |||
| e282831c2e | |||
| 5c5ff6195a | |||
| c4bbf54679 | |||
| 98acafb4b8 | |||
| da20681fc0 | |||
| b38cb961b2 | |||
| 7a0bb07518 | |||
| 403e3e90a2 | |||
| 50b91f14b6 | |||
| b3df47fce0 | |||
| 09bd65d746 | |||
| 020d56ab7f | |||
| f3bee65da9 | |||
| b14b0946e2 | |||
| ca44205f1c | |||
| 2d39e8fd2a | |||
| 6d4df6e927 | |||
| b8ab86e6c0 | |||
| 837329a6d8 | |||
| 8c6c2ffd23 | |||
| ad3c8b6755 | |||
| 03a8e1e0d5 | |||
| 4d4d3c20a1 | |||
| cef16d6bc9 | |||
| aafaad1791 | |||
| 7906fdc2b0 | |||
| 397650ca52 | |||
| 826207006a | |||
| 58c2fcd31c | |||
| b2a2b425ec | |||
| 942c9c9609 | |||
| 46d6e1cff3 | |||
| a4137c57c1 | |||
| 1ad8b627f1 | |||
| 58a02ce290 | |||
| 8e1ad1a2be | |||
| 68cd7ab32c | |||
| f649ce9a8e | |||
| c4df242f07 | |||
| 26846c8d55 | |||
| 31b44a667c | |||
| 4f3b73ee21 | |||
| 4cfae91f02 | |||
| 8d947a6e95 | |||
| 1e84d4252c | |||
| 76072e1d4c | |||
| 6408dce4a9 | |||
| 0b2e1cca38 | |||
| c1bfd8c0b7 | |||
| 90ffa5833b | |||
| 169c669286 | |||
| f8350deafc | |||
| 0286a1b80b | |||
| 7c3e6c1f02 | |||
| d2d72db3c9 | |||
| f81f861408 | |||
| af494543f5 | |||
| db4de55338 | |||
| 37ecbbbbde | |||
| d6a6d2a438 | |||
| bf1c6eec74 | |||
| 0ddae80584 | |||
| 5c96c03bfa | |||
| dfe36e47d8 | |||
| 63e1b75e57 | |||
| 29efdd8598 | |||
| 34d03cf11b | |||
| c339389d44 | |||
| af5f6eb656 | |||
| a6d28e2553 | |||
| 6213267908 | |||
| d084114149 | |||
| f6d99eca0d | |||
| 722eb3289e | |||
| b7f2bdcb2d | |||
| 11c20db6e6 | |||
| 8a4e3f8bb1 | |||
| bc8fe97c13 | |||
| 47262155aa | |||
| 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 |
@@ -7,32 +7,32 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
## DankMaterialShell Bug Report
|
## DankMaterialShell Bug Report
|
||||||
Limit your report to one issue per submission unless closely related
|
Limit your report to one issue per submission unless similarly related
|
||||||
- type: checkboxes
|
- type: dropdown
|
||||||
id: compositor
|
id: compositor
|
||||||
attributes:
|
attributes:
|
||||||
label: Compositor
|
label: Compositor
|
||||||
options:
|
options:
|
||||||
- label: Niri
|
- Niri
|
||||||
- label: Hyprland
|
- Hyprland
|
||||||
- label: MangoWC (dwl)
|
- MangoWC (dwl)
|
||||||
- label: Sway
|
- Sway
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: checkboxes
|
- type: dropdown
|
||||||
id: distribution
|
id: distribution
|
||||||
attributes:
|
attributes:
|
||||||
label: Distribution
|
label: Distribution
|
||||||
options:
|
options:
|
||||||
- label: Arch Linux
|
- Arch Linux
|
||||||
- label: CachyOS
|
- CachyOS
|
||||||
- label: Fedora
|
- Fedora
|
||||||
- label: NixOS
|
- NixOS
|
||||||
- label: Debian
|
- Debian
|
||||||
- label: Ubuntu
|
- Ubuntu
|
||||||
- label: Gentoo
|
- Gentoo
|
||||||
- label: OpenSUSE
|
- OpenSUSE
|
||||||
- label: Other (specify below)
|
- Other (specify below)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
@@ -42,12 +42,45 @@ body:
|
|||||||
placeholder: e.g., PikaOS, Void Linux, etc.
|
placeholder: e.g., PikaOS, Void Linux, etc.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
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
|
||||||
|
attributes:
|
||||||
|
label: Was this your original Installation method?
|
||||||
|
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
|
- type: textarea
|
||||||
id: dms_doctor
|
id: dms_doctor
|
||||||
attributes:
|
attributes:
|
||||||
label: dms doctor -v
|
label: dms doctor -vC
|
||||||
description: Output of `dms doctor -v` command
|
description: Output of `dms doctor -vC` command — paste between the details tags below to keep it collapsed in the issue
|
||||||
placeholder: Paste the output of `dms doctor -v` here
|
placeholder: Paste the output of `dms doctor -vC` here
|
||||||
|
value: |
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand</summary>
|
||||||
|
|
||||||
|
|
||||||
|
</details>
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -69,7 +102,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: steps_to_reproduce
|
id: steps_to_reproduce
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to Reproduce & Installation Method
|
label: Steps to Reproduce
|
||||||
description: Please provide detailed steps to reproduce the issue
|
description: Please provide detailed steps to reproduce the issue
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. ...
|
1. ...
|
||||||
|
|||||||
@@ -23,18 +23,25 @@ body:
|
|||||||
placeholder: Why is this feature important?
|
placeholder: Why is this feature important?
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: checkboxes
|
- type: dropdown
|
||||||
id: compositor
|
id: compositor
|
||||||
attributes:
|
attributes:
|
||||||
label: Compositor(s)
|
label: Compositor(s)
|
||||||
description: Is this feature specific to one or more compositors?
|
description: Is this feature specific to one or more compositors?
|
||||||
options:
|
options:
|
||||||
- label: All compositors
|
- All compositors
|
||||||
- label: Niri
|
- Niri
|
||||||
- label: Hyprland
|
- Hyprland
|
||||||
- label: MangoWC (dwl)
|
- MangoWC (dwl)
|
||||||
- label: Sway
|
- Sway
|
||||||
- label: Other (specify below)
|
- Other (specify below)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: compositor_other
|
||||||
|
attributes:
|
||||||
|
label: If Other, please specify
|
||||||
|
placeholder: e.g., Wayfire, Mutter, etc.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -7,32 +7,87 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
## DankMaterialShell Support Request
|
## DankMaterialShell Support Request
|
||||||
- type: checkboxes
|
- type: dropdown
|
||||||
id: compositor
|
id: compositor
|
||||||
attributes:
|
attributes:
|
||||||
label: Compositor
|
label: Compositor
|
||||||
options:
|
options:
|
||||||
- label: Niri
|
- Niri
|
||||||
- label: Hyprland
|
- Hyprland
|
||||||
- label: MangoWC (dwl)
|
- MangoWC (dwl)
|
||||||
- label: Sway
|
- Sway
|
||||||
- label: Other (specify below)
|
- Other (specify below)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: compositor_other
|
||||||
|
attributes:
|
||||||
|
label: If Other, please specify
|
||||||
|
placeholder: e.g., Wayfire, Mutter, etc.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: input
|
- type: dropdown
|
||||||
id: distribution
|
id: distribution
|
||||||
attributes:
|
attributes:
|
||||||
label: Distribution
|
label: Distribution
|
||||||
description: Which Linux distribution are you using? (e.g., Arch, Fedora, Debian, etc.)
|
options:
|
||||||
placeholder: Your Linux distribution
|
- 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:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: dms_doctor
|
id: dms_doctor
|
||||||
attributes:
|
attributes:
|
||||||
label: dms doctor -v
|
label: dms doctor -vC
|
||||||
description: Output of `dms doctor -v` command
|
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 -v` here
|
placeholder: Paste the output of `dms doctor -vC` here
|
||||||
|
value: |
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand</summary>
|
||||||
|
|
||||||
|
|
||||||
|
</details>
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
+163
-82
@@ -10,6 +10,7 @@ on:
|
|||||||
options:
|
options:
|
||||||
- dms
|
- dms
|
||||||
- dms-git
|
- dms-git
|
||||||
|
- dms-greeter
|
||||||
- all
|
- all
|
||||||
default: "dms"
|
default: "dms"
|
||||||
rebuild_release:
|
rebuild_release:
|
||||||
@@ -72,12 +73,27 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Helper function to check dms-greeter stable tag
|
||||||
|
check_dms_greeter_stable() {
|
||||||
|
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||||
|
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:danklinux/dms-greeter/dms-greeter.spec" 2>/dev/null || echo "")
|
||||||
|
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs | sed 's/^v//')
|
||||||
|
|
||||||
|
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "v$OBS_VERSION" ]]; then
|
||||||
|
echo "📋 dms-greeter: Tag $LATEST_TAG already exists, skipping"
|
||||||
|
return 1 # No update needed
|
||||||
|
else
|
||||||
|
echo "📋 dms-greeter: New tag ${LATEST_TAG:-unknown} (OBS has ${OBS_VERSION:-none})"
|
||||||
|
return 0 # Update needed
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Main logic
|
# Main logic
|
||||||
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
||||||
|
|
||||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "${{ github.event.inputs.package }}" ]]; then
|
||||||
# Tag selected or pushed - always update stable package
|
# Run from tag with no package specified - update both stable packages
|
||||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
echo "packages=dms dms-greeter" >> $GITHUB_OUTPUT
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||||
@@ -112,6 +128,10 @@ jobs:
|
|||||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
if check_dms_greeter_stable; then
|
||||||
|
PACKAGES_TO_UPDATE+=("dms-greeter")
|
||||||
|
[[ -n "$LATEST_TAG" ]] && echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
|
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
|
||||||
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
|
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
|
||||||
@@ -144,6 +164,18 @@ jobs:
|
|||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
elif [[ "$PKG" == "dms-greeter" ]]; then
|
||||||
|
if check_dms_greeter_stable; then
|
||||||
|
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||||
|
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||||
|
if [[ -n "$LATEST_TAG" ]]; then
|
||||||
|
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "packages=" >> $GITHUB_OUTPUT
|
||||||
|
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
else
|
else
|
||||||
# Unknown package - proceed anyway
|
# Unknown package - proceed anyway
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||||
@@ -171,15 +203,8 @@ jobs:
|
|||||||
- name: Determine packages to update
|
- name: Determine packages to update
|
||||||
id: packages
|
id: packages
|
||||||
run: |
|
run: |
|
||||||
# Check if GITHUB_REF points to a tag (works for both push events and workflow_dispatch with tag selected)
|
# Use check-updates outputs when available
|
||||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
|
||||||
# Tag selected or pushed - use the tag from GITHUB_REF
|
|
||||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "Using tag from GITHUB_REF: $VERSION"
|
|
||||||
# Check if check-updates already determined a version (from auto-detection)
|
|
||||||
elif [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
|
|
||||||
# Use version from check-updates job
|
# Use version from check-updates job
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||||
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
|
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
|
||||||
@@ -191,40 +216,16 @@ jobs:
|
|||||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||||
# Manual workflow dispatch
|
# Manual workflow dispatch
|
||||||
|
|
||||||
# Determine version for dms stable
|
# Determine version for dms stable and dms-greeter using the API
|
||||||
if [[ "${{ github.event.inputs.package }}" == "dms" ]]; then
|
# GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref
|
||||||
# Use github.ref if tag selected, otherwise auto-detect latest
|
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "dms-greeter" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
|
||||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
if [[ -n "$LATEST_TAG" ]]; then
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||||
echo "Using tag from GITHUB_REF: $VERSION"
|
echo "Using latest release from API: $LATEST_TAG"
|
||||||
else
|
else
|
||||||
# Auto-detect latest release for dms
|
echo "ERROR: Could not fetch latest release from API"
|
||||||
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
exit 1
|
||||||
if [[ -n "$LATEST_TAG" ]]; then
|
|
||||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
|
||||||
echo "Auto-detected latest release: $LATEST_TAG"
|
|
||||||
else
|
|
||||||
echo "ERROR: Could not auto-detect latest release"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
elif [[ "${{ github.event.inputs.package }}" == "all" ]]; then
|
|
||||||
# Use github.ref if tag selected, otherwise auto-detect latest
|
|
||||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "Using tag from GITHUB_REF: $VERSION"
|
|
||||||
else
|
|
||||||
# Auto-detect latest release for "all"
|
|
||||||
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
|
||||||
if [[ -n "$LATEST_TAG" ]]; then
|
|
||||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
|
||||||
echo "Auto-detected latest release: $LATEST_TAG"
|
|
||||||
else
|
|
||||||
echo "ERROR: Could not auto-detect latest release"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -283,55 +284,66 @@ jobs:
|
|||||||
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||||
} > "distro/debian/dms-git/debian/changelog"
|
} > "distro/debian/dms-git/debian/changelog"
|
||||||
|
|
||||||
- name: Update dms stable version
|
- name: Update stable version (dms + dms-greeter)
|
||||||
if: steps.packages.outputs.version != ''
|
if: steps.packages.outputs.version != ''
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.packages.outputs.version }}"
|
VERSION="${{ steps.packages.outputs.version }}"
|
||||||
VERSION_NO_V="${VERSION#v}"
|
VERSION_NO_V="${VERSION#v}"
|
||||||
|
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||||
echo "==> Updating packaging files to version: $VERSION_NO_V"
|
echo "==> Updating packaging files to version: $VERSION_NO_V"
|
||||||
|
|
||||||
# Update spec file
|
# Update dms spec and changelog when dms is in the upload list
|
||||||
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
|
if [[ "$PACKAGES" == *"dms"* ]]; then
|
||||||
|
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
|
||||||
|
UPDATED_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1)
|
||||||
|
echo "✓ dms spec now shows Version: $UPDATED_VERSION"
|
||||||
|
|
||||||
# Verify the update
|
DATE_STR=$(date "+%a %b %d %Y")
|
||||||
UPDATED_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1)
|
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' distro/opensuse/dms.spec)
|
||||||
echo "✓ Spec file now shows Version: $UPDATED_VERSION"
|
{
|
||||||
|
echo "$LOCAL_SPEC_HEAD"
|
||||||
|
echo "%changelog"
|
||||||
|
echo "* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1"
|
||||||
|
echo "- Update to stable $VERSION release"
|
||||||
|
} > distro/opensuse/dms.spec
|
||||||
|
|
||||||
# Single changelog entry (full history on OBS website)
|
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
|
||||||
DATE_STR=$(date "+%a %b %d %Y")
|
CHANGELOG_DATE=$(date -R)
|
||||||
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' distro/opensuse/dms.spec)
|
{
|
||||||
{
|
echo "dms (${VERSION_NO_V}db1) stable; urgency=medium"
|
||||||
echo "$LOCAL_SPEC_HEAD"
|
echo ""
|
||||||
echo "%changelog"
|
echo " * Update to $VERSION stable release"
|
||||||
echo "* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1"
|
echo ""
|
||||||
echo "- Update to stable $VERSION release"
|
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||||
} > distro/opensuse/dms.spec
|
} > "distro/debian/dms/debian/changelog"
|
||||||
|
echo "✓ Updated dms changelog to ${VERSION_NO_V}db1"
|
||||||
# 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
|
fi
|
||||||
done
|
fi
|
||||||
|
|
||||||
# Update Debian changelog for dms stable (single entry, history on OBS website)
|
# Update dms-greeter changelog when dms-greeter is in the upload list
|
||||||
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
|
if [[ "$PACKAGES" == *"dms-greeter"* ]] && [[ -f "distro/debian/dms-greeter/debian/changelog" ]]; then
|
||||||
CHANGELOG_DATE=$(date -R)
|
CHANGELOG_DATE=$(date -R)
|
||||||
{
|
{
|
||||||
echo "dms (${VERSION_NO_V}db1) stable; urgency=medium"
|
echo "dms-greeter (${VERSION_NO_V}db1) unstable; urgency=medium"
|
||||||
echo ""
|
echo ""
|
||||||
echo " * Update to $VERSION stable release"
|
echo " * Update to $VERSION stable release"
|
||||||
echo ""
|
echo ""
|
||||||
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||||
} > "distro/debian/dms/debian/changelog"
|
} > "distro/debian/dms-greeter/debian/changelog"
|
||||||
echo "✓ Updated Debian changelog to ${VERSION_NO_V}db1"
|
echo "✓ Updated dms-greeter changelog to ${VERSION_NO_V}db1"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Update Debian _service files for packages in upload list (download_url paths)
|
||||||
|
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, dms-greeter 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
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
@@ -354,6 +366,7 @@ jobs:
|
|||||||
chmod 600 ~/.config/osc/oscrc
|
chmod 600 ~/.config/osc/oscrc
|
||||||
|
|
||||||
- name: Upload to OBS
|
- name: Upload to OBS
|
||||||
|
id: upload
|
||||||
env:
|
env:
|
||||||
REBUILD_RELEASE: ${{ github.event.inputs.rebuild_release }}
|
REBUILD_RELEASE: ${{ github.event.inputs.rebuild_release }}
|
||||||
TAG_VERSION: ${{ steps.packages.outputs.version }}
|
TAG_VERSION: ${{ steps.packages.outputs.version }}
|
||||||
@@ -362,6 +375,8 @@ jobs:
|
|||||||
|
|
||||||
if [[ -z "$PACKAGES" ]]; then
|
if [[ -z "$PACKAGES" ]]; then
|
||||||
echo "✓ No packages need uploading. All up to date!"
|
echo "✓ No packages need uploading. All up to date!"
|
||||||
|
echo "uploaded_packages=" >> $GITHUB_OUTPUT
|
||||||
|
echo "skipped_packages=" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -371,6 +386,9 @@ jobs:
|
|||||||
echo "==> Version being uploaded: ${{ steps.packages.outputs.version }}"
|
echo "==> Version being uploaded: ${{ steps.packages.outputs.version }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
UPLOADED_PACKAGES=()
|
||||||
|
SKIPPED_PACKAGES=()
|
||||||
|
|
||||||
# PACKAGES can be space-separated list (e.g., "dms-git dms" from "all" check)
|
# PACKAGES can be space-separated list (e.g., "dms-git dms" from "all" check)
|
||||||
# Loop through each package and upload
|
# Loop through each package and upload
|
||||||
for PKG in $PACKAGES; do
|
for PKG in $PACKAGES; do
|
||||||
@@ -382,13 +400,37 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
LOG_FILE=$(mktemp)
|
||||||
|
set +e
|
||||||
if [[ "$PKG" == "dms-git" ]]; then
|
if [[ "$PKG" == "dms-git" ]]; then
|
||||||
bash distro/scripts/obs-upload.sh dms-git "Automated git update"
|
bash distro/scripts/obs-upload.sh dms-git "Automated git update" >"$LOG_FILE" 2>&1
|
||||||
else
|
else
|
||||||
bash distro/scripts/obs-upload.sh "$PKG" "$MESSAGE"
|
bash distro/scripts/obs-upload.sh "$PKG" "$MESSAGE" >"$LOG_FILE" 2>&1
|
||||||
fi
|
fi
|
||||||
|
STATUS=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cat "$LOG_FILE"
|
||||||
|
|
||||||
|
if [[ $STATUS -ne 0 ]]; then
|
||||||
|
rm -f "$LOG_FILE"
|
||||||
|
echo "❌ Upload failed for $PKG"
|
||||||
|
exit $STATUS
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -Eq "Exiting gracefully \(no changes needed\)|No changes needed for this package\. Exiting gracefully\." "$LOG_FILE"; then
|
||||||
|
echo "ℹ️ $PKG is already up to date. Skipped."
|
||||||
|
SKIPPED_PACKAGES+=("$PKG")
|
||||||
|
else
|
||||||
|
UPLOADED_PACKAGES+=("$PKG")
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$LOG_FILE"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
echo "uploaded_packages=${UPLOADED_PACKAGES[*]}" >> $GITHUB_OUTPUT
|
||||||
|
echo "skipped_packages=${SKIPPED_PACKAGES[*]}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
@@ -402,20 +444,59 @@ jobs:
|
|||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "All packages are current. Run completed successfully." >> $GITHUB_STEP_SUMMARY
|
echo "All packages are current. Run completed successfully." >> $GITHUB_STEP_SUMMARY
|
||||||
else
|
else
|
||||||
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
|
UPLOADED_PACKAGES="${{ steps.upload.outputs.uploaded_packages }}"
|
||||||
|
SKIPPED_PACKAGES="${{ steps.upload.outputs.skipped_packages }}"
|
||||||
|
TOTAL_COUNT=$(wc -w <<<"$PACKAGES" | tr -d ' ')
|
||||||
|
UPLOADED_COUNT=0
|
||||||
|
SKIPPED_COUNT=0
|
||||||
|
if [[ -n "$UPLOADED_PACKAGES" ]]; then
|
||||||
|
UPLOADED_COUNT=$(wc -w <<<"$UPLOADED_PACKAGES" | tr -d ' ')
|
||||||
|
fi
|
||||||
|
if [[ -n "$SKIPPED_PACKAGES" ]]; then
|
||||||
|
SKIPPED_COUNT=$(wc -w <<<"$SKIPPED_PACKAGES" | tr -d ' ')
|
||||||
|
fi
|
||||||
|
in_list() {
|
||||||
|
local item="$1"
|
||||||
|
local list="$2"
|
||||||
|
[[ " $list " == *" $item "* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${{ job.status }}" == "success" ]]; then
|
||||||
|
echo "**Status:** ✅ Completed successfully" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "**Status:** ❌ Completed with errors" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Processed:** $TOTAL_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Uploaded:** $UPLOADED_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Skipped (up to date):** $SKIPPED_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Packages:**" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
for PKG in $PACKAGES; do
|
for PKG in $PACKAGES; do
|
||||||
|
STATUS_ICON="✅"
|
||||||
|
STATUS_TEXT="uploaded"
|
||||||
|
if in_list "$PKG" "$SKIPPED_PACKAGES"; then
|
||||||
|
STATUS_ICON="ℹ️"
|
||||||
|
STATUS_TEXT="up to date (skipped)"
|
||||||
|
elif ! in_list "$PKG" "$UPLOADED_PACKAGES"; then
|
||||||
|
STATUS_ICON="❌"
|
||||||
|
STATUS_TEXT="failed"
|
||||||
|
fi
|
||||||
|
|
||||||
case "$PKG" in
|
case "$PKG" in
|
||||||
dms)
|
dms)
|
||||||
echo "- ✅ **dms** → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms/dms)" >> $GITHUB_STEP_SUMMARY
|
echo "- $STATUS_ICON **dms** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms/dms)" >> $GITHUB_STEP_SUMMARY
|
||||||
;;
|
;;
|
||||||
dms-git)
|
dms-git)
|
||||||
echo "- ✅ **dms-git** → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
|
echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
;;
|
||||||
|
dms-greeter)
|
||||||
|
echo "- $STATUS_ICON **dms-greeter** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:danklinux/dms-greeter)" >> $GITHUB_STEP_SUMMARY
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
|
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
echo "Build succeeded, no hash update needed"
|
echo "Build succeeded, no hash update needed"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1)
|
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1 || true)
|
||||||
[ -n "$new_hash" ] || { echo "Could not extract new vendorHash"; echo "$output"; exit 1; }
|
[ -n "$new_hash" ] || { echo "Could not extract new vendorHash"; echo "$output"; exit 1; }
|
||||||
current_hash=$(grep -oP 'vendorHash = "\K[^"]+' flake.nix)
|
current_hash=$(grep -oP 'vendorHash = "\K[^"]+' flake.nix)
|
||||||
[ "$current_hash" = "$new_hash" ] && { echo "vendorHash already up to date"; exit 0; }
|
[ "$current_hash" = "$new_hash" ] && { echo "vendorHash already up to date"; exit 0; }
|
||||||
@@ -59,8 +59,8 @@ jobs:
|
|||||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||||
git add flake.nix
|
git add flake.nix
|
||||||
git commit -m "nix: update vendorHash for go.mod changes" || exit 0
|
git commit -m "nix: update vendorHash for go.mod changes" || exit 0
|
||||||
git pull --rebase origin master
|
git pull --rebase origin ${{ github.ref_name }}
|
||||||
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:master
|
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
|
||||||
else
|
else
|
||||||
echo "No changes to flake.nix"
|
echo "No changes to flake.nix"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ repos:
|
|||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
- repo: local
|
||||||
rev: v0.10.0.1
|
|
||||||
hooks:
|
hooks:
|
||||||
- id: shellcheck
|
- 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
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: go-mod-tidy
|
- id: go-mod-tidy
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ nix develop
|
|||||||
|
|
||||||
This will provide:
|
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
|
- Quickshell and required QML packages
|
||||||
- Properly configured QML2_IMPORT_PATH
|
- Properly configured QML2_IMPORT_PATH
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
|
|||||||
|
|
||||||
</div>
|
</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
|
## Repository Structure
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ Extend functionality with the [plugin registry](https://plugins.danklinux.com).
|
|||||||
|
|
||||||
## Supported Compositors
|
## 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)
|
[Compositor configuration guide](https://danklinux.com/docs/dankmaterialshell/compositors)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -96,7 +96,7 @@ The on-screen preview displays the selected format. JSON output includes hex, RG
|
|||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Requires Go 1.24+
|
Requires Go 1.25+
|
||||||
|
|
||||||
**Development build:**
|
**Development build:**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"policy_version": 1,
|
||||||
|
"blocked_commands": [
|
||||||
|
"greeter install",
|
||||||
|
"greeter enable",
|
||||||
|
"greeter uninstall",
|
||||||
|
"setup"
|
||||||
|
],
|
||||||
|
"message": "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes."
|
||||||
|
}
|
||||||
@@ -222,16 +222,19 @@ func init() {
|
|||||||
|
|
||||||
func runClipCopy(cmd *cobra.Command, args []string) {
|
func runClipCopy(cmd *cobra.Command, args []string) {
|
||||||
var data []byte
|
var data []byte
|
||||||
|
copyFromStdin := false
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case len(args) > 0:
|
case len(args) > 0:
|
||||||
data = []byte(args[0])
|
data = []byte(args[0])
|
||||||
default:
|
case clipCopyDownload || clipCopyType == "__multi__":
|
||||||
var err error
|
var err error
|
||||||
data, err = io.ReadAll(os.Stdin)
|
data, err = io.ReadAll(os.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("read stdin: %v", err)
|
log.Fatalf("read stdin: %v", err)
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
copyFromStdin = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if clipCopyDownload {
|
if clipCopyDownload {
|
||||||
@@ -257,6 +260,13 @@ func runClipCopy(cmd *cobra.Command, args []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if copyFromStdin {
|
||||||
|
if err := clipboard.CopyReader(os.Stdin, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
|
||||||
|
log.Fatalf("copy: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
|
if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
|
||||||
log.Fatalf("copy: %v", err)
|
log.Fatalf("copy: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -525,5 +525,6 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
doctorCmd,
|
doctorCmd,
|
||||||
configCmd,
|
configCmd,
|
||||||
dlCmd,
|
dlCmd,
|
||||||
|
randrCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -649,6 +649,120 @@ func checkI2CAvailability() checkResult {
|
|||||||
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control", doctorDocsURL + "#optional-features"}
|
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"
|
||||||
|
|
||||||
|
pluginDirs := findQtPluginDirs()
|
||||||
|
if len(pluginDirs) == 0 {
|
||||||
|
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},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
var foundDirs []string
|
||||||
|
for _, pluginDir := range pluginDirs {
|
||||||
|
imageFormatsDir := filepath.Join(pluginDir, "imageformats")
|
||||||
|
for _, p := range c.plugins {
|
||||||
|
if _, err := os.Stat(filepath.Join(imageFormatsDir, p.file)); err == nil {
|
||||||
|
if !slices.Contains(found, p.format) {
|
||||||
|
found = append(found, p.format)
|
||||||
|
}
|
||||||
|
if !slices.Contains(foundDirs, imageFormatsDir) {
|
||||||
|
foundDirs = append(foundDirs, imageFormatsDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ", "), strings.Join(foundDirs, ":"))
|
||||||
|
}
|
||||||
|
result = checkResult{catOptionalFeatures, c.name, statusOK, fmt.Sprintf("Installed (%d formats)", len(found)), details, url}
|
||||||
|
}
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func findQtPluginDirs() []string {
|
||||||
|
var dirs []string
|
||||||
|
|
||||||
|
addDir := func(dir string) {
|
||||||
|
if dir != "" {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
|
||||||
|
dirs = append(dirs, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all paths in QT_PLUGIN_PATH env var (used by NixOS and custom setups)
|
||||||
|
if envPath := os.Getenv("QT_PLUGIN_PATH"); envPath != "" {
|
||||||
|
for dir := range strings.SplitSeq(envPath, ":") {
|
||||||
|
addDir(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try qtpaths
|
||||||
|
for _, cmd := range []string{"qtpaths6", "qtpaths"} {
|
||||||
|
if output, err := exec.Command(cmd, "-query", "QT_INSTALL_PLUGINS").Output(); err == nil {
|
||||||
|
addDir(strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
} {
|
||||||
|
addDir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
func detectNetworkBackend(stackResult *network.DetectResult) string {
|
func detectNetworkBackend(stackResult *network.DetectResult) string {
|
||||||
switch stackResult.Backend {
|
switch stackResult.Backend {
|
||||||
case network.BackendNetworkManager:
|
case network.BackendNetworkManager:
|
||||||
@@ -689,7 +803,21 @@ func checkOptionalDependencies() []checkResult {
|
|||||||
logindStatus, logindMsg := getOptionalDBusStatus("org.freedesktop.login1")
|
logindStatus, logindMsg := getOptionalDBusStatus("org.freedesktop.login1")
|
||||||
results = append(results, checkResult{catOptionalFeatures, "logind", logindStatus, logindMsg, "Session management", optionalFeaturesURL})
|
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, checkI2CAvailability())
|
||||||
|
results = append(results, checkImageFormatPlugins()...)
|
||||||
|
|
||||||
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
||||||
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
|
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
|
||||||
|
|||||||
+1249
-182
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
|
||||||
@@ -82,24 +83,35 @@ func init() {
|
|||||||
func initializeProviders() {
|
func initializeProviders() {
|
||||||
registry := keybinds.GetDefaultRegistry()
|
registry := keybinds.GetDefaultRegistry()
|
||||||
|
|
||||||
hyprlandProvider := providers.NewHyprlandProvider("$HOME/.config/hypr")
|
hyprlandProvider := providers.NewHyprlandProvider("")
|
||||||
if err := registry.Register(hyprlandProvider); err != nil {
|
if err := registry.Register(hyprlandProvider); err != nil {
|
||||||
log.Warnf("Failed to register Hyprland provider: %v", err)
|
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 {
|
if err := registry.Register(mangowcProvider); err != nil {
|
||||||
log.Warnf("Failed to register MangoWC provider: %v", err)
|
log.Warnf("Failed to register MangoWC provider: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollProvider := providers.NewSwayProvider("$HOME/.config/scroll")
|
configDir, _ := os.UserConfigDir()
|
||||||
if err := registry.Register(scrollProvider); err != nil {
|
|
||||||
log.Warnf("Failed to register Scroll provider: %v", err)
|
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")
|
miracleProvider := providers.NewMiracleProvider("")
|
||||||
if err := registry.Register(swayProvider); err != nil {
|
if err := registry.Register(miracleProvider); err != nil {
|
||||||
log.Warnf("Failed to register Sway provider: %v", err)
|
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("")
|
niriProvider := providers.NewNiriProvider("")
|
||||||
@@ -144,6 +156,8 @@ func makeProviderWithPath(name, path string) keybinds.Provider {
|
|||||||
return providers.NewSwayProvider(path)
|
return providers.NewSwayProvider(path)
|
||||||
case "scroll":
|
case "scroll":
|
||||||
return providers.NewSwayProvider(path)
|
return providers.NewSwayProvider(path)
|
||||||
|
case "miracle":
|
||||||
|
return providers.NewMiracleProvider(path)
|
||||||
case "niri":
|
case "niri":
|
||||||
return providers.NewNiriProvider(path)
|
return providers.NewNiriProvider(path)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
@@ -95,7 +97,11 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
|||||||
|
|
||||||
func runMatugenGenerate(cmd *cobra.Command, args []string) {
|
func runMatugenGenerate(cmd *cobra.Command, args []string) {
|
||||||
opts := buildMatugenOptions(cmd)
|
opts := buildMatugenOptions(cmd)
|
||||||
if err := matugen.Run(opts); err != nil {
|
err := matugen.Run(opts)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, matugen.ErrNoChanges):
|
||||||
|
os.Exit(2)
|
||||||
|
case err != nil:
|
||||||
log.Fatalf("Theme generation failed: %v", err)
|
log.Fatalf("Theme generation failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,7 +135,11 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
|||||||
if !wait {
|
if !wait {
|
||||||
if err := sendServerRequestFireAndForget(request); err != nil {
|
if err := sendServerRequestFireAndForget(request); err != nil {
|
||||||
log.Info("Server unavailable, running synchronously")
|
log.Info("Server unavailable, running synchronously")
|
||||||
if err := matugen.Run(opts); err != nil {
|
err := matugen.Run(opts)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, matugen.ErrNoChanges):
|
||||||
|
os.Exit(2)
|
||||||
|
case err != nil:
|
||||||
log.Fatalf("Theme generation failed: %v", err)
|
log.Fatalf("Theme generation failed: %v", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -146,11 +156,15 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
|||||||
resp, ok := tryServerRequest(request)
|
resp, ok := tryServerRequest(request)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Info("Server unavailable, running synchronously")
|
log.Info("Server unavailable, running synchronously")
|
||||||
if err := matugen.Run(opts); err != nil {
|
err := matugen.Run(opts)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, matugen.ErrNoChanges):
|
||||||
|
resultCh <- matugen.ErrNoChanges
|
||||||
|
case err != nil:
|
||||||
resultCh <- err
|
resultCh <- err
|
||||||
return
|
default:
|
||||||
|
resultCh <- nil
|
||||||
}
|
}
|
||||||
resultCh <- nil
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if resp.Error != "" {
|
if resp.Error != "" {
|
||||||
@@ -162,7 +176,10 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-resultCh:
|
case err := <-resultCh:
|
||||||
if err != nil {
|
switch {
|
||||||
|
case errors.Is(err, matugen.ErrNoChanges):
|
||||||
|
os.Exit(2)
|
||||||
|
case err != nil:
|
||||||
log.Fatalf("Theme generation failed: %v", err)
|
log.Fatalf("Theme generation failed: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Println("Theme generation completed")
|
fmt.Println("Theme generation completed")
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var randrCmd = &cobra.Command{
|
||||||
|
Use: "randr",
|
||||||
|
Short: "Query output display information",
|
||||||
|
Long: "Query Wayland compositor for output names, scales, resolutions and refresh rates via zwlr-output-management",
|
||||||
|
Run: runRandr,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
randrCmd.Flags().Bool("json", false, "Output in JSON format")
|
||||||
|
}
|
||||||
|
|
||||||
|
type randrJSON struct {
|
||||||
|
Outputs []randrOutput `json:"outputs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRandr(cmd *cobra.Command, args []string) {
|
||||||
|
outputs, err := queryRandr()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonFlag, _ := cmd.Flags().GetBool("json")
|
||||||
|
|
||||||
|
if jsonFlag {
|
||||||
|
data, err := json.Marshal(randrJSON{Outputs: outputs})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to marshal JSON: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, out := range outputs {
|
||||||
|
if i > 0 {
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
status := "enabled"
|
||||||
|
if !out.Enabled {
|
||||||
|
status = "disabled"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s (%s)\n", out.Name, status)
|
||||||
|
fmt.Printf(" Scale: %.4g\n", out.Scale)
|
||||||
|
fmt.Printf(" Resolution: %dx%d\n", out.Width, out.Height)
|
||||||
|
if out.Refresh > 0 {
|
||||||
|
fmt.Printf(" Refresh: %.2f Hz\n", float64(out.Refresh)/1000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,16 +13,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ssOutputName string
|
ssOutputName string
|
||||||
ssIncludeCursor bool
|
ssCursor string
|
||||||
ssFormat string
|
ssFormat string
|
||||||
ssQuality int
|
ssQuality int
|
||||||
ssOutputDir string
|
ssOutputDir string
|
||||||
ssFilename string
|
ssFilename string
|
||||||
ssNoClipboard bool
|
ssNoClipboard bool
|
||||||
ssNoFile bool
|
ssNoFile bool
|
||||||
ssNoNotify bool
|
ssNoNotify bool
|
||||||
ssStdout bool
|
ssStdout bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var screenshotCmd = &cobra.Command{
|
var screenshotCmd = &cobra.Command{
|
||||||
@@ -52,7 +52,7 @@ Examples:
|
|||||||
dms screenshot last # Last region (pre-selected)
|
dms screenshot last # Last region (pre-selected)
|
||||||
dms screenshot --no-clipboard # Save file only
|
dms screenshot --no-clipboard # Save file only
|
||||||
dms screenshot --no-file # Clipboard 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`,
|
dms screenshot -f jpg -q 85 # JPEG with quality 85`,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ var notifyActionCmd = &cobra.Command{
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
|
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().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
|
||||||
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
|
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
|
||||||
screenshotCmd.PersistentFlags().StringVarP(&ssOutputDir, "dir", "d", "", "Output directory")
|
screenshotCmd.PersistentFlags().StringVarP(&ssOutputDir, "dir", "d", "", "Output directory")
|
||||||
@@ -136,7 +136,9 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
|||||||
config := screenshot.DefaultConfig()
|
config := screenshot.DefaultConfig()
|
||||||
config.Mode = mode
|
config.Mode = mode
|
||||||
config.OutputName = ssOutputName
|
config.OutputName = ssOutputName
|
||||||
config.IncludeCursor = ssIncludeCursor
|
if strings.EqualFold(ssCursor, "on") {
|
||||||
|
config.Cursor = screenshot.CursorOn
|
||||||
|
}
|
||||||
config.Clipboard = !ssNoClipboard
|
config.Clipboard = !ssNoClipboard
|
||||||
config.SaveFile = !ssNoFile
|
config.SaveFile = !ssNoFile
|
||||||
config.Notify = !ssNoNotify
|
config.Notify = !ssNoNotify
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var setupCmd = &cobra.Command{
|
var setupCmd = &cobra.Command{
|
||||||
Use: "setup",
|
Use: "setup",
|
||||||
Short: "Deploy DMS configurations",
|
Short: "Deploy DMS configurations",
|
||||||
Long: "Deploy compositor and terminal configurations with interactive prompts",
|
Long: "Deploy compositor and terminal configurations with interactive prompts",
|
||||||
|
PersistentPreRunE: requireMutableSystemCommand,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if err := runSetup(); err != nil {
|
if err := runSetup(); err != nil {
|
||||||
log.Fatalf("Error during setup: %v", err)
|
log.Fatalf("Error during setup: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json"
|
||||||
|
cliPolicyAdminPath = "/etc/dms/cli-policy.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
immutablePolicyOnce sync.Once
|
||||||
|
immutablePolicy immutableCommandPolicy
|
||||||
|
immutablePolicyErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/cli-policy.default.json
|
||||||
|
var defaultCLIPolicyJSON []byte
|
||||||
|
|
||||||
|
type immutableCommandPolicy struct {
|
||||||
|
ImmutableSystem bool
|
||||||
|
ImmutableReason string
|
||||||
|
BlockedCommands []string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type cliPolicyFile struct {
|
||||||
|
PolicyVersion int `json:"policy_version"`
|
||||||
|
ImmutableSystem *bool `json:"immutable_system"`
|
||||||
|
BlockedCommands *[]string `json:"blocked_commands"`
|
||||||
|
Message *string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCommandSpec(raw string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
normalized = strings.TrimPrefix(normalized, "dms ")
|
||||||
|
return strings.Join(strings.Fields(normalized), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBlockedCommands(raw []string) []string {
|
||||||
|
normalized := make([]string, 0, len(raw))
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, cmd := range raw {
|
||||||
|
spec := normalizeCommandSpec(cmd)
|
||||||
|
if spec == "" || seen[spec] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[spec] = true
|
||||||
|
normalized = append(normalized, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandBlockedByPolicy(commandPath string, blocked []string) bool {
|
||||||
|
normalizedPath := normalizeCommandSpec(commandPath)
|
||||||
|
if normalizedPath == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range blocked {
|
||||||
|
spec := normalizeCommandSpec(entry)
|
||||||
|
if spec == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if normalizedPath == spec || strings.HasPrefix(normalizedPath, spec+" ") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPolicyFile(path string) (*cliPolicyFile, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var policy cliPolicyFile
|
||||||
|
if err := json.Unmarshal(data, &policy); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergePolicyFile(base *immutableCommandPolicy, path string) error {
|
||||||
|
policyFile, err := loadPolicyFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if policyFile == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if policyFile.ImmutableSystem != nil {
|
||||||
|
base.ImmutableSystem = *policyFile.ImmutableSystem
|
||||||
|
}
|
||||||
|
if policyFile.BlockedCommands != nil {
|
||||||
|
base.BlockedCommands = normalizeBlockedCommands(*policyFile.BlockedCommands)
|
||||||
|
}
|
||||||
|
if policyFile.Message != nil {
|
||||||
|
msg := strings.TrimSpace(*policyFile.Message)
|
||||||
|
if msg != "" {
|
||||||
|
base.Message = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readOSReleaseMap(path string) map[string]string {
|
||||||
|
values := make(map[string]string)
|
||||||
|
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToUpper(strings.TrimSpace(parts[0]))
|
||||||
|
value := strings.Trim(strings.TrimSpace(parts[1]), "\"")
|
||||||
|
values[key] = strings.ToLower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAnyToken(text string, tokens ...string) bool {
|
||||||
|
if text == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, token := range tokens {
|
||||||
|
if strings.Contains(text, token) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectImmutableSystem() (bool, string) {
|
||||||
|
if _, err := os.Stat("/run/ostree-booted"); err == nil {
|
||||||
|
return true, "/run/ostree-booted is present"
|
||||||
|
}
|
||||||
|
|
||||||
|
osRelease := readOSReleaseMap("/etc/os-release")
|
||||||
|
if len(osRelease) == 0 {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
id := osRelease["ID"]
|
||||||
|
idLike := osRelease["ID_LIKE"]
|
||||||
|
variantID := osRelease["VARIANT_ID"]
|
||||||
|
name := osRelease["NAME"]
|
||||||
|
prettyName := osRelease["PRETTY_NAME"]
|
||||||
|
|
||||||
|
immutableIDs := map[string]bool{
|
||||||
|
"bluefin": true,
|
||||||
|
"bazzite": true,
|
||||||
|
"silverblue": true,
|
||||||
|
"kinoite": true,
|
||||||
|
"sericea": true,
|
||||||
|
"onyx": true,
|
||||||
|
"aurora": true,
|
||||||
|
"fedora-iot": true,
|
||||||
|
"fedora-coreos": true,
|
||||||
|
}
|
||||||
|
if immutableIDs[id] {
|
||||||
|
return true, "os-release ID=" + id
|
||||||
|
}
|
||||||
|
|
||||||
|
markers := []string{"silverblue", "kinoite", "sericea", "onyx", "bazzite", "bluefin", "aurora", "ostree", "atomic"}
|
||||||
|
if hasAnyToken(variantID, markers...) {
|
||||||
|
return true, "os-release VARIANT_ID=" + variantID
|
||||||
|
}
|
||||||
|
if hasAnyToken(idLike, "ostree", "rpm-ostree") {
|
||||||
|
return true, "os-release ID_LIKE=" + idLike
|
||||||
|
}
|
||||||
|
if hasAnyToken(name, markers...) || hasAnyToken(prettyName, markers...) {
|
||||||
|
return true, "os-release identifies an atomic/ostree variant"
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getImmutablePolicy() (*immutableCommandPolicy, error) {
|
||||||
|
immutablePolicyOnce.Do(func() {
|
||||||
|
detectedImmutable, reason := detectImmutableSystem()
|
||||||
|
immutablePolicy = immutableCommandPolicy{
|
||||||
|
ImmutableSystem: detectedImmutable,
|
||||||
|
ImmutableReason: reason,
|
||||||
|
BlockedCommands: []string{"greeter install", "greeter enable", "setup"},
|
||||||
|
Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultPolicy cliPolicyFile
|
||||||
|
if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil {
|
||||||
|
immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if defaultPolicy.BlockedCommands != nil {
|
||||||
|
immutablePolicy.BlockedCommands = normalizeBlockedCommands(*defaultPolicy.BlockedCommands)
|
||||||
|
}
|
||||||
|
if defaultPolicy.Message != nil {
|
||||||
|
msg := strings.TrimSpace(*defaultPolicy.Message)
|
||||||
|
if msg != "" {
|
||||||
|
immutablePolicy.Message = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mergePolicyFile(&immutablePolicy, cliPolicyPackagedPath); err != nil {
|
||||||
|
immutablePolicyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil {
|
||||||
|
immutablePolicyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if immutablePolicyErr != nil {
|
||||||
|
return nil, immutablePolicyErr
|
||||||
|
}
|
||||||
|
return &immutablePolicy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
|
||||||
|
policy, err := getImmutablePolicy()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !policy.ImmutableSystem {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
commandPath := normalizeCommandSpec(cmd.CommandPath())
|
||||||
|
if !commandBlockedByPolicy(commandPath, policy.BlockedCommands) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := ""
|
||||||
|
if policy.ImmutableReason != "" {
|
||||||
|
reason = "Detected immutable system: " + policy.ImmutableReason + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath)
|
||||||
|
}
|
||||||
+1
-10
@@ -16,19 +16,10 @@ func init() {
|
|||||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
// Add subcommands to greeter
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
|
||||||
|
|
||||||
// Add subcommands to setup
|
|
||||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||||
|
|
||||||
// Add subcommands to update
|
|
||||||
updateCmd.AddCommand(updateCheckCmd)
|
updateCmd.AddCommand(updateCheckCmd)
|
||||||
|
|
||||||
// Add subcommands to plugins
|
|
||||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||||
|
|
||||||
// Add common commands to root
|
|
||||||
rootCmd.AddCommand(getCommonCommands()...)
|
rootCmd.AddCommand(getCommonCommands()...)
|
||||||
|
|
||||||
rootCmd.AddCommand(updateCmd)
|
rootCmd.AddCommand(updateCmd)
|
||||||
|
|||||||
@@ -11,29 +11,20 @@ import (
|
|||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// Add flags
|
|
||||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
||||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
// Add subcommands to greeter
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||||
greeterCmd.AddCommand(greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
|
||||||
|
|
||||||
// Add subcommands to setup
|
|
||||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||||
|
|
||||||
// Add subcommands to plugins
|
|
||||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||||
|
|
||||||
// Add common commands to root
|
|
||||||
rootCmd.AddCommand(getCommonCommands()...)
|
rootCmd.AddCommand(getCommonCommands()...)
|
||||||
|
|
||||||
rootCmd.SetHelpTemplate(getHelpTemplate())
|
rootCmd.SetHelpTemplate(getHelpTemplate())
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Block root
|
|
||||||
if os.Geteuid() == 0 {
|
if os.Geteuid() == 0 {
|
||||||
log.Fatal("This program should not be run as root. Exiting.")
|
log.Fatal("This program should not be run as root. Exiting.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
|
||||||
|
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
type randrOutput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scale float64 `json:"scale"`
|
||||||
|
Width int32 `json:"width"`
|
||||||
|
Height int32 `json:"height"`
|
||||||
|
Refresh int32 `json:"refresh"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type randrHead struct {
|
||||||
|
name string
|
||||||
|
enabled bool
|
||||||
|
scale float64
|
||||||
|
currentModeID uint32
|
||||||
|
modeIDs []uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type randrMode struct {
|
||||||
|
width int32
|
||||||
|
height int32
|
||||||
|
refresh int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type randrClient struct {
|
||||||
|
display *wlclient.Display
|
||||||
|
ctx *wlclient.Context
|
||||||
|
manager *wlr_output_management.ZwlrOutputManagerV1
|
||||||
|
heads map[uint32]*randrHead
|
||||||
|
modes map[uint32]*randrMode
|
||||||
|
done bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryRandr() ([]randrOutput, error) {
|
||||||
|
display, err := wlclient.Connect("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to Wayland: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &randrClient{
|
||||||
|
display: display,
|
||||||
|
ctx: display.Context(),
|
||||||
|
heads: make(map[uint32]*randrHead),
|
||||||
|
modes: make(map[uint32]*randrMode),
|
||||||
|
}
|
||||||
|
defer c.ctx.Close()
|
||||||
|
|
||||||
|
registry, err := display.GetRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||||
|
if e.Interface == wlr_output_management.ZwlrOutputManagerV1InterfaceName {
|
||||||
|
mgr := wlr_output_management.NewZwlrOutputManagerV1(c.ctx)
|
||||||
|
version := min(e.Version, 4)
|
||||||
|
|
||||||
|
mgr.SetHeadHandler(func(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
|
||||||
|
c.handleHead(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
mgr.SetDoneHandler(func(e wlr_output_management.ZwlrOutputManagerV1DoneEvent) {
|
||||||
|
c.done = true
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := registry.Bind(e.Name, e.Interface, version, mgr); err == nil {
|
||||||
|
c.manager = mgr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// First roundtrip: discover globals and bind manager
|
||||||
|
syncCallback, err := display.Sync()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to sync display: %w", err)
|
||||||
|
}
|
||||||
|
syncCallback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) {
|
||||||
|
if c.manager == nil {
|
||||||
|
c.err = fmt.Errorf("zwlr_output_manager_v1 protocol not supported by compositor")
|
||||||
|
c.done = true
|
||||||
|
}
|
||||||
|
// Otherwise wait for manager's DoneHandler
|
||||||
|
})
|
||||||
|
|
||||||
|
for !c.done {
|
||||||
|
if err := c.ctx.Dispatch(); err != nil {
|
||||||
|
return nil, fmt.Errorf("dispatch error: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.err != nil {
|
||||||
|
return nil, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.buildOutputs(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *randrClient) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
|
||||||
|
handle := e.Head
|
||||||
|
headID := handle.ID()
|
||||||
|
|
||||||
|
head := &randrHead{
|
||||||
|
modeIDs: make([]uint32, 0),
|
||||||
|
}
|
||||||
|
c.heads[headID] = head
|
||||||
|
|
||||||
|
handle.SetNameHandler(func(e wlr_output_management.ZwlrOutputHeadV1NameEvent) {
|
||||||
|
head.name = e.Name
|
||||||
|
})
|
||||||
|
|
||||||
|
handle.SetEnabledHandler(func(e wlr_output_management.ZwlrOutputHeadV1EnabledEvent) {
|
||||||
|
head.enabled = e.Enabled != 0
|
||||||
|
})
|
||||||
|
|
||||||
|
handle.SetScaleHandler(func(e wlr_output_management.ZwlrOutputHeadV1ScaleEvent) {
|
||||||
|
head.scale = e.Scale
|
||||||
|
})
|
||||||
|
|
||||||
|
handle.SetCurrentModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1CurrentModeEvent) {
|
||||||
|
head.currentModeID = e.Mode.ID()
|
||||||
|
})
|
||||||
|
|
||||||
|
handle.SetModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModeEvent) {
|
||||||
|
modeHandle := e.Mode
|
||||||
|
modeID := modeHandle.ID()
|
||||||
|
|
||||||
|
head.modeIDs = append(head.modeIDs, modeID)
|
||||||
|
|
||||||
|
mode := &randrMode{}
|
||||||
|
c.modes[modeID] = mode
|
||||||
|
|
||||||
|
modeHandle.SetSizeHandler(func(e wlr_output_management.ZwlrOutputModeV1SizeEvent) {
|
||||||
|
mode.width = e.Width
|
||||||
|
mode.height = e.Height
|
||||||
|
})
|
||||||
|
|
||||||
|
modeHandle.SetRefreshHandler(func(e wlr_output_management.ZwlrOutputModeV1RefreshEvent) {
|
||||||
|
mode.refresh = e.Refresh
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *randrClient) buildOutputs() []randrOutput {
|
||||||
|
outputs := make([]randrOutput, 0, len(c.heads))
|
||||||
|
|
||||||
|
for _, head := range c.heads {
|
||||||
|
out := randrOutput{
|
||||||
|
Name: head.name,
|
||||||
|
Scale: head.scale,
|
||||||
|
Enabled: head.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode, ok := c.modes[head.currentModeID]; ok {
|
||||||
|
out.Width = mode.width
|
||||||
|
out.Height = mode.height
|
||||||
|
out.Refresh = mode.refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs = append(outputs, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputs
|
||||||
|
}
|
||||||
@@ -210,7 +210,7 @@ func runShellInteractive(session bool) {
|
|||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
||||||
}
|
}
|
||||||
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
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
|
cmd.Stdin = os.Stdin
|
||||||
@@ -450,7 +450,7 @@ func runShellDaemon(session bool) {
|
|||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
||||||
}
|
}
|
||||||
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
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)
|
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
||||||
|
|||||||
@@ -7,14 +7,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func findCommandPath(cmd string) (string, error) {
|
|
||||||
path, err := exec.LookPath(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("command '%s' not found in PATH", cmd)
|
|
||||||
}
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isArchPackageInstalled(packageName string) bool {
|
func isArchPackageInstalled(packageName string) bool {
|
||||||
cmd := exec.Command("pacman", "-Q", packageName)
|
cmd := exec.Command("pacman", "-Q", packageName)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
|
|||||||
+5
-5
@@ -19,7 +19,7 @@ require (
|
|||||||
github.com/yuin/goldmark v1.7.16
|
github.com/yuin/goldmark v1.7.16
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
golang.org/x/exp v0.0.0-20260211191109-2735e65f0518
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
|
||||||
golang.org/x/image v0.36.0
|
golang.org/x/image v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ require (
|
|||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
@@ -36,7 +36,7 @@ require (
|
|||||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // 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/go-logfmt/logfmt v0.6.1 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // 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/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
@@ -55,7 +55,7 @@ require (
|
|||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20260210102253-e4d10f0e569a
|
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0
|
github.com/lucasb-eyer/go-colorful v1.3.0
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
@@ -71,7 +71,7 @@ require (
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.41.0
|
golang.org/x/sys v0.41.0
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.34.0
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
// v0.0.1 tag is missing a LICENSE file; master has it.
|
// v0.0.1 tag is missing a LICENSE file; master has it.
|
||||||
|
|||||||
+8
-8
@@ -40,8 +40,8 @@ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSg
|
|||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
||||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
||||||
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
@@ -68,8 +68,8 @@ github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g8
|
|||||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
|
github.com/go-git/go-billy/v6 v6.0.0-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 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-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20260210102253-e4d10f0e569a h1:LLju0NuXQqR4WmGl1Dm86b9ZXsvXgLYbx/aaAjdQr6w=
|
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-20260210102253-e4d10f0e569a/go.mod h1:IdXOePSwsMKGpuAbpczsm+f0Uy5fdHHjwgDPOymKA78=
|
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 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
@@ -86,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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
@@ -152,8 +152,8 @@ go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20260211191109-2735e65f0518 h1:2E1CW7v5QB+Wi3N+MXllOtVR6SFmI8iJM8EdzgxrgrU=
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||||
golang.org/x/exp v0.0.0-20260211191109-2735e65f0518/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
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 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
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 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package clipboard
|
package clipboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||||
@@ -12,17 +14,37 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Copy(data []byte, mimeType string) error {
|
func Copy(data []byte, mimeType string) error {
|
||||||
return CopyOpts(data, mimeType, false, false)
|
return CopyReader(bytes.NewReader(data), mimeType, false, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||||
|
if foreground {
|
||||||
|
return copyServeWithWriter(func(writer io.Writer) error {
|
||||||
|
total := 0
|
||||||
|
for total < len(data) {
|
||||||
|
n, err := writer.Write(data[total:])
|
||||||
|
total += n
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total != len(data) {
|
||||||
|
return io.ErrShortWrite
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, mimeType, pasteOnce)
|
||||||
|
}
|
||||||
|
return CopyReader(bytes.NewReader(data), mimeType, foreground, pasteOnce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
||||||
if !foreground {
|
if !foreground {
|
||||||
return copyFork(data, mimeType, pasteOnce)
|
return copyFork(data, mimeType, pasteOnce)
|
||||||
}
|
}
|
||||||
return copyServe(data, mimeType, pasteOnce)
|
return copyServeReader(data, mimeType, pasteOnce)
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyFork(data []byte, mimeType string, pasteOnce bool) error {
|
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||||
args := []string{os.Args[0], "cl", "copy", "--foreground"}
|
args := []string{os.Args[0], "cl", "copy", "--foreground"}
|
||||||
if pasteOnce {
|
if pasteOnce {
|
||||||
args = append(args, "--paste-once")
|
args = append(args, "--paste-once")
|
||||||
@@ -30,11 +52,15 @@ func copyFork(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
args = append(args, "--type", mimeType)
|
args = append(args, "--type", mimeType)
|
||||||
|
|
||||||
cmd := exec.Command(args[0], args[1:]...)
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
cmd.Stdin = nil
|
|
||||||
cmd.Stdout = nil
|
cmd.Stdout = nil
|
||||||
cmd.Stderr = nil
|
cmd.Stderr = nil
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
|
||||||
|
if stdinSource, ok := data.(*os.File); ok {
|
||||||
|
cmd.Stdin = stdinSource
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
stdin, err := cmd.StdinPipe()
|
stdin, err := cmd.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stdin pipe: %w", err)
|
return fmt.Errorf("stdin pipe: %w", err)
|
||||||
@@ -44,16 +70,66 @@ func copyFork(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
return fmt.Errorf("start: %w", err)
|
return fmt.Errorf("start: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := stdin.Write(data); err != nil {
|
if _, err := io.Copy(stdin, data); err != nil {
|
||||||
stdin.Close()
|
stdin.Close()
|
||||||
return fmt.Errorf("write stdin: %w", err)
|
return fmt.Errorf("write stdin: %w", err)
|
||||||
}
|
}
|
||||||
stdin.Close()
|
if err := stdin.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close stdin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyServe(data []byte, mimeType string, pasteOnce bool) error {
|
func copyServeReader(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||||
|
cachedData, err := createClipboardCacheFile()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create clipboard cache file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(cachedData.Name())
|
||||||
|
|
||||||
|
if _, err := io.Copy(cachedData, data); err != nil {
|
||||||
|
return fmt.Errorf("cache clipboard data: %w", err)
|
||||||
|
}
|
||||||
|
if err := cachedData.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close temp cache file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return copyServeWithWriter(func(writer io.Writer) error {
|
||||||
|
cachedFile, err := os.Open(cachedData.Name())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open temp cache file: %w", err)
|
||||||
|
}
|
||||||
|
defer cachedFile.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(writer, cachedFile); err != nil {
|
||||||
|
return fmt.Errorf("write clipboard data: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, mimeType, pasteOnce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createClipboardCacheFile() (*os.File, error) {
|
||||||
|
preferredDirs := []string{}
|
||||||
|
|
||||||
|
if cacheDir, err := os.UserCacheDir(); err == nil {
|
||||||
|
preferredDirs = append(preferredDirs, filepath.Join(cacheDir, "dms", "clipboard"))
|
||||||
|
}
|
||||||
|
preferredDirs = append(preferredDirs, "/var/tmp/dms/clipboard")
|
||||||
|
|
||||||
|
for _, dir := range preferredDirs {
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cachedData, err := os.CreateTemp(dir, "dms-clipboard-*")
|
||||||
|
if err == nil {
|
||||||
|
return cachedData, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.CreateTemp("", "dms-clipboard-*")
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOnce bool) error {
|
||||||
display, err := wlclient.Connect("")
|
display, err := wlclient.Connect("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wayland connect: %w", err)
|
return fmt.Errorf("wayland connect: %w", err)
|
||||||
@@ -139,12 +215,18 @@ func copyServe(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
|
|
||||||
cancelled := make(chan struct{})
|
cancelled := make(chan struct{})
|
||||||
pasted := make(chan struct{}, 1)
|
pasted := make(chan struct{}, 1)
|
||||||
|
sendErr := make(chan error, 1)
|
||||||
|
|
||||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||||
defer syscall.Close(e.Fd)
|
defer syscall.Close(e.Fd)
|
||||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
file.Write(data)
|
if err := writeTo(file); err != nil {
|
||||||
|
select {
|
||||||
|
case sendErr <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case pasted <- struct{}{}:
|
case pasted <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
@@ -165,6 +247,8 @@ func copyServe(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
select {
|
select {
|
||||||
case <-cancelled:
|
case <-cancelled:
|
||||||
return nil
|
return nil
|
||||||
|
case err := <-sendErr:
|
||||||
|
return err
|
||||||
case <-pasted:
|
case <-pasted:
|
||||||
if pasteOnce {
|
if pasteOnce {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -644,7 +644,7 @@ func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalC
|
|||||||
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") {
|
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") {
|
||||||
startupSectionFound = true
|
startupSectionFound = true
|
||||||
result = append(result, "exec-once = dms run")
|
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 = ELECTRON_OZONE_PLATFORM_HINT,auto")
|
||||||
result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3")
|
result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3")
|
||||||
result = append(result, "env = QT_QPA_PLATFORMTHEME_QT6,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") {
|
if strings.Contains(line, "STARTUP APPS") {
|
||||||
insertLines := []string{
|
insertLines := []string{
|
||||||
"exec-once = dms run",
|
"exec-once = dms run",
|
||||||
"env = QT_QPA_PLATFORM,wayland",
|
"env = QT_QPA_PLATFORM,wayland;xcb",
|
||||||
"env = ELECTRON_OZONE_PLATFORM_HINT,auto",
|
"env = ELECTRON_OZONE_PLATFORM_HINT,auto",
|
||||||
"env = QT_QPA_PLATFORMTHEME,gtk3",
|
"env = QT_QPA_PLATFORMTHEME,gtk3",
|
||||||
"env = QT_QPA_PLATFORMTHEME_QT6,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 {
|
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
|
||||||
envVars := fmt.Sprintf(`environment {
|
envVars := fmt.Sprintf(`environment {
|
||||||
XDG_CURRENT_DESKTOP "niri"
|
XDG_CURRENT_DESKTOP "niri"
|
||||||
QT_QPA_PLATFORM "wayland"
|
QT_QPA_PLATFORM "wayland;xcb"
|
||||||
ELECTRON_OZONE_PLATFORM_HINT "auto"
|
ELECTRON_OZONE_PLATFORM_HINT "auto"
|
||||||
QT_QPA_PLATFORMTHEME "gtk3"
|
QT_QPA_PLATFORMTHEME "gtk3"
|
||||||
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
|
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ windowrule = float on, match:class ^(blueman-manager)$
|
|||||||
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
|
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
|
||||||
windowrule = float on, match:class ^(xdg-desktop-portal)$
|
windowrule = float on, match:class ^(xdg-desktop-portal)$
|
||||||
|
|
||||||
windowrule = noinitialfocus on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
windowrule = no_initial_focus on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
||||||
windowrule = pin 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 ^(firefox)$, match:title ^(Picture-in-Picture)$
|
||||||
@@ -111,6 +111,7 @@ windowrule = float on, match:class ^(zoom)$
|
|||||||
# windowrule = float on, match:class ^(org.quickshell)$
|
# windowrule = float on, match:class ^(org.quickshell)$
|
||||||
|
|
||||||
layerrule = no_anim on, match:namespace ^(quickshell)$
|
layerrule = no_anim on, match:namespace ^(quickshell)$
|
||||||
|
layerrule = no_anim on, match:namespace ^dms:.*
|
||||||
|
|
||||||
source = ./dms/colors.conf
|
source = ./dms/colors.conf
|
||||||
source = ./dms/outputs.conf
|
source = ./dms/outputs.conf
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ window-rule {
|
|||||||
// Open dms windows as floating by default
|
// Open dms windows as floating by default
|
||||||
window-rule {
|
window-rule {
|
||||||
match app-id=r#"org.quickshell$"#
|
match app-id=r#"org.quickshell$"#
|
||||||
|
match app-id=r#"com.danklinux.dms$"#
|
||||||
open-floating true
|
open-floating true
|
||||||
}
|
}
|
||||||
debug {
|
debug {
|
||||||
|
|||||||
+118
-62
@@ -26,6 +26,9 @@ func init() {
|
|||||||
Register("cachyos", "#08A283", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
Register("cachyos", "#08A283", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewArchDistribution(config, logChan)
|
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 {
|
Register("endeavouros", "#7F3FBF", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewArchDistribution(config, logChan)
|
return NewArchDistribution(config, logChan)
|
||||||
})
|
})
|
||||||
@@ -94,6 +97,7 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
|||||||
dependencies = append(dependencies, a.detectGit())
|
dependencies = append(dependencies, a.detectGit())
|
||||||
dependencies = append(dependencies, a.detectWindowManager(wm))
|
dependencies = append(dependencies, a.detectWindowManager(wm))
|
||||||
dependencies = append(dependencies, a.detectQuickshell())
|
dependencies = append(dependencies, a.detectQuickshell())
|
||||||
|
dependencies = append(dependencies, a.detectDMSGreeter())
|
||||||
dependencies = append(dependencies, a.detectXDGPortal())
|
dependencies = append(dependencies, a.detectXDGPortal())
|
||||||
dependencies = append(dependencies, a.detectAccountsService())
|
dependencies = append(dependencies, a.detectAccountsService())
|
||||||
|
|
||||||
@@ -121,12 +125,52 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
|
|||||||
return a.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", a.packageInstalled("accountsservice"))
|
return a.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", a.packageInstalled("accountsservice"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
|
||||||
|
return a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", a.packageInstalled("greetd-dms-greeter-git"))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
||||||
cmd := exec.Command("pacman", "-Q", pkg)
|
cmd := exec.Command("pacman", "-Q", pkg)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package
|
||||||
|
func parseSRCINFODeps(srcinfoPath string) (deps []string, makedeps []string, err error) {
|
||||||
|
data, err := os.ReadFile(srcinfoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
var pkg string
|
||||||
|
var target *[]string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "makedepends = "):
|
||||||
|
pkg = strings.TrimPrefix(line, "makedepends = ")
|
||||||
|
target = &makedeps
|
||||||
|
case strings.HasPrefix(line, "depends = "):
|
||||||
|
pkg = strings.TrimPrefix(line, "depends = ")
|
||||||
|
target = &deps
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Strip version constraint (>=, <=, >, <, =) and colon-descriptions
|
||||||
|
if idx := strings.IndexAny(pkg, "><:="); idx >= 0 {
|
||||||
|
pkg = pkg[:idx]
|
||||||
|
}
|
||||||
|
pkg = strings.TrimSpace(pkg)
|
||||||
|
if pkg != "" {
|
||||||
|
*target = append(*target, pkg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deps, makedeps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) isInSystemRepo(pkg string) bool {
|
||||||
|
return exec.Command("pacman", "-Si", pkg).Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||||
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
||||||
}
|
}
|
||||||
@@ -136,6 +180,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
|||||||
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
|
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
|
||||||
"git": {Name: "git", Repository: RepoTypeSystem},
|
"git": {Name: "git", Repository: RepoTypeSystem},
|
||||||
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
|
||||||
|
"dms-greeter": {Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR},
|
||||||
"matugen": a.getMatugenMapping(variants["matugen"]),
|
"matugen": a.getMatugenMapping(variants["matugen"]),
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
|
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||||
@@ -431,29 +476,10 @@ func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []st
|
|||||||
a.log(fmt.Sprintf("Installing AUR packages manually: %s", strings.Join(packages, ", ")))
|
a.log(fmt.Sprintf("Installing AUR packages manually: %s", strings.Join(packages, ", ")))
|
||||||
|
|
||||||
hasNiri := false
|
hasNiri := false
|
||||||
hasQuickshell := false
|
|
||||||
for _, pkg := range packages {
|
for _, pkg := range packages {
|
||||||
if pkg == "niri-git" {
|
if pkg == "niri-git" {
|
||||||
hasNiri = true
|
hasNiri = true
|
||||||
}
|
}
|
||||||
if pkg == "quickshell" || pkg == "quickshell-git" {
|
|
||||||
hasQuickshell = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If quickshell is in the list, always reinstall google-breakpad first
|
|
||||||
if hasQuickshell {
|
|
||||||
progressChan <- InstallProgressMsg{
|
|
||||||
Phase: PhaseAURPackages,
|
|
||||||
Progress: 0.63,
|
|
||||||
Step: "Reinstalling google-breakpad for quickshell...",
|
|
||||||
IsComplete: false,
|
|
||||||
CommandInfo: "Reinstalling prerequisite AUR package for quickshell",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := a.installSingleAURPackage(ctx, "google-breakpad", sudoPassword, progressChan, 0.63, 0.65); err != nil {
|
|
||||||
return fmt.Errorf("failed to reinstall google-breakpad prerequisite for quickshell: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If niri is in the list, install makepkg-git-lfs-proto first if not already installed
|
// If niri is in the list, install makepkg-git-lfs-proto first if not already installed
|
||||||
@@ -534,6 +560,16 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64) error {
|
func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64) error {
|
||||||
|
return a.installSingleAURPackageInternal(ctx, pkg, sudoPassword, progressChan, startProgress, endProgress, make(map[string]bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64, visited map[string]bool) error {
|
||||||
|
if visited[pkg] {
|
||||||
|
a.log(fmt.Sprintf("Skipping %s (already being installed, cycle detected)", pkg))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
visited[pkg] = true
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
@@ -607,48 +643,8 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
|
|||||||
return fmt.Errorf("failed to remove optdepends from .SRCINFO for %s: %w", pkg, err)
|
return fmt.Errorf("failed to remove optdepends from .SRCINFO for %s: %w", pkg, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip dependency installation for dms-shell-git and dms-shell-bin
|
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
|
||||||
// since we manually manage those dependencies
|
if pkg == "dms-shell-bin" {
|
||||||
if pkg != "dms-shell-git" && pkg != "dms-shell-bin" {
|
|
||||||
// Pre-install dependencies from .SRCINFO
|
|
||||||
progressChan <- InstallProgressMsg{
|
|
||||||
Phase: PhaseAURPackages,
|
|
||||||
Progress: startProgress + 0.3*(endProgress-startProgress),
|
|
||||||
Step: fmt.Sprintf("Installing dependencies for %s...", pkg),
|
|
||||||
IsComplete: false,
|
|
||||||
CommandInfo: "Installing package dependencies and makedepends",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install dependencies and makedepends explicitly
|
|
||||||
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
|
|
||||||
|
|
||||||
depsCmd := exec.CommandContext(ctx, "bash", "-c",
|
|
||||||
fmt.Sprintf(`
|
|
||||||
deps=$(grep "depends = " "%s" | grep -v "makedepends" | sed 's/.*depends = //' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
|
||||||
if [[ "%s" == *"quickshell"* ]]; then
|
|
||||||
deps=$(echo "$deps" | sed 's/google-breakpad//g' | sed 's/ / /g' | sed 's/^ *//g' | sed 's/ *$//g')
|
|
||||||
fi
|
|
||||||
if [ ! -z "$deps" ] && [ "$deps" != " " ]; then
|
|
||||||
echo '%s' | sudo -S pacman -S --needed --noconfirm $deps
|
|
||||||
fi
|
|
||||||
`, srcinfoPath, pkg, sudoPassword))
|
|
||||||
|
|
||||||
if err := a.runWithProgress(depsCmd, progressChan, PhaseAURPackages, startProgress+0.3*(endProgress-startProgress), startProgress+0.35*(endProgress-startProgress)); err != nil {
|
|
||||||
return fmt.Errorf("FAILED to install runtime dependencies for %s: %w", pkg, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
makedepsCmd := exec.CommandContext(ctx, "bash", "-c",
|
|
||||||
fmt.Sprintf(`
|
|
||||||
makedeps=$(grep -E "^[[:space:]]*makedepends = " "%s" | sed 's/^[[:space:]]*makedepends = //' | tr '\n' ' ')
|
|
||||||
if [ ! -z "$makedeps" ]; then
|
|
||||||
echo '%s' | sudo -S pacman -S --needed --noconfirm $makedeps
|
|
||||||
fi
|
|
||||||
`, srcinfoPath, sudoPassword))
|
|
||||||
|
|
||||||
if err := a.runWithProgress(makedepsCmd, progressChan, PhaseAURPackages, startProgress+0.35*(endProgress-startProgress), startProgress+0.4*(endProgress-startProgress)); err != nil {
|
|
||||||
return fmt.Errorf("FAILED to install make dependencies for %s: %w", pkg, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: PhaseAURPackages,
|
Phase: PhaseAURPackages,
|
||||||
Progress: startProgress + 0.35*(endProgress-startProgress),
|
Progress: startProgress + 0.35*(endProgress-startProgress),
|
||||||
@@ -656,6 +652,66 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
|
|||||||
IsComplete: false,
|
IsComplete: false,
|
||||||
LogOutput: fmt.Sprintf("Dependencies for %s are installed separately", pkg),
|
LogOutput: fmt.Sprintf("Dependencies for %s are installed separately", pkg),
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseAURPackages,
|
||||||
|
Progress: startProgress + 0.3*(endProgress-startProgress),
|
||||||
|
Step: fmt.Sprintf("Resolving dependencies for %s...", pkg),
|
||||||
|
IsComplete: false,
|
||||||
|
CommandInfo: "Classifying dependencies as system or AUR",
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeDeps, makeDeps, err := parseSRCINFODeps(srcinfoPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse .SRCINFO for %s: %w", pkg, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var systemPkgs []string
|
||||||
|
var aurPkgs []string
|
||||||
|
|
||||||
|
for _, dep := range append(runtimeDeps, makeDeps...) {
|
||||||
|
if seen[dep] || a.packageInstalled(dep) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[dep] = true
|
||||||
|
if a.isInSystemRepo(dep) {
|
||||||
|
systemPkgs = append(systemPkgs, dep)
|
||||||
|
} else {
|
||||||
|
aurPkgs = append(aurPkgs, dep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(systemPkgs) > 0 {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseAURPackages,
|
||||||
|
Progress: startProgress + 0.32*(endProgress-startProgress),
|
||||||
|
Step: fmt.Sprintf("Installing %d system dependencies for %s...", len(systemPkgs), pkg),
|
||||||
|
IsComplete: false,
|
||||||
|
CommandInfo: fmt.Sprintf("sudo pacman -S --needed --noconfirm %s", strings.Join(systemPkgs, " ")),
|
||||||
|
}
|
||||||
|
if err := a.installSystemPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to install system dependencies for %s: %w", pkg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, aurDep := range aurPkgs {
|
||||||
|
a.log(fmt.Sprintf("Dependency %s is AUR-only, building from source...", aurDep))
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseAURPackages,
|
||||||
|
Progress: startProgress + 0.35*(endProgress-startProgress),
|
||||||
|
Step: fmt.Sprintf("Installing AUR dependency %s for %s...", aurDep, pkg),
|
||||||
|
IsComplete: false,
|
||||||
|
CommandInfo: fmt.Sprintf("Building AUR dependency: %s", aurDep),
|
||||||
|
}
|
||||||
|
if err := a.installSingleAURPackageInternal(ctx, aurDep, sudoPassword, progressChan,
|
||||||
|
startProgress+0.35*(endProgress-startProgress),
|
||||||
|
startProgress+0.39*(endProgress-startProgress),
|
||||||
|
visited,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("failed to install AUR dependency %s for %s: %w", aurDep, pkg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
@@ -668,7 +724,7 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
|
|||||||
|
|
||||||
buildCmd := exec.CommandContext(ctx, "makepkg", "--noconfirm")
|
buildCmd := exec.CommandContext(ctx, "makepkg", "--noconfirm")
|
||||||
buildCmd.Dir = packageDir
|
buildCmd.Dir = packageDir
|
||||||
buildCmd.Env = append(os.Environ(), "PKGEXT=.pkg.tar") // Disable compression for speed
|
buildCmd.Env = append(os.Environ(), "PKGEXT=.pkg.tar")
|
||||||
|
|
||||||
if err := a.runWithProgress(buildCmd, progressChan, PhaseAURPackages, startProgress+0.4*(endProgress-startProgress), startProgress+0.7*(endProgress-startProgress)); err != nil {
|
if err := a.runWithProgress(buildCmd, progressChan, PhaseAURPackages, startProgress+0.4*(endProgress-startProgress), startProgress+0.7*(endProgress-startProgress)); err != nil {
|
||||||
return fmt.Errorf("failed to build %s: %w", pkg, err)
|
return fmt.Errorf("failed to build %s: %w", pkg, err)
|
||||||
|
|||||||
@@ -102,6 +102,19 @@ func (b *BaseDistribution) detectPackage(name, description string, installed boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *BaseDistribution) detectOptionalPackage(name, description string, installed bool) deps.Dependency {
|
||||||
|
status := deps.StatusMissing
|
||||||
|
if installed {
|
||||||
|
status = deps.StatusInstalled
|
||||||
|
}
|
||||||
|
return deps.Dependency{
|
||||||
|
Name: name,
|
||||||
|
Status: status,
|
||||||
|
Description: description,
|
||||||
|
Required: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (b *BaseDistribution) detectGit() deps.Dependency {
|
func (b *BaseDistribution) detectGit() deps.Dependency {
|
||||||
return b.detectCommand("git", "Version control system")
|
return b.detectCommand("git", "Version control system")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
@@ -61,6 +60,7 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
dependencies = append(dependencies, d.detectGit())
|
dependencies = append(dependencies, d.detectGit())
|
||||||
dependencies = append(dependencies, d.detectWindowManager(wm))
|
dependencies = append(dependencies, d.detectWindowManager(wm))
|
||||||
dependencies = append(dependencies, d.detectQuickshell())
|
dependencies = append(dependencies, d.detectQuickshell())
|
||||||
|
dependencies = append(dependencies, d.detectDMSGreeter())
|
||||||
dependencies = append(dependencies, d.detectXDGPortal())
|
dependencies = append(dependencies, d.detectXDGPortal())
|
||||||
dependencies = append(dependencies, d.detectAccountsService())
|
dependencies = append(dependencies, d.detectAccountsService())
|
||||||
|
|
||||||
@@ -86,12 +86,27 @@ func (d *DebianDistribution) detectAccountsService() deps.Dependency {
|
|||||||
return d.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", d.packageInstalled("accountsservice"))
|
return d.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", d.packageInstalled("accountsservice"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DebianDistribution) detectDMSGreeter() deps.Dependency {
|
||||||
|
return d.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", d.packageInstalled("dms-greeter"))
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DebianDistribution) packageInstalled(pkg string) bool {
|
func (d *DebianDistribution) packageInstalled(pkg string) bool {
|
||||||
cmd := exec.Command("dpkg", "-l", pkg)
|
cmd := exec.Command("dpkg", "-l", pkg)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func debianRepoArchitecture(arch string) string {
|
||||||
|
switch arch {
|
||||||
|
case "amd64", "x86_64":
|
||||||
|
return "amd64"
|
||||||
|
case "arm64", "aarch64":
|
||||||
|
return "arm64"
|
||||||
|
default:
|
||||||
|
return arch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DebianDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
func (d *DebianDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||||
return d.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
return d.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
||||||
}
|
}
|
||||||
@@ -108,6 +123,7 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
// DMS packages from OBS with variant support
|
// DMS packages from OBS with variant support
|
||||||
"dms (DankMaterialShell)": d.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
"dms (DankMaterialShell)": d.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||||
"quickshell": d.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": d.getQuickshellMapping(variants["quickshell"]),
|
||||||
|
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
@@ -430,7 +446,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add repository
|
// 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, debianRepoArchitecture(osInfo.Architecture), baseURL)
|
||||||
|
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: PhaseSystemPackages,
|
Phase: PhaseSystemPackages,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ func init() {
|
|||||||
Register("fedora", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
Register("fedora", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewFedoraDistribution(config, logChan)
|
return NewFedoraDistribution(config, logChan)
|
||||||
})
|
})
|
||||||
|
Register("evernight", "#72B8DC", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
|
return NewFedoraDistribution(config, logChan)
|
||||||
|
})
|
||||||
Register("nobara", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
Register("nobara", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewFedoraDistribution(config, logChan)
|
return NewFedoraDistribution(config, logChan)
|
||||||
})
|
})
|
||||||
@@ -75,6 +78,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
dependencies = append(dependencies, f.detectGit())
|
dependencies = append(dependencies, f.detectGit())
|
||||||
dependencies = append(dependencies, f.detectWindowManager(wm))
|
dependencies = append(dependencies, f.detectWindowManager(wm))
|
||||||
dependencies = append(dependencies, f.detectQuickshell())
|
dependencies = append(dependencies, f.detectQuickshell())
|
||||||
|
dependencies = append(dependencies, f.detectDMSGreeter())
|
||||||
dependencies = append(dependencies, f.detectXDGPortal())
|
dependencies = append(dependencies, f.detectXDGPortal())
|
||||||
dependencies = append(dependencies, f.detectAccountsService())
|
dependencies = append(dependencies, f.detectAccountsService())
|
||||||
|
|
||||||
@@ -120,6 +124,7 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
|
|
||||||
// COPR packages
|
// COPR packages
|
||||||
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
|
||||||
|
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||||
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||||
@@ -191,6 +196,10 @@ func (f *FedoraDistribution) detectAccountsService() deps.Dependency {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *FedoraDistribution) detectDMSGreeter() deps.Dependency {
|
||||||
|
return f.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", f.packageInstalled("dms-greeter"))
|
||||||
|
}
|
||||||
|
|
||||||
func (f *FedoraDistribution) getPrerequisites() []string {
|
func (f *FedoraDistribution) getPrerequisites() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"dnf-plugins-core",
|
"dnf-plugins-core",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const (
|
|||||||
PhaseAURPackages
|
PhaseAURPackages
|
||||||
PhaseCursorTheme
|
PhaseCursorTheme
|
||||||
PhaseConfiguration
|
PhaseConfiguration
|
||||||
|
PhaseGreeterSetup
|
||||||
PhaseComplete
|
PhaseComplete
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
|
|||||||
dependencies = append(dependencies, o.detectGit())
|
dependencies = append(dependencies, o.detectGit())
|
||||||
dependencies = append(dependencies, o.detectWindowManager(wm))
|
dependencies = append(dependencies, o.detectWindowManager(wm))
|
||||||
dependencies = append(dependencies, o.detectQuickshell())
|
dependencies = append(dependencies, o.detectQuickshell())
|
||||||
|
dependencies = append(dependencies, o.detectDMSGreeter())
|
||||||
dependencies = append(dependencies, o.detectXDGPortal())
|
dependencies = append(dependencies, o.detectXDGPortal())
|
||||||
dependencies = append(dependencies, o.detectAccountsService())
|
dependencies = append(dependencies, o.detectAccountsService())
|
||||||
|
|
||||||
@@ -100,6 +101,10 @@ func (o *OpenSUSEDistribution) packageInstalled(pkg string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *OpenSUSEDistribution) detectDMSGreeter() deps.Dependency {
|
||||||
|
return o.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", o.packageInstalled("dms-greeter"))
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OpenSUSEDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
func (o *OpenSUSEDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||||
return o.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
return o.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
||||||
}
|
}
|
||||||
@@ -116,6 +121,7 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
|||||||
// DMS packages from OBS
|
// DMS packages from OBS
|
||||||
"dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
"dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||||
"quickshell": o.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": o.getQuickshellMapping(variants["quickshell"]),
|
||||||
|
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
dependencies = append(dependencies, u.detectGit())
|
dependencies = append(dependencies, u.detectGit())
|
||||||
dependencies = append(dependencies, u.detectWindowManager(wm))
|
dependencies = append(dependencies, u.detectWindowManager(wm))
|
||||||
dependencies = append(dependencies, u.detectQuickshell())
|
dependencies = append(dependencies, u.detectQuickshell())
|
||||||
|
dependencies = append(dependencies, u.detectDMSGreeter())
|
||||||
dependencies = append(dependencies, u.detectXDGPortal())
|
dependencies = append(dependencies, u.detectXDGPortal())
|
||||||
dependencies = append(dependencies, u.detectAccountsService())
|
dependencies = append(dependencies, u.detectAccountsService())
|
||||||
|
|
||||||
@@ -94,6 +95,10 @@ func (u *UbuntuDistribution) detectAccountsService() deps.Dependency {
|
|||||||
return u.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", u.packageInstalled("accountsservice"))
|
return u.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", u.packageInstalled("accountsservice"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UbuntuDistribution) detectDMSGreeter() deps.Dependency {
|
||||||
|
return u.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", u.packageInstalled("dms-greeter"))
|
||||||
|
}
|
||||||
|
|
||||||
func (u *UbuntuDistribution) packageInstalled(pkg string) bool {
|
func (u *UbuntuDistribution) packageInstalled(pkg string) bool {
|
||||||
cmd := exec.Command("dpkg", "-l", pkg)
|
cmd := exec.Command("dpkg", "-l", pkg)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
@@ -116,6 +121,7 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
// DMS packages from PPAs
|
// DMS packages from PPAs
|
||||||
"dms (DankMaterialShell)": u.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
"dms (DankMaterialShell)": u.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||||
"quickshell": u.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": u.getQuickshellMapping(variants["quickshell"]),
|
||||||
|
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# AppArmor profile for dms-greeter
|
||||||
|
#
|
||||||
|
# Managed by DMS — regenerated on every `dms greeter install` / `dms greeter sync`.
|
||||||
|
# Manual edits will be overwritten on next sync.
|
||||||
|
#
|
||||||
|
# Mode: complain (denials are logged, nothing is blocked)
|
||||||
|
# To switch to enforce after validating with `aa-logprof`:
|
||||||
|
# sudo aa-enforce /etc/apparmor.d/usr.bin.dms-greeter
|
||||||
|
#
|
||||||
|
#include <tunables/global>
|
||||||
|
|
||||||
|
profile dms-greeter /usr/bin/dms-greeter flags=(complain) {
|
||||||
|
#include <abstractions/base>
|
||||||
|
#include <abstractions/bash>
|
||||||
|
|
||||||
|
# The launcher script itself
|
||||||
|
/usr/bin/dms-greeter r,
|
||||||
|
|
||||||
|
# Cache directory — created by dms greeter sync/enable with greeter:greeter ownership
|
||||||
|
/var/cache/dms-greeter/ rw,
|
||||||
|
/var/cache/dms-greeter/** rwlk,
|
||||||
|
|
||||||
|
# DMS config — packaged path
|
||||||
|
/usr/share/quickshell/dms-greeter/ r,
|
||||||
|
/usr/share/quickshell/dms-greeter/** r,
|
||||||
|
/usr/share/quickshell/ r,
|
||||||
|
/usr/share/quickshell/** r,
|
||||||
|
|
||||||
|
# DMS config — system and user overrides
|
||||||
|
/etc/dms/ r,
|
||||||
|
/etc/dms/** r,
|
||||||
|
/usr/share/dms/ r,
|
||||||
|
/usr/share/dms/** r,
|
||||||
|
/home/*/.config/quickshell/ r,
|
||||||
|
/home/*/.config/quickshell/** r,
|
||||||
|
/root/.config/quickshell/ r,
|
||||||
|
/root/.config/quickshell/** r,
|
||||||
|
|
||||||
|
# greetd / PAM — read-only for session setup
|
||||||
|
/etc/greetd/ r,
|
||||||
|
/etc/greetd/** r,
|
||||||
|
/etc/pam.d/ r,
|
||||||
|
/etc/pam.d/** r,
|
||||||
|
/usr/lib/pam.d/ r,
|
||||||
|
/usr/lib/pam.d/** r,
|
||||||
|
|
||||||
|
# Compositor binaries — run unconfined so each compositor uses its own profile
|
||||||
|
/usr/bin/niri Ux,
|
||||||
|
/usr/bin/hyprland Ux,
|
||||||
|
/usr/bin/Hyprland Ux,
|
||||||
|
/usr/bin/sway Ux,
|
||||||
|
/usr/bin/labwc Ux,
|
||||||
|
/usr/bin/scroll Ux,
|
||||||
|
/usr/bin/miracle-wm Ux,
|
||||||
|
/usr/bin/mango Ux,
|
||||||
|
|
||||||
|
# Quickshell — run unconfined (has its own compositor profile on some distros)
|
||||||
|
/usr/bin/qs Ux,
|
||||||
|
/usr/bin/quickshell Ux,
|
||||||
|
|
||||||
|
# Wayland / XDG runtime (pipewire, wireplumber, wayland socket)
|
||||||
|
/run/user/[0-9]*/ rw,
|
||||||
|
/run/user/[0-9]*/** rw,
|
||||||
|
|
||||||
|
# DRM / GPU devices (required for Wayland compositor startup)
|
||||||
|
/dev/dri/ r,
|
||||||
|
/dev/dri/* rw,
|
||||||
|
/dev/udmabuf rw,
|
||||||
|
|
||||||
|
# Input devices
|
||||||
|
/dev/input/ r,
|
||||||
|
/dev/input/* r,
|
||||||
|
|
||||||
|
# Systemd journal / logging
|
||||||
|
/run/systemd/journal/socket rw,
|
||||||
|
/dev/log rw,
|
||||||
|
|
||||||
|
# Shell helper binaries invoked by the launcher script
|
||||||
|
/usr/bin/env ix,
|
||||||
|
/usr/bin/mkdir ix,
|
||||||
|
/usr/bin/cat ix,
|
||||||
|
/usr/bin/grep ix,
|
||||||
|
/usr/bin/dirname ix,
|
||||||
|
/usr/bin/basename ix,
|
||||||
|
/usr/bin/command ix,
|
||||||
|
/bin/env ix,
|
||||||
|
/bin/mkdir ix,
|
||||||
|
|
||||||
|
# Signal management (compositor lifecycle)
|
||||||
|
signal (send, receive) set=("term", "int", "hup", "kill"),
|
||||||
|
}
|
||||||
+1764
-117
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
|||||||
|
package greeter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeTestJSON(t *testing.T, path string, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create parent dir for %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGreeterThemeSyncState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
settingsJSON string
|
||||||
|
sessionJSON string
|
||||||
|
wantSourcePath string
|
||||||
|
wantResolvedWallpaper string
|
||||||
|
wantDynamicOverrideUsed bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "dynamic theme with greeter wallpaper override uses generated greeter colors",
|
||||||
|
settingsJSON: `{
|
||||||
|
"currentThemeName": "dynamic",
|
||||||
|
"greeterWallpaperPath": "Pictures/blue.jpg",
|
||||||
|
"matugenScheme": "scheme-tonal-spot",
|
||||||
|
"iconTheme": "Papirus"
|
||||||
|
}`,
|
||||||
|
sessionJSON: `{"isLightMode":true}`,
|
||||||
|
wantSourcePath: filepath.Join(".cache", "DankMaterialShell", "greeter-colors", "dms-colors.json"),
|
||||||
|
wantResolvedWallpaper: filepath.Join("Pictures", "blue.jpg"),
|
||||||
|
wantDynamicOverrideUsed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dynamic theme without override uses desktop colors",
|
||||||
|
settingsJSON: `{
|
||||||
|
"currentThemeName": "dynamic",
|
||||||
|
"greeterWallpaperPath": ""
|
||||||
|
}`,
|
||||||
|
sessionJSON: `{"isLightMode":false}`,
|
||||||
|
wantSourcePath: filepath.Join(".cache", "DankMaterialShell", "dms-colors.json"),
|
||||||
|
wantResolvedWallpaper: "",
|
||||||
|
wantDynamicOverrideUsed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-dynamic theme keeps desktop colors even with override wallpaper",
|
||||||
|
settingsJSON: `{
|
||||||
|
"currentThemeName": "purple",
|
||||||
|
"greeterWallpaperPath": "/tmp/blue.jpg"
|
||||||
|
}`,
|
||||||
|
sessionJSON: `{"isLightMode":false}`,
|
||||||
|
wantSourcePath: filepath.Join(".cache", "DankMaterialShell", "dms-colors.json"),
|
||||||
|
wantResolvedWallpaper: "/tmp/blue.jpg",
|
||||||
|
wantDynamicOverrideUsed: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
homeDir := t.TempDir()
|
||||||
|
writeTestJSON(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON)
|
||||||
|
writeTestJSON(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON)
|
||||||
|
|
||||||
|
state, err := resolveGreeterThemeSyncState(homeDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveGreeterThemeSyncState returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := state.effectiveColorsSource(homeDir); got != filepath.Join(homeDir, tt.wantSourcePath) {
|
||||||
|
t.Fatalf("effectiveColorsSource = %q, want %q", got, filepath.Join(homeDir, tt.wantSourcePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
wantResolvedWallpaper := tt.wantResolvedWallpaper
|
||||||
|
if wantResolvedWallpaper != "" && !filepath.IsAbs(wantResolvedWallpaper) {
|
||||||
|
wantResolvedWallpaper = filepath.Join(homeDir, wantResolvedWallpaper)
|
||||||
|
}
|
||||||
|
if state.ResolvedGreeterWallpaperPath != wantResolvedWallpaper {
|
||||||
|
t.Fatalf("ResolvedGreeterWallpaperPath = %q, want %q", state.ResolvedGreeterWallpaperPath, wantResolvedWallpaper)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.UsesDynamicWallpaperOverride != tt.wantDynamicOverrideUsed {
|
||||||
|
t.Fatalf("UsesDynamicWallpaperOverride = %v, want %v", state.UsesDynamicWallpaperOverride, tt.wantDynamicOverrideUsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -341,6 +341,8 @@ func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
|
|||||||
val := arg.ValueString()
|
val := arg.ValueString()
|
||||||
if val == "" {
|
if val == "" {
|
||||||
parts = append(parts, `""`)
|
parts = append(parts, `""`)
|
||||||
|
} else if strings.ContainsAny(val, " \t") {
|
||||||
|
parts = append(parts, `"`+strings.ReplaceAll(val, `"`, `\"`)+`"`)
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, val)
|
parts = append(parts, val)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package providers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||||
@@ -18,14 +19,21 @@ func NewSwayProvider(configPath string) *SwayProvider {
|
|||||||
_, scrollEnvSet := os.LookupEnv("SCROLLSOCK")
|
_, scrollEnvSet := os.LookupEnv("SCROLLSOCK")
|
||||||
|
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
configDir = ""
|
||||||
|
}
|
||||||
if scrollEnvSet {
|
if scrollEnvSet {
|
||||||
configPath = "$HOME/.config/scroll"
|
if configDir != "" {
|
||||||
|
configPath = filepath.Join(configDir, "scroll")
|
||||||
|
}
|
||||||
isScroll = true
|
isScroll = true
|
||||||
} else {
|
} else {
|
||||||
configPath = "$HOME/.config/sway"
|
if configDir != "" {
|
||||||
|
configPath = filepath.Join(configDir, "sway")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Determine isScroll based on the provided config path
|
|
||||||
isScroll = strings.Contains(configPath, "scroll")
|
isScroll = strings.Contains(configPath, "scroll")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,16 +44,16 @@ func NewSwayProvider(configPath string) *SwayProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SwayProvider) Name() string {
|
func (s *SwayProvider) Name() string {
|
||||||
if s != nil && s.isScroll {
|
|
||||||
return "scroll"
|
|
||||||
}
|
|
||||||
if s == nil {
|
if s == nil {
|
||||||
_, ok := os.LookupEnv("SCROLLSOCK")
|
if os.Getenv("SCROLLSOCK") != "" {
|
||||||
if ok {
|
|
||||||
return "scroll"
|
return "scroll"
|
||||||
}
|
}
|
||||||
|
return "sway"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.isScroll {
|
||||||
|
return "scroll"
|
||||||
|
}
|
||||||
return "sway"
|
return "sway"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ func TestSwayProviderName(t *testing.T) {
|
|||||||
|
|
||||||
func TestSwayProviderDefaultPath(t *testing.T) {
|
func TestSwayProviderDefaultPath(t *testing.T) {
|
||||||
provider := NewSwayProvider("")
|
provider := NewSwayProvider("")
|
||||||
if provider.configPath != "$HOME/.config/sway" {
|
configDir, err := os.UserConfigDir()
|
||||||
t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/sway")
|
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,7 +1,9 @@
|
|||||||
package matugen
|
package matugen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
@@ -19,6 +21,8 @@ import (
|
|||||||
"github.com/lucasb-eyer/go-colorful"
|
"github.com/lucasb-eyer/go-colorful"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrNoChanges = errors.New("no color changes")
|
||||||
|
|
||||||
type ColorMode string
|
type ColorMode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -33,6 +37,7 @@ const (
|
|||||||
TemplateKindTerminal
|
TemplateKindTerminal
|
||||||
TemplateKindGTK
|
TemplateKindGTK
|
||||||
TemplateKindVSCode
|
TemplateKindVSCode
|
||||||
|
TemplateKindEmacs
|
||||||
)
|
)
|
||||||
|
|
||||||
type TemplateDef struct {
|
type TemplateDef struct {
|
||||||
@@ -53,7 +58,7 @@ var templateRegistry = []TemplateDef{
|
|||||||
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
|
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
|
||||||
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
|
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
|
||||||
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
||||||
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
||||||
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
||||||
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
||||||
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
||||||
@@ -65,7 +70,8 @@ var templateRegistry = []TemplateDef{
|
|||||||
{ID: "dgop", Commands: []string{"dgop"}, ConfigFile: "dgop.toml"},
|
{ID: "dgop", Commands: []string{"dgop"}, ConfigFile: "dgop.toml"},
|
||||||
{ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true},
|
{ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true},
|
||||||
{ID: "vscode", Kind: TemplateKindVSCode},
|
{ID: "vscode", Kind: TemplateKindVSCode},
|
||||||
{ID: "emacs", Commands: []string{"emacs"}, ConfigFile: "emacs.toml"},
|
{ID: "emacs", Commands: []string{"emacs"}, ConfigFile: "emacs.toml", Kind: TemplateKindEmacs},
|
||||||
|
{ID: "zed", Commands: []string{"zed", "zeditor", "zedit"}, ConfigFile: "zed.toml"},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ColorMode) GTKTheme() string {
|
func (c *ColorMode) GTKTheme() string {
|
||||||
@@ -78,8 +84,10 @@ func (c *ColorMode) GTKTheme() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
matugenVersionOnce sync.Once
|
matugenVersionMu sync.Mutex
|
||||||
|
matugenVersionOK bool
|
||||||
matugenSupportsCOE bool
|
matugenSupportsCOE bool
|
||||||
|
matugenIsV4 bool
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -92,6 +100,7 @@ type Options struct {
|
|||||||
IconTheme string
|
IconTheme string
|
||||||
MatugenType string
|
MatugenType string
|
||||||
RunUserTemplates bool
|
RunUserTemplates bool
|
||||||
|
ColorsOnly bool
|
||||||
StockColors string
|
StockColors string
|
||||||
SyncModeWithPortal bool
|
SyncModeWithPortal bool
|
||||||
TerminalsAlwaysDark bool
|
TerminalsAlwaysDark bool
|
||||||
@@ -157,8 +166,14 @@ func Run(opts Options) error {
|
|||||||
|
|
||||||
log.Infof("Building theme: %s %s (%s)", opts.Kind, opts.Value, opts.Mode)
|
log.Infof("Building theme: %s %s (%s)", opts.Kind, opts.Value, opts.Mode)
|
||||||
|
|
||||||
if err := buildOnce(&opts); err != nil {
|
changed, buildErr := buildOnce(&opts)
|
||||||
return err
|
if buildErr != nil {
|
||||||
|
return buildErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
log.Info("No color changes detected, skipping refresh")
|
||||||
|
return ErrNoChanges
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.SyncModeWithPortal {
|
if opts.SyncModeWithPortal {
|
||||||
@@ -169,25 +184,27 @@ func Run(opts Options) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOnce(opts *Options) error {
|
func buildOnce(opts *Options) (bool, error) {
|
||||||
cfgFile, err := os.CreateTemp("", "matugen-config-*.toml")
|
cfgFile, err := os.CreateTemp("", "matugen-config-*.toml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp config: %w", err)
|
return false, fmt.Errorf("failed to create temp config: %w", err)
|
||||||
}
|
}
|
||||||
defer os.Remove(cfgFile.Name())
|
defer os.Remove(cfgFile.Name())
|
||||||
defer cfgFile.Close()
|
defer cfgFile.Close()
|
||||||
|
|
||||||
tmpDir, err := os.MkdirTemp("", "matugen-templates-*")
|
tmpDir, err := os.MkdirTemp("", "matugen-templates-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp dir: %w", err)
|
return false, fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
if err := buildMergedConfig(opts, cfgFile, tmpDir); err != nil {
|
if err := buildMergedConfig(opts, cfgFile, tmpDir); err != nil {
|
||||||
return fmt.Errorf("failed to build config: %w", err)
|
return false, fmt.Errorf("failed to build config: %w", err)
|
||||||
}
|
}
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
|
|
||||||
|
oldColors, _ := os.ReadFile(opts.ColorsOutput())
|
||||||
|
|
||||||
var primaryDark, primaryLight, surface string
|
var primaryDark, primaryLight, surface string
|
||||||
var dank16JSON string
|
var dank16JSON string
|
||||||
var importArgs []string
|
var importArgs []string
|
||||||
@@ -199,7 +216,7 @@ func buildOnce(opts *Options) error {
|
|||||||
surface = extractNestedColor(opts.StockColors, "surface", "dark")
|
surface = extractNestedColor(opts.StockColors, "surface", "dark")
|
||||||
|
|
||||||
if primaryDark == "" {
|
if primaryDark == "" {
|
||||||
return fmt.Errorf("failed to extract primary dark from stock colors")
|
return false, fmt.Errorf("failed to extract primary dark from stock colors")
|
||||||
}
|
}
|
||||||
if primaryLight == "" {
|
if primaryLight == "" {
|
||||||
primaryLight = primaryDark
|
primaryLight = primaryDark
|
||||||
@@ -213,14 +230,14 @@ func buildOnce(opts *Options) error {
|
|||||||
args := []string{"color", "hex", primaryDark, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()}
|
args := []string{"color", "hex", primaryDark, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()}
|
||||||
args = append(args, importArgs...)
|
args = append(args, importArgs...)
|
||||||
if err := runMatugen(args); err != nil {
|
if err := runMatugen(args); err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Infof("Using dynamic theme from %s: %s", opts.Kind, opts.Value)
|
log.Infof("Using dynamic theme from %s: %s", opts.Kind, opts.Value)
|
||||||
|
|
||||||
matJSON, err := runMatugenDryRun(opts)
|
matJSON, err := runMatugenDryRun(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("matugen dry-run failed: %w", err)
|
return false, fmt.Errorf("matugen dry-run failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
primaryDark = extractMatugenColor(matJSON, "primary", "dark")
|
primaryDark = extractMatugenColor(matJSON, "primary", "dark")
|
||||||
@@ -228,7 +245,7 @@ func buildOnce(opts *Options) error {
|
|||||||
surface = extractMatugenColor(matJSON, "surface", "dark")
|
surface = extractMatugenColor(matJSON, "surface", "dark")
|
||||||
|
|
||||||
if primaryDark == "" {
|
if primaryDark == "" {
|
||||||
return fmt.Errorf("failed to extract primary color")
|
return false, fmt.Errorf("failed to extract primary color")
|
||||||
}
|
}
|
||||||
if primaryLight == "" {
|
if primaryLight == "" {
|
||||||
primaryLight = primaryDark
|
primaryLight = primaryDark
|
||||||
@@ -249,10 +266,19 @@ func buildOnce(opts *Options) error {
|
|||||||
args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name())
|
args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name())
|
||||||
args = append(args, importArgs...)
|
args = append(args, importArgs...)
|
||||||
if err := runMatugen(args); err != nil {
|
if err := runMatugen(args); err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newColors, _ := os.ReadFile(opts.ColorsOutput())
|
||||||
|
if bytes.Equal(oldColors, newColors) && len(oldColors) > 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ColorsOnly {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
if isDMSGTKActive(opts.ConfigDir) {
|
if isDMSGTKActive(opts.ConfigDir) {
|
||||||
switch opts.Mode {
|
switch opts.Mode {
|
||||||
case ColorModeLight:
|
case ColorModeLight:
|
||||||
@@ -270,7 +296,7 @@ func buildOnce(opts *Options) error {
|
|||||||
|
|
||||||
signalTerminals(opts)
|
signalTerminals(opts)
|
||||||
|
|
||||||
return nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
|
func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
|
||||||
@@ -310,6 +336,10 @@ output_path = '%s'
|
|||||||
|
|
||||||
`, opts.ShellDir, opts.ColorsOutput())
|
`, opts.ShellDir, opts.ColorsOutput())
|
||||||
|
|
||||||
|
if opts.ColorsOnly {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
homeDir, _ := os.UserHomeDir()
|
homeDir, _ := os.UserHomeDir()
|
||||||
for _, tmpl := range templateRegistry {
|
for _, tmpl := range templateRegistry {
|
||||||
if opts.ShouldSkipTemplate(tmpl.ID) {
|
if opts.ShouldSkipTemplate(tmpl.ID) {
|
||||||
@@ -333,6 +363,10 @@ output_path = '%s'
|
|||||||
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
|
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
|
||||||
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/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)
|
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:
|
default:
|
||||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||||
}
|
}
|
||||||
@@ -490,6 +524,9 @@ func substituteVars(content, shellDir string) string {
|
|||||||
result = strings.ReplaceAll(result, "'CONFIG_DIR/", "'"+utils.XDGConfigHome()+"/")
|
result = strings.ReplaceAll(result, "'CONFIG_DIR/", "'"+utils.XDGConfigHome()+"/")
|
||||||
result = strings.ReplaceAll(result, "'DATA_DIR/", "'"+utils.XDGDataHome()+"/")
|
result = strings.ReplaceAll(result, "'DATA_DIR/", "'"+utils.XDGDataHome()+"/")
|
||||||
result = strings.ReplaceAll(result, "'CACHE_DIR/", "'"+utils.XDGCacheHome()+"/")
|
result = strings.ReplaceAll(result, "'CACHE_DIR/", "'"+utils.XDGCacheHome()+"/")
|
||||||
|
if emacsDir := utils.EmacsConfigDir(); emacsDir != "" {
|
||||||
|
result = strings.ReplaceAll(result, "'EMACS_DIR/", "'"+emacsDir+"/")
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,67 +547,160 @@ func extractTOMLSection(content, startMarker, endMarker string) string {
|
|||||||
return content[startIdx : startIdx+endIdx]
|
return content[startIdx : startIdx+endIdx]
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkMatugenVersion() {
|
type matugenFlags struct {
|
||||||
matugenVersionOnce.Do(func() {
|
supportsCOE bool
|
||||||
cmd := exec.Command("matugen", "--version")
|
isV4 bool
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runMatugen(args []string) error {
|
func detectMatugenVersion() (matugenFlags, error) {
|
||||||
checkMatugenVersion()
|
matugenVersionMu.Lock()
|
||||||
|
defer matugenVersionMu.Unlock()
|
||||||
|
|
||||||
if matugenSupportsCOE {
|
if matugenVersionOK {
|
||||||
args = append([]string{"--continue-on-error"}, args...)
|
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.Debugf("Matugen %s detected: continue-on-error support enabled", versionStr)
|
||||||
|
}
|
||||||
|
if matugenIsV4 {
|
||||||
|
log.Debugf("Matugen %s detected: using v4 compatibility 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 := exec.Command("matugen", args...)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
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) {
|
func runMatugenDryRun(opts *Options) (string, error) {
|
||||||
var args []string
|
flags, err := detectMatugenVersion()
|
||||||
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()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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
|
return strings.ReplaceAll(string(output), "\n", ""), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,6 +936,8 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
|
|||||||
detected = true
|
detected = true
|
||||||
case tmpl.Kind == TemplateKindVSCode:
|
case tmpl.Kind == TemplateKindVSCode:
|
||||||
detected = checkVSCodeExtension(homeDir)
|
detected = checkVSCodeExtension(homeDir)
|
||||||
|
case tmpl.Kind == TemplateKindEmacs:
|
||||||
|
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != ""
|
||||||
default:
|
default:
|
||||||
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks)
|
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package matugen
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mocks_utils "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/utils"
|
mocks_utils "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/utils"
|
||||||
@@ -392,3 +393,51 @@ func TestSubstituteVars(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildMergedConfigColorsOnly(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
shellDir := filepath.Join(tempDir, "shell")
|
||||||
|
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||||
|
if err := os.MkdirAll(configsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create configs dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseConfig := "[config]\ncustom_keywords = []\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(configsDir, "base.toml"), []byte(baseConfig), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write base config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgFile, err := os.CreateTemp(tempDir, "merged-*.toml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp config: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(cfgFile.Name())
|
||||||
|
defer cfgFile.Close()
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
ShellDir: shellDir,
|
||||||
|
ConfigDir: filepath.Join(tempDir, "config"),
|
||||||
|
StateDir: filepath.Join(tempDir, "state"),
|
||||||
|
ColorsOnly: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil {
|
||||||
|
t.Fatalf("buildMergedConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfgFile.Close(); err != nil {
|
||||||
|
t.Fatalf("failed to close merged config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := os.ReadFile(cfgFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read merged config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(output)
|
||||||
|
assert.Contains(t, content, "[templates.dank]")
|
||||||
|
assert.Contains(t, content, "output_path = '"+filepath.Join(opts.StateDir, "dms-colors.json")+"'")
|
||||||
|
assert.NotContains(t, content, "[templates.gtk]")
|
||||||
|
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package matugen
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
@@ -93,10 +94,13 @@ func (q *Queue) runWorker() {
|
|||||||
err := Run(job.Options)
|
err := Run(job.Options)
|
||||||
|
|
||||||
var result Result
|
var result Result
|
||||||
if err != nil {
|
switch {
|
||||||
result = Result{Success: false, Error: err}
|
case err == nil:
|
||||||
} else {
|
|
||||||
result = Result{Success: true}
|
result = Result{Success: true}
|
||||||
|
case errors.Is(err, ErrNoChanges):
|
||||||
|
result = Result{Success: true}
|
||||||
|
default:
|
||||||
|
result = Result{Success: false, Error: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
q.finishJob(result)
|
q.finishJob(result)
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const (
|
|||||||
notifyDest = "org.freedesktop.Notifications"
|
notifyDest = "org.freedesktop.Notifications"
|
||||||
notifyPath = "/org/freedesktop/Notifications"
|
notifyPath = "/org/freedesktop/Notifications"
|
||||||
notifyInterface = "org.freedesktop.Notifications"
|
notifyInterface = "org.freedesktop.Notifications"
|
||||||
|
|
||||||
|
maxSummaryLen = 29
|
||||||
|
maxBodyLen = 80
|
||||||
)
|
)
|
||||||
|
|
||||||
type Notification struct {
|
type Notification struct {
|
||||||
@@ -39,6 +42,13 @@ func Send(n Notification) error {
|
|||||||
n.Timeout = 5000
|
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
|
var actions []string
|
||||||
if n.FilePath != "" {
|
if n.FilePath != "" {
|
||||||
actions = []string{
|
actions = []string{
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ func (i *ExtWorkspaceManagerV1) Dispatch(opcode uint32, fd int, data []byte) {
|
|||||||
l := 0
|
l := 0
|
||||||
objectID := client.Uint32(data[l : l+4])
|
objectID := client.Uint32(data[l : l+4])
|
||||||
proxy := i.Context().GetProxy(objectID)
|
proxy := i.Context().GetProxy(objectID)
|
||||||
if proxy != nil {
|
if proxy != nil && !proxy.IsZombie() {
|
||||||
e.WorkspaceGroup = proxy.(*ExtWorkspaceGroupHandleV1)
|
e.WorkspaceGroup = proxy.(*ExtWorkspaceGroupHandleV1)
|
||||||
} else {
|
} else {
|
||||||
groupHandle := &ExtWorkspaceGroupHandleV1{}
|
groupHandle := &ExtWorkspaceGroupHandleV1{}
|
||||||
@@ -278,7 +278,7 @@ func (i *ExtWorkspaceManagerV1) Dispatch(opcode uint32, fd int, data []byte) {
|
|||||||
l := 0
|
l := 0
|
||||||
objectID := client.Uint32(data[l : l+4])
|
objectID := client.Uint32(data[l : l+4])
|
||||||
proxy := i.Context().GetProxy(objectID)
|
proxy := i.Context().GetProxy(objectID)
|
||||||
if proxy != nil {
|
if proxy != nil && !proxy.IsZombie() {
|
||||||
e.Workspace = proxy.(*ExtWorkspaceHandleV1)
|
e.Workspace = proxy.(*ExtWorkspaceHandleV1)
|
||||||
} else {
|
} else {
|
||||||
wsHandle := &ExtWorkspaceHandleV1{}
|
wsHandle := &ExtWorkspaceHandleV1{}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const (
|
|||||||
CompositorNiri
|
CompositorNiri
|
||||||
CompositorDWL
|
CompositorDWL
|
||||||
CompositorScroll
|
CompositorScroll
|
||||||
|
CompositorMiracle
|
||||||
)
|
)
|
||||||
|
|
||||||
var detectedCompositor Compositor = -1
|
var detectedCompositor Compositor = -1
|
||||||
@@ -34,6 +35,7 @@ func DetectCompositor() Compositor {
|
|||||||
niriSocket := os.Getenv("NIRI_SOCKET")
|
niriSocket := os.Getenv("NIRI_SOCKET")
|
||||||
swaySocket := os.Getenv("SWAYSOCK")
|
swaySocket := os.Getenv("SWAYSOCK")
|
||||||
scrollSocket := os.Getenv("SCROLLSOCK")
|
scrollSocket := os.Getenv("SCROLLSOCK")
|
||||||
|
miracleSocket := os.Getenv("MIRACLESOCK")
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case niriSocket != "":
|
case niriSocket != "":
|
||||||
@@ -46,7 +48,11 @@ func DetectCompositor() Compositor {
|
|||||||
detectedCompositor = CompositorScroll
|
detectedCompositor = CompositorScroll
|
||||||
return detectedCompositor
|
return detectedCompositor
|
||||||
}
|
}
|
||||||
|
case miracleSocket != "":
|
||||||
|
if _, err := os.Stat(miracleSocket); err == nil {
|
||||||
|
detectedCompositor = CompositorMiracle
|
||||||
|
return detectedCompositor
|
||||||
|
}
|
||||||
case swaySocket != "":
|
case swaySocket != "":
|
||||||
if _, err := os.Stat(swaySocket); err == nil {
|
if _, err := os.Stat(swaySocket); err == nil {
|
||||||
detectedCompositor = CompositorSway
|
detectedCompositor = CompositorSway
|
||||||
@@ -260,6 +266,25 @@ func getScrollFocusedMonitor() string {
|
|||||||
return ""
|
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 {
|
type niriWorkspace struct {
|
||||||
Output string `json:"output"`
|
Output string `json:"output"`
|
||||||
IsFocused bool `json:"is_focused"`
|
IsFocused bool `json:"is_focused"`
|
||||||
@@ -407,6 +432,8 @@ func GetFocusedMonitor() string {
|
|||||||
return getSwayFocusedMonitor()
|
return getSwayFocusedMonitor()
|
||||||
case CompositorScroll:
|
case CompositorScroll:
|
||||||
return getScrollFocusedMonitor()
|
return getScrollFocusedMonitor()
|
||||||
|
case CompositorMiracle:
|
||||||
|
return getMiracleFocusedMonitor()
|
||||||
case CompositorNiri:
|
case CompositorNiri:
|
||||||
return getNiriFocusedMonitor()
|
return getNiriFocusedMonitor()
|
||||||
case CompositorDWL:
|
case CompositorDWL:
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func NewRegionSelector(s *Screenshoter) *RegionSelector {
|
|||||||
screenshoter: s,
|
screenshoter: s,
|
||||||
outputs: make(map[uint32]*WaylandOutput),
|
outputs: make(map[uint32]*WaylandOutput),
|
||||||
preCapture: make(map[*WaylandOutput]*PreCapture),
|
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) {
|
func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult, error) {
|
||||||
cursor := int32(0)
|
cursor := int32(s.config.Cursor)
|
||||||
if s.config.IncludeCursor {
|
|
||||||
cursor = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
frame, err := s.screencopy.CaptureOutput(cursor, output.wlOutput)
|
frame, err := s.screencopy.CaptureOutput(cursor, output.wlOutput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -624,10 +621,7 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor := int32(0)
|
cursor := int32(s.config.Cursor)
|
||||||
if s.config.IncludeCursor {
|
|
||||||
cursor = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
frame, err := s.screencopy.CaptureOutputRegion(cursor, output.wlOutput, localX, localY, w, h)
|
frame, err := s.screencopy.CaptureOutputRegion(cursor, output.wlOutput, localX, localY, w, h)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ const (
|
|||||||
FormatPPM
|
FormatPPM
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type CursorMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CursorOff CursorMode = iota
|
||||||
|
CursorOn
|
||||||
|
)
|
||||||
|
|
||||||
type Region struct {
|
type Region struct {
|
||||||
X int32 `json:"x"`
|
X int32 `json:"x"`
|
||||||
Y int32 `json:"y"`
|
Y int32 `json:"y"`
|
||||||
@@ -42,29 +49,29 @@ type Output struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Mode Mode
|
Mode Mode
|
||||||
OutputName string
|
OutputName string
|
||||||
IncludeCursor bool
|
Cursor CursorMode
|
||||||
Format Format
|
Format Format
|
||||||
Quality int
|
Quality int
|
||||||
OutputDir string
|
OutputDir string
|
||||||
Filename string
|
Filename string
|
||||||
Clipboard bool
|
Clipboard bool
|
||||||
SaveFile bool
|
SaveFile bool
|
||||||
Notify bool
|
Notify bool
|
||||||
Stdout bool
|
Stdout bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Mode: ModeRegion,
|
Mode: ModeRegion,
|
||||||
IncludeCursor: false,
|
Cursor: CursorOff,
|
||||||
Format: FormatPNG,
|
Format: FormatPNG,
|
||||||
Quality: 90,
|
Quality: 90,
|
||||||
OutputDir: "",
|
OutputDir: "",
|
||||||
Filename: "",
|
Filename: "",
|
||||||
Clipboard: true,
|
Clipboard: true,
|
||||||
SaveFile: true,
|
SaveFile: true,
|
||||||
Notify: true,
|
Notify: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,6 +311,10 @@ func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed ma
|
|||||||
select {
|
select {
|
||||||
case m.eventQueue <- func() {
|
case m.eventQueue <- func() {
|
||||||
time.Sleep(300 * time.Millisecond)
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
log.Infof("[Bluetooth] Auto-trusting newly paired device: %s", devicePath)
|
||||||
|
if err := m.TrustDevice(devicePath, true); err != nil {
|
||||||
|
log.Warnf("[Bluetooth] Auto-trust failed: %v", err)
|
||||||
|
}
|
||||||
log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath)
|
log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath)
|
||||||
if err := m.ConnectDevice(devicePath); err != nil {
|
if err := m.ConnectDevice(devicePath); err != nil {
|
||||||
log.Warnf("[Bluetooth] Auto-connect failed: %v", err)
|
log.Warnf("[Bluetooth] Auto-connect failed: %v", err)
|
||||||
|
|||||||
@@ -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()
|
m.updateState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ func shouldSuppressDevice(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *SysfsBackend) Rescan() error {
|
||||||
|
return b.scanDevices()
|
||||||
|
}
|
||||||
|
|
||||||
func (b *SysfsBackend) GetDevices() ([]Device, error) {
|
func (b *SysfsBackend) GetDevices() ([]Device, error) {
|
||||||
devices := make([]Device, 0)
|
devices := make([]Device, 0)
|
||||||
|
|
||||||
|
|||||||
@@ -232,8 +232,15 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prevOffer := m.currentOffer
|
||||||
m.currentOffer = offer
|
m.currentOffer = offer
|
||||||
|
|
||||||
|
if prevOffer != nil && prevOffer != offer {
|
||||||
|
m.offerMutex.Lock()
|
||||||
|
delete(m.offerMimeTypes, prevOffer)
|
||||||
|
m.offerMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
m.offerMutex.RLock()
|
m.offerMutex.RLock()
|
||||||
mimes := m.offerMimeTypes[offer]
|
mimes := m.offerMimeTypes[offer]
|
||||||
m.offerMutex.RUnlock()
|
m.offerMutex.RUnlock()
|
||||||
@@ -587,20 +594,26 @@ func (m *Manager) uriListPreview(data []byte) (string, bool) {
|
|||||||
uris = strings.Split(text, "\n")
|
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://") {
|
if len(uris) == 1 && strings.HasPrefix(uris[0], "file://") {
|
||||||
filePath := strings.TrimPrefix(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 imgData, err := os.ReadFile(filePath); err == nil {
|
||||||
if config, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData)); 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 %s %dx%d ]]", filepath.Base(filePath), imgFmt, config.Width, config.Height), true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), false
|
|
||||||
}
|
}
|
||||||
}
|
return fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), false
|
||||||
|
|
||||||
if len(uris) > 1 {
|
|
||||||
return fmt.Sprintf("[[ %d files ]]", len(uris)), false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.textPreview(data), false
|
return m.textPreview(data), false
|
||||||
@@ -623,6 +636,11 @@ func (m *Manager) tryReadImageFromURI(data []byte) ([]byte, string, bool) {
|
|||||||
return nil, "", false
|
return nil, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg := m.getConfig()
|
||||||
|
if info.Size() > cfg.MaxEntrySize {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
imgData, err := os.ReadFile(filePath)
|
imgData, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", false
|
return nil, "", false
|
||||||
|
|||||||
@@ -16,4 +16,8 @@ const (
|
|||||||
dbusScreensaverPath = "/ScreenSaver"
|
dbusScreensaverPath = "/ScreenSaver"
|
||||||
dbusScreensaverPath2 = "/org/freedesktop/ScreenSaver"
|
dbusScreensaverPath2 = "/org/freedesktop/ScreenSaver"
|
||||||
dbusScreensaverInterface = "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
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
m.screensaverSubscribers.Range(func(key string, ch chan ScreensaverState) bool {
|
||||||
|
close(ch)
|
||||||
|
m.screensaverSubscribers.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
if m.systemConn != nil {
|
if m.systemConn != nil {
|
||||||
m.systemConn.Close()
|
m.systemConn.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package freedesktop
|
package freedesktop
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -15,6 +16,51 @@ type screensaverHandler struct {
|
|||||||
manager *Manager
|
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 {
|
func (m *Manager) initializeScreensaver() error {
|
||||||
if m.sessionConn == nil {
|
if m.sessionConn == nil {
|
||||||
m.stateMutex.Lock()
|
m.stateMutex.Lock()
|
||||||
@@ -23,66 +69,71 @@ func (m *Manager) initializeScreensaver() error {
|
|||||||
return nil
|
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}
|
handler := &screensaverHandler{manager: m}
|
||||||
|
|
||||||
if err := m.sessionConn.Export(handler, dbusScreensaverPath, dbusScreensaverInterface); err != nil {
|
m.screensaverFreedesktopClaimed = m.claimScreensaverName(handler,
|
||||||
log.Warnf("Failed to export screensaver on %s: %v", dbusScreensaverPath, err)
|
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
|
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()
|
go m.watchPeerDisconnects()
|
||||||
|
|
||||||
m.stateMutex.Lock()
|
m.stateMutex.Lock()
|
||||||
m.state.Screensaver.Available = true
|
m.state.Screensaver.Available = true
|
||||||
|
m.state.Screensaver.Active = false
|
||||||
m.state.Screensaver.Inhibited = false
|
m.state.Screensaver.Inhibited = false
|
||||||
m.state.Screensaver.Inhibitors = []ScreensaverInhibitor{}
|
m.state.Screensaver.Inhibitors = []ScreensaverInhibitor{}
|
||||||
m.stateMutex.Unlock()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,3 +299,51 @@ func (m *Manager) NotifyScreensaverSubscribers() {
|
|||||||
return true
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
type ScreensaverState struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
|
Active bool `json:"active"`
|
||||||
Inhibited bool `json:"inhibited"`
|
Inhibited bool `json:"inhibited"`
|
||||||
Inhibitors []ScreensaverInhibitor `json:"inhibitors"`
|
Inhibitors []ScreensaverInhibitor `json:"inhibitors"`
|
||||||
}
|
}
|
||||||
@@ -50,14 +51,16 @@ type FreedeskState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
state *FreedeskState
|
state *FreedeskState
|
||||||
stateMutex sync.RWMutex
|
stateMutex sync.RWMutex
|
||||||
systemConn *dbus.Conn
|
systemConn *dbus.Conn
|
||||||
sessionConn *dbus.Conn
|
sessionConn *dbus.Conn
|
||||||
accountsObj dbus.BusObject
|
accountsObj dbus.BusObject
|
||||||
settingsObj dbus.BusObject
|
settingsObj dbus.BusObject
|
||||||
currentUID uint64
|
currentUID uint64
|
||||||
subscribers syncmap.Map[string, chan FreedeskState]
|
subscribers syncmap.Map[string, chan FreedeskState]
|
||||||
screensaverSubscribers syncmap.Map[string, chan ScreensaverState]
|
screensaverSubscribers syncmap.Map[string, chan ScreensaverState]
|
||||||
screensaverCookieCounter uint32
|
screensaverCookieCounter uint32
|
||||||
|
screensaverFreedesktopClaimed bool
|
||||||
|
screensaverGnomeClaimed bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ const (
|
|||||||
dbusPath = "/org/freedesktop/login1"
|
dbusPath = "/org/freedesktop/login1"
|
||||||
dbusManagerInterface = "org.freedesktop.login1.Manager"
|
dbusManagerInterface = "org.freedesktop.login1.Manager"
|
||||||
dbusSessionInterface = "org.freedesktop.login1.Session"
|
dbusSessionInterface = "org.freedesktop.login1.Session"
|
||||||
|
dbusUserInterface = "org.freedesktop.login1.User"
|
||||||
dbusPropsInterface = "org.freedesktop.DBus.Properties"
|
dbusPropsInterface = "org.freedesktop.DBus.Properties"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,15 +17,8 @@ func NewManager() (*Manager, error) {
|
|||||||
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
|
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID := os.Getenv("XDG_SESSION_ID")
|
|
||||||
if sessionID == "" {
|
|
||||||
sessionID = "self"
|
|
||||||
}
|
|
||||||
|
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
state: &SessionState{
|
state: &SessionState{},
|
||||||
SessionID: sessionID,
|
|
||||||
},
|
|
||||||
stateMutex: sync.RWMutex{},
|
stateMutex: sync.RWMutex{},
|
||||||
|
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
@@ -60,12 +53,13 @@ func (m *Manager) initialize() error {
|
|||||||
|
|
||||||
m.initializeFallbackDelay()
|
m.initializeFallbackDelay()
|
||||||
|
|
||||||
sessionPath, err := m.getSession(m.state.SessionID)
|
sessionID, sessionPath, err := m.discoverSession()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get session path: %w", err)
|
return fmt.Errorf("failed to get session path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.stateMutex.Lock()
|
m.stateMutex.Lock()
|
||||||
|
m.state.SessionID = sessionID
|
||||||
m.state.SessionPath = string(sessionPath)
|
m.state.SessionPath = string(sessionPath)
|
||||||
m.sessionPath = sessionPath
|
m.sessionPath = sessionPath
|
||||||
m.stateMutex.Unlock()
|
m.stateMutex.Unlock()
|
||||||
@@ -79,6 +73,41 @@ func (m *Manager) initialize() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) discoverSession() (string, dbus.ObjectPath, error) {
|
||||||
|
// 1. Explicit XDG_SESSION_ID
|
||||||
|
if id := os.Getenv("XDG_SESSION_ID"); id != "" {
|
||||||
|
if path, err := m.getSession(id); err == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "loginctl: using XDG_SESSION_ID=%s\n", id)
|
||||||
|
return id, path, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PID-based lookup (works when caller is inside a session cgroup)
|
||||||
|
if id, path, err := m.getSessionByPID(uint32(os.Getpid())); err == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "loginctl: found session %s via PID\n", id)
|
||||||
|
return id, path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. User's primary display session (handles UWSM and similar)
|
||||||
|
if id, path, err := m.getUserDisplaySession(); err == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "loginctl: found session %s via User.Display\n", id)
|
||||||
|
return id, path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Score all sessions for current UID
|
||||||
|
if id, path, err := m.findBestSession(); err == nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "loginctl: found session %s via ListSessions scoring\n", id)
|
||||||
|
return id, path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Last resort: "self"
|
||||||
|
path, err := m.getSession("self")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("%w", err)
|
||||||
|
}
|
||||||
|
return "self", path, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) getSession(id string) (dbus.ObjectPath, error) {
|
func (m *Manager) getSession(id string) (dbus.ObjectPath, error) {
|
||||||
var out dbus.ObjectPath
|
var out dbus.ObjectPath
|
||||||
err := m.managerObj.Call(dbusManagerInterface+".GetSession", 0, id).Store(&out)
|
err := m.managerObj.Call(dbusManagerInterface+".GetSession", 0, id).Store(&out)
|
||||||
@@ -88,6 +117,166 @@ func (m *Manager) getSession(id string) (dbus.ObjectPath, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) getSessionByPID(pid uint32) (string, dbus.ObjectPath, error) {
|
||||||
|
var path dbus.ObjectPath
|
||||||
|
if err := m.managerObj.Call(dbusManagerInterface+".GetSessionByPID", 0, pid).Store(&path); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionObj := m.conn.Object(dbusDest, path)
|
||||||
|
var id dbus.Variant
|
||||||
|
if err := sessionObj.Call(dbusPropsInterface+".Get", 0, dbusSessionInterface, "Id").Store(&id); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return id.Value().(string), path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) getUserDisplaySession() (string, dbus.ObjectPath, error) {
|
||||||
|
uid := uint32(os.Getuid())
|
||||||
|
|
||||||
|
var userPath dbus.ObjectPath
|
||||||
|
if err := m.managerObj.Call(dbusManagerInterface+".GetUser", 0, uid).Store(&userPath); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj := m.conn.Object(dbusDest, userPath)
|
||||||
|
var display dbus.Variant
|
||||||
|
if err := userObj.Call(dbusPropsInterface+".Get", 0, dbusUserInterface, "Display").Store(&display); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
pair, ok := display.Value().([]any)
|
||||||
|
if !ok || len(pair) < 2 {
|
||||||
|
return "", "", fmt.Errorf("unexpected Display format")
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, _ := pair[0].(string)
|
||||||
|
sessionPath, _ := pair[1].(dbus.ObjectPath)
|
||||||
|
if sessionID == "" || sessionPath == "" {
|
||||||
|
return "", "", fmt.Errorf("empty Display session")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionID, sessionPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionCandidate struct {
|
||||||
|
id string
|
||||||
|
path dbus.ObjectPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) findBestSession() (string, dbus.ObjectPath, error) {
|
||||||
|
// ListSessions returns a(susso): [][]any where each entry is [id, uid, name, seat, path]
|
||||||
|
var raw [][]any
|
||||||
|
if err := m.managerObj.Call(dbusManagerInterface+".ListSessions", 0).Store(&raw); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := uint32(os.Getuid())
|
||||||
|
var candidates []sessionCandidate
|
||||||
|
for _, entry := range raw {
|
||||||
|
if len(entry) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entryUID, _ := entry[1].(uint32)
|
||||||
|
if entryUID != uid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, _ := entry[0].(string)
|
||||||
|
path, _ := entry[4].(dbus.ObjectPath)
|
||||||
|
if id != "" && path != "" {
|
||||||
|
candidates = append(candidates, sessionCandidate{id: id, path: path})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return "", "", fmt.Errorf("no sessions for uid %d", uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
bestScore := -1
|
||||||
|
var best sessionCandidate
|
||||||
|
for _, c := range candidates {
|
||||||
|
score := m.scoreSession(c.path)
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
best = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bestScore < 0 {
|
||||||
|
return "", "", fmt.Errorf("no viable session found")
|
||||||
|
}
|
||||||
|
return best.id, best.path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) scoreSession(path dbus.ObjectPath) int {
|
||||||
|
obj := m.conn.Object(dbusDest, path)
|
||||||
|
var props map[string]dbus.Variant
|
||||||
|
if err := obj.Call(dbusPropsInterface+".GetAll", 0, dbusSessionInterface).Store(&props); err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
getStr := func(key string) string {
|
||||||
|
if v, ok := props[key]; ok {
|
||||||
|
if s, ok := v.Value().(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
getBool := func(key string) bool {
|
||||||
|
if v, ok := props[key]; ok {
|
||||||
|
if b, ok := v.Value().(bool); ok {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
getUint32 := func(key string) uint32 {
|
||||||
|
if v, ok := props[key]; ok {
|
||||||
|
if u, ok := v.Value().(uint32); ok {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
class := getStr("Class")
|
||||||
|
if class != "user" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if getBool("Remote") {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
score := 0
|
||||||
|
|
||||||
|
if getBool("Active") {
|
||||||
|
score += 100
|
||||||
|
}
|
||||||
|
|
||||||
|
switch getStr("Type") {
|
||||||
|
case "wayland", "x11":
|
||||||
|
score += 80
|
||||||
|
case "tty":
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := props["Seat"]; ok {
|
||||||
|
if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 {
|
||||||
|
if seat, ok := seatArr[0].(string); ok && seat != "" {
|
||||||
|
score += 40
|
||||||
|
if seat == "seat0" {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if getUint32("VTNr") > 0 {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) refreshSessionBinding() error {
|
func (m *Manager) refreshSessionBinding() error {
|
||||||
if m.managerObj == nil || m.conn == nil {
|
if m.managerObj == nil || m.conn == nil {
|
||||||
return fmt.Errorf("manager not fully initialized")
|
return fmt.Errorf("manager not fully initialized")
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ type SecretAgent struct {
|
|||||||
backend *NetworkManagerBackend
|
backend *NetworkManagerBackend
|
||||||
}
|
}
|
||||||
|
|
||||||
type nmVariantMap map[string]dbus.Variant
|
type (
|
||||||
type nmSettingMap map[string]nmVariantMap
|
nmVariantMap map[string]dbus.Variant
|
||||||
|
nmSettingMap map[string]nmVariantMap
|
||||||
|
)
|
||||||
|
|
||||||
const introspectXML = `
|
const introspectXML = `
|
||||||
<node>
|
<node>
|
||||||
@@ -122,7 +124,7 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
|
|
||||||
connType, displayName, vpnSvc := readConnTypeAndName(conn)
|
connType, displayName, vpnSvc := readConnTypeAndName(conn)
|
||||||
ssid := readSSID(conn)
|
ssid := readSSID(conn)
|
||||||
fields := fieldsNeeded(settingName, hints)
|
fields := fieldsNeeded(settingName, hints, conn)
|
||||||
vpnPasswordFlags := readVPNPasswordFlags(conn, settingName)
|
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)
|
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{}
|
out[settingName] = nmVariantMap{}
|
||||||
return out, nil
|
return out, nil
|
||||||
} else if passwordFlags&NM_SETTING_SECRET_FLAG_AGENT_OWNED != 0 {
|
} 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)
|
switch settingName {
|
||||||
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
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 {
|
} else {
|
||||||
log.Infof("[SecretAgent] No secrets needed, using system stored secrets (flags=%d)", passwordFlags)
|
log.Infof("[SecretAgent] No secrets needed, using system stored secrets (flags=%d)", passwordFlags)
|
||||||
out := nmSettingMap{}
|
out := nmSettingMap{}
|
||||||
@@ -300,6 +310,63 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
a.backend.cachedVPNCredsMu.Unlock()
|
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)
|
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")
|
log.Infof("[SecretAgent] Cached PKCS11 PIN for potential re-request")
|
||||||
}
|
}
|
||||||
case "802-1x":
|
case "802-1x":
|
||||||
out[settingName] = sec
|
secretsOnly := nmVariantMap{}
|
||||||
log.Infof("[SecretAgent] Returning 802-1x enterprise secrets with %d fields", len(sec))
|
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:
|
default:
|
||||||
out[settingName] = sec
|
out[settingName] = sec
|
||||||
}
|
}
|
||||||
@@ -434,63 +512,6 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
}
|
}
|
||||||
a.backend.pendingVPNSaveMu.Unlock()
|
a.backend.pendingVPNSaveMu.Unlock()
|
||||||
log.Infof("[SecretAgent] Queued credentials persist for after connection succeeds")
|
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
|
return out, nil
|
||||||
@@ -523,6 +544,35 @@ func (a *SecretAgent) Introspect() (string, *dbus.Error) {
|
|||||||
return introspectXML, nil
|
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 {
|
func readSSID(conn map[string]nmVariantMap) string {
|
||||||
if w, ok := conn["802-11-wireless"]; ok {
|
if w, ok := conn["802-11-wireless"]; ok {
|
||||||
if v, ok := w["ssid"]; ok {
|
if v, ok := w["ssid"]; ok {
|
||||||
@@ -564,12 +614,15 @@ func readConnTypeAndName(conn map[string]nmVariantMap) (string, string, string)
|
|||||||
return connType, name, svc
|
return connType, name, svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func fieldsNeeded(setting string, hints []string) []string {
|
func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap) []string {
|
||||||
switch setting {
|
switch setting {
|
||||||
case "802-11-wireless-security":
|
case "802-11-wireless-security":
|
||||||
return []string{"psk"}
|
return []string{"psk"}
|
||||||
case "802-1x":
|
case "802-1x":
|
||||||
return []string{"identity", "password"}
|
if len(hints) > 0 {
|
||||||
|
return hints
|
||||||
|
}
|
||||||
|
return infer8021xFields(conn)
|
||||||
case "vpn":
|
case "vpn":
|
||||||
return hints
|
return hints
|
||||||
default:
|
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 {
|
func buildFieldsInfo(setting string, fields []string, vpnService string) []FieldInfo {
|
||||||
result := make([]FieldInfo, 0, len(fields))
|
result := make([]FieldInfo, 0, len(fields))
|
||||||
for _, f := range fields {
|
for _, f := range fields {
|
||||||
@@ -630,12 +718,25 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(vpnService, "openconnect"):
|
case strings.Contains(vpnService, "openconnect"):
|
||||||
|
protocol := dataMap["protocol"]
|
||||||
authType := dataMap["authtype"]
|
authType := dataMap["authtype"]
|
||||||
userCert := dataMap["usercert"]
|
username := dataMap["username"]
|
||||||
if authType == "cert" && strings.HasPrefix(userCert, "pkcs11:") {
|
|
||||||
|
if authType == "cert" && strings.HasPrefix(dataMap["usercert"], "pkcs11:") {
|
||||||
return []string{"key_pass"}
|
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"}
|
fields = []string{"username", "password"}
|
||||||
}
|
}
|
||||||
case strings.Contains(vpnService, "openvpn"):
|
case strings.Contains(vpnService, "openvpn"):
|
||||||
@@ -654,8 +755,31 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
|
|||||||
return fields
|
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) {
|
func vpnFieldMeta(field, vpnService string) (label string, isSecret bool) {
|
||||||
switch field {
|
switch field {
|
||||||
|
case "gp-saml":
|
||||||
|
return "GlobalProtect SAML/SSO", false
|
||||||
case "key_pass":
|
case "key_pass":
|
||||||
return "PIN", true
|
return "PIN", true
|
||||||
case "password":
|
case "password":
|
||||||
@@ -756,3 +880,18 @@ func reasonFromFlags(flags uint32) string {
|
|||||||
}
|
}
|
||||||
return "required"
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
lastFailedTime int64
|
||||||
failedMutex sync.RWMutex
|
failedMutex sync.RWMutex
|
||||||
|
|
||||||
pendingVPNSave *pendingVPNCredentials
|
pendingVPNSave *pendingVPNCredentials
|
||||||
pendingVPNSaveMu sync.Mutex
|
pendingVPNSaveMu sync.Mutex
|
||||||
cachedVPNCreds *cachedVPNCredentials
|
cachedVPNCreds *cachedVPNCredentials
|
||||||
cachedVPNCredsMu sync.Mutex
|
cachedVPNCredsMu sync.Mutex
|
||||||
cachedPKCS11PIN *cachedPKCS11PIN
|
cachedPKCS11PIN *cachedPKCS11PIN
|
||||||
cachedPKCS11Mu sync.Mutex
|
cachedPKCS11Mu sync.Mutex
|
||||||
|
cachedGPSamlCookie *cachedGPSamlCookie
|
||||||
|
cachedGPSamlMu sync.Mutex
|
||||||
|
|
||||||
onStateChange func()
|
onStateChange func()
|
||||||
}
|
}
|
||||||
@@ -97,6 +99,14 @@ type cachedPKCS11PIN struct {
|
|||||||
PIN string
|
PIN string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cachedGPSamlCookie struct {
|
||||||
|
ConnectionUUID string
|
||||||
|
Cookie string
|
||||||
|
Host string
|
||||||
|
User string
|
||||||
|
Fingerprint string
|
||||||
|
}
|
||||||
|
|
||||||
func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) {
|
func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) {
|
||||||
var nm gonetworkmanager.NetworkManager
|
var nm gonetworkmanager.NetworkManager
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -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()
|
b.stateMutex.Lock()
|
||||||
defer b.stateMutex.Unlock()
|
|
||||||
|
|
||||||
wasConnecting = b.state.IsConnecting
|
wasConnecting = b.state.IsConnecting
|
||||||
connectingSSID = b.state.ConnectingSSID
|
connectingSSID = b.state.ConnectingSSID
|
||||||
|
|
||||||
if wasConnecting && connectingSSID != "" {
|
if wasConnecting && connectingSSID != "" {
|
||||||
if connected && ssid == connectingSSID {
|
switch {
|
||||||
|
case connected && ssid == connectingSSID:
|
||||||
log.Infof("[updateWiFiState] Connection successful: %s", ssid)
|
log.Infof("[updateWiFiState] Connection successful: %s", ssid)
|
||||||
b.state.IsConnecting = false
|
b.state.IsConnecting = false
|
||||||
b.state.ConnectingSSID = ""
|
b.state.ConnectingSSID = ""
|
||||||
b.state.LastError = ""
|
b.state.LastError = ""
|
||||||
} else if failed || (disconnected && !connected) {
|
case failed || (disconnected && !connected):
|
||||||
log.Warnf("[updateWiFiState] Connection failed: SSID=%s, state=%d", connectingSSID, state)
|
log.Warnf("[updateWiFiState] Connection failed: SSID=%s, state=%d", connectingSSID, state)
|
||||||
b.state.IsConnecting = false
|
b.state.IsConnecting = false
|
||||||
b.state.ConnectingSSID = ""
|
b.state.ConnectingSSID = ""
|
||||||
b.state.LastError = reasonCode
|
b.state.LastError = reasonCode
|
||||||
|
|
||||||
// If user cancelled, delete the connection profile that was just created
|
|
||||||
if reasonCode == errdefs.ErrUserCanceled {
|
if reasonCode == errdefs.ErrUserCanceled {
|
||||||
log.Infof("[updateWiFiState] User cancelled authentication, removing connection profile for %s", connectingSSID)
|
forgetSSID = connectingSSID
|
||||||
b.stateMutex.Unlock()
|
|
||||||
if err := b.ForgetWiFiNetwork(connectingSSID); err != nil {
|
|
||||||
log.Warnf("[updateWiFiState] Failed to remove cancelled connection: %v", err)
|
|
||||||
}
|
|
||||||
b.stateMutex.Lock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
b.failedMutex.Lock()
|
b.failedMutex.Lock()
|
||||||
@@ -254,6 +250,15 @@ func (b *NetworkManagerBackend) updateWiFiState() error {
|
|||||||
b.state.WiFiBSSID = bssid
|
b.state.WiFiBSSID = bssid
|
||||||
b.state.WiFiSignal = signal
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,51 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
|||||||
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
|
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
|
||||||
return err
|
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()
|
b.stateMutex.Lock()
|
||||||
@@ -339,6 +384,16 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
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"):
|
case strings.Contains(serviceType, "openvpn"):
|
||||||
connType := data["connection-type"]
|
connType := data["connection-type"]
|
||||||
username := data["username"]
|
username := data["username"]
|
||||||
@@ -412,16 +467,6 @@ func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkma
|
|||||||
}
|
}
|
||||||
data["username"] = username
|
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)
|
vpn["data"] = dbus.MakeVariant(data)
|
||||||
settings["vpn"] = vpn
|
settings["vpn"] = vpn
|
||||||
|
|
||||||
@@ -432,7 +477,7 @@ func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkma
|
|||||||
}
|
}
|
||||||
log.Infof("[ConnectVPN] Username saved to connection")
|
log.Infof("[ConnectVPN] Username saved to connection")
|
||||||
|
|
||||||
if password != "" && !reply.Save {
|
if password != "" {
|
||||||
b.cachedVPNCredsMu.Lock()
|
b.cachedVPNCredsMu.Lock()
|
||||||
b.cachedVPNCreds = &cachedVPNCredentials{
|
b.cachedVPNCreds = &cachedVPNCredentials{
|
||||||
ConnectionUUID: targetUUID,
|
ConnectionUUID: targetUUID,
|
||||||
@@ -614,11 +659,7 @@ func (b *NetworkManagerBackend) ClearVPNCredentials(uuidOrName string) error {
|
|||||||
dataMap["password-flags"] = "1"
|
dataMap["password-flags"] = "1"
|
||||||
vpnSettings["data"] = dataMap
|
vpnSettings["data"] = dataMap
|
||||||
}
|
}
|
||||||
|
|
||||||
vpnSettings["password-flags"] = uint32(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
settings["vpn-secrets"] = make(map[string]any)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := conn.Update(settings); err != nil {
|
if err := conn.Update(settings); err != nil {
|
||||||
@@ -684,10 +725,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
|||||||
b.state.LastError = ""
|
b.state.LastError = ""
|
||||||
b.stateMutex.Unlock()
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
// Clear cached PKCS11 PIN on success
|
// Clear cached PKCS11 PIN and SAML cookie on success
|
||||||
b.cachedPKCS11Mu.Lock()
|
b.cachedPKCS11Mu.Lock()
|
||||||
b.cachedPKCS11PIN = nil
|
b.cachedPKCS11PIN = nil
|
||||||
b.cachedPKCS11Mu.Unlock()
|
b.cachedPKCS11Mu.Unlock()
|
||||||
|
b.cachedGPSamlMu.Lock()
|
||||||
|
b.cachedGPSamlCookie = nil
|
||||||
|
b.cachedGPSamlMu.Unlock()
|
||||||
|
|
||||||
b.pendingVPNSaveMu.Lock()
|
b.pendingVPNSaveMu.Lock()
|
||||||
pending := b.pendingVPNSave
|
pending := b.pendingVPNSave
|
||||||
@@ -706,10 +750,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
|||||||
b.state.LastError = "VPN connection failed"
|
b.state.LastError = "VPN connection failed"
|
||||||
b.stateMutex.Unlock()
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
// Clear cached PKCS11 PIN on failure
|
// Clear cached PKCS11 PIN and SAML cookie on failure
|
||||||
b.cachedPKCS11Mu.Lock()
|
b.cachedPKCS11Mu.Lock()
|
||||||
b.cachedPKCS11PIN = nil
|
b.cachedPKCS11PIN = nil
|
||||||
b.cachedPKCS11Mu.Unlock()
|
b.cachedPKCS11Mu.Unlock()
|
||||||
|
b.cachedGPSamlMu.Lock()
|
||||||
|
b.cachedGPSamlCookie = nil
|
||||||
|
b.cachedGPSamlMu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -723,10 +770,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
|||||||
b.state.LastError = "VPN connection failed"
|
b.state.LastError = "VPN connection failed"
|
||||||
b.stateMutex.Unlock()
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
// Clear cached PKCS11 PIN
|
// Clear cached PKCS11 PIN and SAML cookie
|
||||||
b.cachedPKCS11Mu.Lock()
|
b.cachedPKCS11Mu.Lock()
|
||||||
b.cachedPKCS11PIN = nil
|
b.cachedPKCS11PIN = nil
|
||||||
b.cachedPKCS11Mu.Unlock()
|
b.cachedPKCS11Mu.Unlock()
|
||||||
|
b.cachedGPSamlMu.Lock()
|
||||||
|
b.cachedGPSamlCookie = nil
|
||||||
|
b.cachedGPSamlMu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -28,7 +29,13 @@ func TestDetectResult_HasNetworkdField(t *testing.T) {
|
|||||||
|
|
||||||
func TestDetectNetworkStack_Integration(t *testing.T) {
|
func TestDetectNetworkStack_Integration(t *testing.T) {
|
||||||
result, err := DetectNetworkStack()
|
result, err := DetectNetworkStack()
|
||||||
|
|
||||||
|
if err != nil && strings.Contains(err.Error(), "connect system bus") {
|
||||||
|
t.Skipf("system D-Bus unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, result)
|
if assert.NotNil(t, result) {
|
||||||
assert.NotEmpty(t, result.ChosenReason)
|
assert.NotEmpty(t, result.ChosenReason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1516,7 +1516,11 @@ func Start(printDocs bool) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
loginctlReady := make(chan struct{})
|
||||||
|
freedesktopReady := make(chan struct{})
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
defer close(loginctlReady)
|
||||||
if err := InitializeLoginctlManager(); err != nil {
|
if err := InitializeLoginctlManager(); err != nil {
|
||||||
log.Warnf("Loginctl manager unavailable: %v", err)
|
log.Warnf("Loginctl manager unavailable: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -1525,6 +1529,7 @@ func Start(printDocs bool) error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
defer close(freedesktopReady)
|
||||||
if err := InitializeFreedeskManager(); err != nil {
|
if err := InitializeFreedeskManager(); err != nil {
|
||||||
log.Warnf("Freedesktop manager unavailable: %v", err)
|
log.Warnf("Freedesktop manager unavailable: %v", err)
|
||||||
} else if freedesktopManager != nil {
|
} 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 {
|
if err := InitializeWaylandManager(); err != nil {
|
||||||
log.Warnf("Wayland manager unavailable: %v", err)
|
log.Warnf("Wayland manager unavailable: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1569,6 +1599,13 @@ func Start(printDocs bool) error {
|
|||||||
log.Warnf("Theme mode manager unavailable: %v", err)
|
log.Warnf("Theme mode manager unavailable: %v", err)
|
||||||
} else {
|
} else {
|
||||||
notifyCapabilityChange()
|
notifyCapabilityChange()
|
||||||
|
go func() {
|
||||||
|
<-loginctlReady
|
||||||
|
if loginctlManager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
themeModeManager.WatchLoginctl(loginctlManager)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
fatalErrChan := make(chan error, 1)
|
fatalErrChan := make(chan error, 1)
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ func TestCleanupStaleSockets(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("XDG_RUNTIME_DIR", 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)
|
err := os.WriteFile(staleSocket, []byte{}, 0o600)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||||
)
|
)
|
||||||
@@ -187,6 +188,29 @@ func (m *Manager) Close() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
|
||||||
|
ch := lm.Subscribe("thememode")
|
||||||
|
m.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer m.wg.Done()
|
||||||
|
defer lm.Unsubscribe("thememode")
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
case state, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state.PreparingForSleep {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.TriggerUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) schedulerLoop() {
|
func (m *Manager) schedulerLoop() {
|
||||||
defer m.wg.Done()
|
defer m.wg.Done()
|
||||||
|
|
||||||
@@ -327,10 +351,12 @@ func statesEqual(a, b *State) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) computeSchedule(now time.Time, config Config) (bool, time.Time) {
|
func (m *Manager) computeSchedule(now time.Time, config Config) (bool, time.Time) {
|
||||||
if config.Mode == "location" {
|
switch config.Mode {
|
||||||
|
case "location":
|
||||||
return m.computeLocationSchedule(now, config)
|
return m.computeLocationSchedule(now, config)
|
||||||
|
default:
|
||||||
|
return computeTimeSchedule(now, config)
|
||||||
}
|
}
|
||||||
return computeTimeSchedule(now, config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func computeTimeSchedule(now time.Time, config Config) (bool, time.Time) {
|
func computeTimeSchedule(now time.Time, config Config) (bool, time.Time) {
|
||||||
@@ -381,10 +407,10 @@ func (m *Manager) computeLocationSchedule(now time.Time, config Config) (bool, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
times, cond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
|
times, cond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
|
||||||
if cond != wayland.SunNormal {
|
switch cond {
|
||||||
if cond == wayland.SunMidnightSun {
|
case wayland.SunMidnightSun:
|
||||||
return true, startOfNextDay(now)
|
return true, startOfNextDay(now)
|
||||||
}
|
case wayland.SunPolarNight:
|
||||||
return false, startOfNextDay(now)
|
return false, startOfNextDay(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,10 +423,10 @@ func (m *Manager) computeLocationSchedule(now time.Time, config Config) (bool, t
|
|||||||
|
|
||||||
nextDay := startOfNextDay(now)
|
nextDay := startOfNextDay(now)
|
||||||
nextTimes, nextCond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, nextDay, config.ElevationTwilight, config.ElevationDaylight)
|
nextTimes, nextCond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, nextDay, config.ElevationTwilight, config.ElevationDaylight)
|
||||||
if nextCond != wayland.SunNormal {
|
switch nextCond {
|
||||||
if nextCond == wayland.SunMidnightSun {
|
case wayland.SunMidnightSun:
|
||||||
return true, startOfNextDay(nextDay)
|
return true, startOfNextDay(nextDay)
|
||||||
}
|
case wayland.SunPolarNight:
|
||||||
return false, startOfNextDay(nextDay)
|
return false, startOfNextDay(nextDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,13 +439,7 @@ func startOfNextDay(t time.Time) time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateHourMinute(hour, minute int) bool {
|
func validateHourMinute(hour, minute int) bool {
|
||||||
if hour < 0 || hour > 23 {
|
return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59
|
||||||
return false
|
|
||||||
}
|
|
||||||
if minute < 0 || minute > 59 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ValidateSchedule(startHour, startMinute, endHour, endMinute int) error {
|
func (m *Manager) ValidateSchedule(startHour, startMinute, endHour, endMinute int) error {
|
||||||
|
|||||||
@@ -92,21 +92,13 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
|
|||||||
return
|
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)
|
themeMap := make(map[string]themes.Theme)
|
||||||
for _, t := range allThemes {
|
if registry, err := themes.NewRegistry(); err == nil {
|
||||||
themeMap[t.ID] = t
|
if allThemes, err := registry.List(); err == nil {
|
||||||
|
for _, t := range allThemes {
|
||||||
|
themeMap[t.ID] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]ThemeInfo, 0, len(installedIDs))
|
result := make([]ThemeInfo, 0, len(installedIDs))
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package wlcontext
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
@@ -123,6 +123,9 @@ func (sc *SharedContext) eventDispatcher() {
|
|||||||
{Fd: int32(sc.wakeR), Events: unix.POLLIN},
|
{Fd: int32(sc.wakeR), Events: unix.POLLIN},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
consecutiveErrors := 0
|
||||||
|
const maxConsecutiveErrors = 20
|
||||||
|
|
||||||
for {
|
for {
|
||||||
sc.drainCmdQueue()
|
sc.drainCmdQueue()
|
||||||
|
|
||||||
@@ -153,9 +156,19 @@ func (sc *SharedContext) eventDispatcher() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := ctx.Dispatch(); err != nil && !os.IsTimeout(err) {
|
if err := ctx.Dispatch(); err != nil && !os.IsTimeout(err) {
|
||||||
log.Errorf("Wayland connection error: %v", err)
|
consecutiveErrors++
|
||||||
return
|
log.Warnf("Wayland connection error (%d/%d): %v", consecutiveErrors, maxConsecutiveErrors, err)
|
||||||
|
|
||||||
|
if consecutiveErrors >= maxConsecutiveErrors {
|
||||||
|
log.Errorf("Fatal: Wayland connection unrecoverable after %d attempts. Exiting dispatcher.", maxConsecutiveErrors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond * time.Duration(consecutiveErrors))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
consecutiveErrors = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,19 +81,24 @@ func (m Model) viewDependencyReview() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
note := ""
|
||||||
|
if dep.Name == "dms-greeter" {
|
||||||
|
note = m.styles.Subtle.Render(" (selection replaces your current display manager)")
|
||||||
|
}
|
||||||
|
|
||||||
var line string
|
var line string
|
||||||
if i == m.selectedDep {
|
if i == m.selectedDep {
|
||||||
line = fmt.Sprintf("▶ %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status)
|
line = fmt.Sprintf("▶ %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status)
|
||||||
if dep.Version != "" {
|
if dep.Version != "" {
|
||||||
line += fmt.Sprintf(" (%s)", dep.Version)
|
line += fmt.Sprintf(" (%s)", dep.Version)
|
||||||
}
|
}
|
||||||
line = m.styles.SelectedOption.Render(line)
|
line = m.styles.SelectedOption.Render(line) + note
|
||||||
} else {
|
} else {
|
||||||
line = fmt.Sprintf(" %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status)
|
line = fmt.Sprintf(" %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status)
|
||||||
if dep.Version != "" {
|
if dep.Version != "" {
|
||||||
line += fmt.Sprintf(" (%s)", dep.Version)
|
line += fmt.Sprintf(" (%s)", dep.Version)
|
||||||
}
|
}
|
||||||
line = m.styles.Normal.Render(line)
|
line = m.styles.Normal.Render(line) + note
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(line)
|
b.WriteString(line)
|
||||||
@@ -115,6 +121,13 @@ func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.state = StateError
|
m.state = StateError
|
||||||
} else {
|
} else {
|
||||||
m.dependencies = depsMsg.deps
|
m.dependencies = depsMsg.deps
|
||||||
|
// dms-greeter is opt-in skipped by default
|
||||||
|
for _, dep := range depsMsg.deps {
|
||||||
|
if dep.Name == "dms-greeter" {
|
||||||
|
m.disabledItems["dms-greeter"] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
m.state = StateDependencyReview
|
m.state = StateDependencyReview
|
||||||
}
|
}
|
||||||
return m, m.listenForLogs()
|
return m, m.listenForLogs()
|
||||||
@@ -230,6 +243,41 @@ func (m Model) installPackages() tea.Cmd {
|
|||||||
// Convert installer messages to TUI messages
|
// Convert installer messages to TUI messages
|
||||||
go func() {
|
go func() {
|
||||||
for msg := range installerProgressChan {
|
for msg := range installerProgressChan {
|
||||||
|
// Run optional greeter setup
|
||||||
|
if msg.Phase == distros.PhaseComplete && msg.IsComplete && msg.Error == nil {
|
||||||
|
greeterSelected := false
|
||||||
|
for _, dep := range m.dependencies {
|
||||||
|
if dep.Name == "dms-greeter" && !m.disabledItems["dms-greeter"] {
|
||||||
|
greeterSelected = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if greeterSelected {
|
||||||
|
compositorName := "niri"
|
||||||
|
if m.selectedWM == 1 {
|
||||||
|
compositorName = "Hyprland"
|
||||||
|
}
|
||||||
|
m.packageProgressChan <- packageInstallProgressMsg{
|
||||||
|
progress: 0.92,
|
||||||
|
step: "Configuring DMS greeter...",
|
||||||
|
logOutput: "Starting automated greeter setup...",
|
||||||
|
}
|
||||||
|
greeterLogFunc := func(line string) {
|
||||||
|
m.packageProgressChan <- packageInstallProgressMsg{
|
||||||
|
progress: 0.94,
|
||||||
|
step: "Configuring DMS greeter...",
|
||||||
|
logOutput: line,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := greeter.AutoSetupGreeter(compositorName, m.sudoPassword, greeterLogFunc); err != nil {
|
||||||
|
m.packageProgressChan <- packageInstallProgressMsg{
|
||||||
|
progress: 0.96,
|
||||||
|
step: "Greeter setup warning",
|
||||||
|
logOutput: fmt.Sprintf("⚠ Greeter auto-setup warning (non-fatal): %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
tuiMsg := packageInstallProgressMsg{
|
tuiMsg := packageInstallProgressMsg{
|
||||||
progress: msg.Progress,
|
progress: msg.Progress,
|
||||||
step: msg.Step,
|
step: msg.Step,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,3 +20,18 @@ func IsDBusServiceAvailable(busName string) bool {
|
|||||||
}
|
}
|
||||||
return owned
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,22 @@ func XDGConfigHome() string {
|
|||||||
return filepath.Join(home, ".config")
|
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) {
|
func ExpandPath(path string) (string, error) {
|
||||||
expanded := os.ExpandEnv(path)
|
expanded := os.ExpandEnv(path)
|
||||||
expanded = filepath.Clean(expanded)
|
expanded = filepath.Clean(expanded)
|
||||||
|
|||||||
@@ -864,10 +864,12 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
|
|||||||
|
|
||||||
func formatSizeProperty(name, value string) string {
|
func formatSizeProperty(name, value string) string {
|
||||||
parts := strings.SplitN(value, " ", 2)
|
parts := strings.SplitN(value, " ", 2)
|
||||||
if len(parts) != 2 {
|
if len(parts) == 2 {
|
||||||
return fmt.Sprintf(" %s { }", name)
|
return fmt.Sprintf(" %s { %s %s; }", name, parts[0], parts[1])
|
||||||
}
|
}
|
||||||
sizeType := parts[0]
|
// Bare number without type prefix — default to "fixed"
|
||||||
sizeValue := parts[1]
|
if _, err := strconv.Atoi(value); err == nil {
|
||||||
return fmt.Sprintf(" %s { %s %s; }", name, sizeType, sizeValue)
|
return fmt.Sprintf(" %s { fixed %s; }", name, value)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(" %s { }", name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ Depends: ${misc:Depends},
|
|||||||
qml6-module-qtquick-templates,
|
qml6-module-qtquick-templates,
|
||||||
qml6-module-qtquick-window,
|
qml6-module-qtquick-window,
|
||||||
qt6ct
|
qt6ct
|
||||||
|
Suggests: cups-pk-helper
|
||||||
Provides: dms
|
Provides: dms
|
||||||
Conflicts: dms
|
Conflicts: dms
|
||||||
Replaces: dms
|
Replaces: dms
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<services>
|
||||||
|
<!-- Download dms-qml source tarball from GitHub releases (greeter + quickshell content) -->
|
||||||
|
<service name="download_url">
|
||||||
|
<param name="protocol">https</param>
|
||||||
|
<param name="host">github.com</param>
|
||||||
|
<param name="path">/AvengeMedia/DankMaterialShell/releases/download/v1.4.2/dms-qml.tar.gz</param>
|
||||||
|
<param name="filename">dms-qml.tar.gz</param>
|
||||||
|
</service>
|
||||||
|
</services>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
dms-greeter (1.4.2db8) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Initial Debian OBS package
|
||||||
|
* Port from Ubuntu/Fedora packaging
|
||||||
|
|
||||||
|
-- Avenge Media <AvengeMedia.US@gmail.com> Sat, 21 Feb 2026 00:00:00 +0000
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
Source: dms-greeter
|
||||||
|
Section: x11
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: Avenge Media <AvengeMedia.US@gmail.com>
|
||||||
|
Build-Depends: debhelper-compat (= 13)
|
||||||
|
Standards-Version: 4.6.2
|
||||||
|
Homepage: https://github.com/AvengeMedia/DankMaterialShell
|
||||||
|
Vcs-Browser: https://github.com/AvengeMedia/DankMaterialShell
|
||||||
|
Vcs-Git: https://github.com/AvengeMedia/DankMaterialShell.git
|
||||||
|
|
||||||
|
Package: dms-greeter
|
||||||
|
Architecture: any
|
||||||
|
Depends: ${misc:Depends},
|
||||||
|
greetd,
|
||||||
|
quickshell-git | quickshell
|
||||||
|
Suggests: niri | hyprland | sway
|
||||||
|
Description: DankMaterialShell greeter for greetd
|
||||||
|
DankMaterialShell greeter for greetd login manager. A modern, Material Design 3
|
||||||
|
inspired greeter interface built with Quickshell for Wayland compositors.
|
||||||
|
.
|
||||||
|
Supports multiple compositors including Niri, Hyprland, and Sway with automatic
|
||||||
|
compositor detection and configuration. Features session selection, user
|
||||||
|
authentication, and dynamic theming.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: dms-greeter
|
||||||
|
Upstream-Contact: Avenge Media LLC <AvengeMedia.US@gmail.com>
|
||||||
|
Source: https://github.com/AvengeMedia/DankMaterialShell
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: 2026 Avenge Media LLC
|
||||||
|
License: MIT
|
||||||
|
|
||||||
|
License: MIT
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
.
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
.
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
# Create greeter user/group if they don't exist
|
||||||
|
if ! getent group greeter >/dev/null; then
|
||||||
|
addgroup --system greeter
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! getent passwd greeter >/dev/null; then
|
||||||
|
adduser --system --ingroup greeter --home /var/lib/greeter \
|
||||||
|
--shell /bin/bash --gecos "System Greeter" greeter
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d /var/cache/dms-greeter ]; then
|
||||||
|
chown -R greeter:greeter /var/cache/dms-greeter 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d /var/lib/greeter ]; then
|
||||||
|
chown -R greeter:greeter /var/lib/greeter 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check and set graphical.target as default
|
||||||
|
CURRENT_TARGET=$(systemctl get-default 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$CURRENT_TARGET" != "graphical.target" ]; then
|
||||||
|
systemctl set-default graphical.target >/dev/null 2>&1 || true
|
||||||
|
TARGET_STATUS="Set to graphical.target (was: $CURRENT_TARGET) ✓"
|
||||||
|
else
|
||||||
|
TARGET_STATUS="Already graphical.target ✓"
|
||||||
|
fi
|
||||||
|
|
||||||
|
GREETD_CONFIG="/etc/greetd/config.toml"
|
||||||
|
CONFIG_STATUS="Not modified (already configured)"
|
||||||
|
|
||||||
|
# Check if niri or hyprland exists
|
||||||
|
COMPOSITOR="niri"
|
||||||
|
if ! command -v niri >/dev/null 2>&1; then
|
||||||
|
if command -v Hyprland >/dev/null 2>&1; then
|
||||||
|
COMPOSITOR="hyprland"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If config doesn't exist, create a default one
|
||||||
|
if [ ! -f "$GREETD_CONFIG" ]; then
|
||||||
|
mkdir -p /etc/greetd
|
||||||
|
cat > "$GREETD_CONFIG" << 'GREETD_EOF'
|
||||||
|
[terminal]
|
||||||
|
vt = 1
|
||||||
|
|
||||||
|
[default_session]
|
||||||
|
user = "greeter"
|
||||||
|
command = "/usr/bin/dms-greeter --command COMPOSITOR_PLACEHOLDER"
|
||||||
|
GREETD_EOF
|
||||||
|
sed -i "s|COMPOSITOR_PLACEHOLDER|$COMPOSITOR|" "$GREETD_CONFIG"
|
||||||
|
CONFIG_STATUS="Created new config with $COMPOSITOR ✓"
|
||||||
|
elif ! grep -q "dms-greeter" "$GREETD_CONFIG"; then
|
||||||
|
# Backup existing config
|
||||||
|
BACKUP_FILE="${GREETD_CONFIG}.backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$GREETD_CONFIG" "$BACKUP_FILE" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Update command in default_session section
|
||||||
|
sed -i "/^\[default_session\]/,/^\[/ s|^command =.*|command = \"/usr/bin/dms-greeter --command $COMPOSITOR\"|" "$GREETD_CONFIG"
|
||||||
|
sed -i '/^\[default_session\]/,/^\[/ s|^user =.*|user = "greeter"|' "$GREETD_CONFIG"
|
||||||
|
CONFIG_STATUS="Updated existing config (backed up) with $COMPOSITOR ✓"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only show banner on initial install
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
cat << 'EOF'
|
||||||
|
|
||||||
|
=========================================================================
|
||||||
|
DMS Greeter Installation Complete!
|
||||||
|
=========================================================================
|
||||||
|
|
||||||
|
Status:
|
||||||
|
EOF
|
||||||
|
echo " ✓ Greetd config: $CONFIG_STATUS"
|
||||||
|
echo " ✓ Default target: $TARGET_STATUS"
|
||||||
|
cat << 'EOF'
|
||||||
|
✓ Greeter user: Created
|
||||||
|
✓ Greeter directories: /var/cache/dms-greeter, /var/lib/greeter
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
|
||||||
|
1. Enable the greeter:
|
||||||
|
dms greeter enable
|
||||||
|
(This will automatically disable conflicting display managers,
|
||||||
|
set graphical.target, and enable greetd)
|
||||||
|
|
||||||
|
2. Sync your theme with the greeter (optional):
|
||||||
|
dms greeter sync
|
||||||
|
|
||||||
|
3. Check your setup:
|
||||||
|
dms greeter status
|
||||||
|
|
||||||
|
Ready to test? Run: sudo systemctl start greetd
|
||||||
|
Documentation: https://danklinux.com/docs/dankgreeter/
|
||||||
|
=========================================================================
|
||||||
|
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
#DEBHELPER#
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
purge)
|
||||||
|
# Remove greeter cache directory on purge
|
||||||
|
rm -rf /var/cache/dms-greeter 2>/dev/null || true
|
||||||
|
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
#DEBHELPER#
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/make -f
|
||||||
|
|
||||||
|
export DH_VERBOSE = 1
|
||||||
|
|
||||||
|
DEB_VERSION := $(shell dpkg-parsechangelog -S Version)
|
||||||
|
UPSTREAM_VERSION := $(shell echo $(DEB_VERSION) | sed 's/-[^-]*$$//')
|
||||||
|
|
||||||
|
%:
|
||||||
|
dh $@
|
||||||
|
|
||||||
|
override_dh_auto_build:
|
||||||
|
: nothing to build, we use prebuilt tarball content
|
||||||
|
|
||||||
|
override_dh_auto_install:
|
||||||
|
# Same pattern as dms: upstream from combined tarball (native format)
|
||||||
|
# Build root is either . (we're inside dms-qml) or has dms-qml/ subdir
|
||||||
|
SOURCE_DIR=""; \
|
||||||
|
if [ -d dms-qml ]; then SOURCE_DIR="dms-qml"; \
|
||||||
|
elif [ -f Modules/Greetd/assets/dms-greeter ]; then SOURCE_DIR="."; \
|
||||||
|
fi; \
|
||||||
|
if [ -n "$$SOURCE_DIR" ]; then \
|
||||||
|
mkdir -p debian/dms-greeter/usr/share/quickshell/dms-greeter && \
|
||||||
|
( cd $$SOURCE_DIR && tar cf - --exclude=debian . ) | \
|
||||||
|
( cd debian/dms-greeter/usr/share/quickshell/dms-greeter && tar xf - ) && \
|
||||||
|
install -Dm755 $$SOURCE_DIR/Modules/Greetd/assets/dms-greeter \
|
||||||
|
debian/dms-greeter/usr/bin/dms-greeter && \
|
||||||
|
install -Dm644 $$SOURCE_DIR/Modules/Greetd/README.md \
|
||||||
|
debian/dms-greeter/usr/share/doc/dms-greeter/README.md && \
|
||||||
|
install -Dm644 $$SOURCE_DIR/LICENSE \
|
||||||
|
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE && \
|
||||||
|
install -Dpm0644 $$SOURCE_DIR/systemd/tmpfiles-dms-greeter.conf \
|
||||||
|
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf; \
|
||||||
|
else \
|
||||||
|
echo "ERROR: No upstream source (dms-qml or Modules/Greetd/assets/dms-greeter)!" && \
|
||||||
|
echo "Contents of current directory:" && ls -la && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove build and development files
|
||||||
|
rm -rf debian/dms-greeter/usr/share/quickshell/dms-greeter/core
|
||||||
|
rm -rf debian/dms-greeter/usr/share/quickshell/dms-greeter/distro
|
||||||
|
rm -rf debian/dms-greeter/usr/share/quickshell/dms-greeter/.git*
|
||||||
|
rm -f debian/dms-greeter/usr/share/quickshell/dms-greeter/.gitignore
|
||||||
|
rm -rf debian/dms-greeter/usr/share/quickshell/dms-greeter/.github
|
||||||
|
|
||||||
|
override_dh_auto_clean:
|
||||||
|
rm -rf dms-qml
|
||||||
|
# When build root is dms-qml itself, we're inside it - nothing extra to remove
|
||||||
|
dh_auto_clean
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.0 (native)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# OBS _service downloads dms-qml.tar.gz; no extra excludes needed
|
||||||
@@ -9,7 +9,7 @@ Vcs-Browser: https://github.com/AvengeMedia/DankMaterialShell
|
|||||||
Vcs-Git: https://github.com/AvengeMedia/DankMaterialShell.git
|
Vcs-Git: https://github.com/AvengeMedia/DankMaterialShell.git
|
||||||
|
|
||||||
Package: dms
|
Package: dms
|
||||||
Architecture: amd64
|
Architecture: amd64 arm64
|
||||||
Depends: ${misc:Depends},
|
Depends: ${misc:Depends},
|
||||||
quickshell | quickshell-git,
|
quickshell | quickshell-git,
|
||||||
accountsservice,
|
accountsservice,
|
||||||
@@ -28,6 +28,7 @@ Depends: ${misc:Depends},
|
|||||||
qml6-module-qtquick-templates,
|
qml6-module-qtquick-templates,
|
||||||
qml6-module-qtquick-window,
|
qml6-module-qtquick-window,
|
||||||
qt6ct
|
qt6ct
|
||||||
|
Suggests: cups-pk-helper
|
||||||
Conflicts: dms-git
|
Conflicts: dms-git
|
||||||
Replaces: dms-git
|
Replaces: dms-git
|
||||||
Description: DankMaterialShell - Modern Wayland Desktop Shell
|
Description: DankMaterialShell - Modern Wayland Desktop Shell
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
dms-distropkg-amd64.gz
|
dms-distropkg-amd64.gz
|
||||||
|
dms-distropkg-arm64.gz
|
||||||
dms-source.tar.gz
|
dms-source.tar.gz
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# Include files that are normally excluded by .gitignore
|
# Include files that are normally excluded by .gitignore
|
||||||
# These are needed for the build process on Launchpad
|
# These are needed for the build process on Launchpad
|
||||||
tar-ignore = !dms-distropkg-amd64.gz
|
tar-ignore = !dms-distropkg-amd64.gz
|
||||||
|
tar-ignore = !dms-distropkg-arm64.gz
|
||||||
tar-ignore = !dms-source.tar.gz
|
tar-ignore = !dms-source.tar.gz
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user