mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-11 23:09:42 -04:00
Compare commits
102 Commits
4c2c193766
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fc805ba53 | ||
|
|
371a7d0cd1 | ||
|
|
189b7c84ce | ||
|
|
b8f4c350a8 | ||
|
|
3989c7f801 | ||
|
|
2690305724 | ||
|
|
676219bc09 | ||
|
|
b192b5f779 | ||
|
|
a5352623fd | ||
|
|
2b6ae58bff | ||
|
|
b12511481d | ||
|
|
c7d44cfb12 | ||
|
|
4193cf51ff | ||
|
|
1ec0311086 | ||
|
|
c6a1473d2f | ||
|
|
ee16047e15 | ||
|
|
4968b80268 | ||
|
|
e28b0c695e | ||
|
|
7f6486b3e7 | ||
|
|
faa30c4d48 | ||
|
|
cf641b4e08 | ||
|
|
b75453c7d6 | ||
|
|
10872b5fc8 | ||
|
|
80c853f16c | ||
|
|
6167f22837 | ||
|
|
d8835f2bc6 | ||
|
|
1c01774fde | ||
|
|
0d3eb774e2 | ||
|
|
7fb4b6e0d9 | ||
|
|
5df2b5fc33 | ||
|
|
d49c49cd99 | ||
|
|
b209827f38 | ||
|
|
1b9d1c667c | ||
|
|
04d961ad0b | ||
|
|
e60caf8028 | ||
|
|
1e67927f8a | ||
|
|
e6e343dacb | ||
|
|
f87ad3d2ca | ||
|
|
a6ba4b1c79 | ||
|
|
7cf718cd50 | ||
|
|
d223a74740 | ||
|
|
408beb202c | ||
|
|
cfe6e6867e | ||
|
|
7c991bc4e3 | ||
|
|
50f0cbb122 | ||
|
|
7ee0879103 | ||
|
|
56ef186ce8 | ||
|
|
5b507136e3 | ||
|
|
19c561da14 | ||
|
|
cc47703d48 | ||
|
|
31e60a3df5 | ||
|
|
082de6f1f0 | ||
|
|
fd24b4a36d | ||
|
|
dd668469d7 | ||
|
|
434490e100 | ||
|
|
d2f6cb3ae4 | ||
|
|
c1cbd0994f | ||
|
|
c81645bacb | ||
|
|
cdc4ca7e1f | ||
|
|
7d92842ff2 | ||
|
|
d8bf3bdfe8 | ||
|
|
23ed795e85 | ||
|
|
2877c63c97 | ||
|
|
86096db26b | ||
|
|
f76724f7cd | ||
|
|
3b96c6ab22 | ||
|
|
1467f5dba9 | ||
|
|
baaa30c94e | ||
|
|
24a3cd5a3d | ||
|
|
65151dbfd7 | ||
|
|
7bd9574868 | ||
|
|
a4cfdf4a59 | ||
|
|
fd651dc943 | ||
|
|
919b09fc96 | ||
|
|
aeb3fdd637 | ||
|
|
dc5636bed5 | ||
|
|
36a7692da7 | ||
|
|
c9b38023d5 | ||
|
|
536e654b5e | ||
|
|
e805f6b5ac | ||
|
|
94f4b6d4a9 | ||
|
|
28f68ac702 | ||
|
|
441ec42ee0 | ||
|
|
5415444e15 | ||
|
|
bd5276b40d | ||
|
|
dd3f17f51e | ||
|
|
a459b7d1b4 | ||
|
|
0f71c29776 | ||
|
|
4a32739d3f | ||
|
|
1abb221024 | ||
|
|
b2668a2ffc | ||
|
|
f4c11bc2ff | ||
|
|
97fa86d8f0 | ||
|
|
b87c36d29e | ||
|
|
c6ed64b24e | ||
|
|
cf382c0322 | ||
|
|
9139fd2fb1 | ||
|
|
da3df9bb77 | ||
|
|
e7834c981a | ||
|
|
316428b14a | ||
|
|
6a9de8b423 | ||
|
|
f1e3452307 |
24
.github/workflows/nix-pr-check.yml
vendored
24
.github/workflows/nix-pr-check.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Check nix flake
|
name: Nix flake and NixOS tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -9,6 +9,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
check-flake:
|
check-flake:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -18,6 +19,25 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: cachix/install-nix-action@v31
|
uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
enable_kvm: true
|
||||||
|
extra_nix_config: |
|
||||||
|
system-features = nixos-test benchmark big-parallel kvm
|
||||||
|
|
||||||
- name: Check the flake
|
- name: Check the flake
|
||||||
run: nix flake check
|
run: nix flake check -L
|
||||||
|
|
||||||
|
- name: Run NixOS module test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.nixos-module -L
|
||||||
|
|
||||||
|
- name: Run NixOS service start test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
|
||||||
|
|
||||||
|
- name: Run greeter niri test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.greeter-niri-module -L
|
||||||
|
|
||||||
|
- name: Run home-manager module test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
|
||||||
|
|
||||||
|
- name: Run niri home-manager module test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.niri-home-module -L
|
||||||
|
|||||||
10
.github/workflows/run-obs.yml
vendored
10
.github/workflows/run-obs.yml
vendored
@@ -367,6 +367,16 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
chmod 600 ~/.config/osc/oscrc
|
chmod 600 ~/.config/osc/oscrc
|
||||||
|
|
||||||
|
# Cache OBS bundled Go toolchains
|
||||||
|
- name: Cache OBS bundled Go toolchains (dms-git)
|
||||||
|
if: contains(steps.packages.outputs.packages, 'dms-git')
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /home/runner/.cache/dms-obs-go-toolchain
|
||||||
|
key: dms-obs-go-toolchain-${{ runner.os }}-${{ hashFiles('core/go.mod') }}
|
||||||
|
restore-keys: |
|
||||||
|
dms-obs-go-toolchain-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Upload to OBS
|
- name: Upload to OBS
|
||||||
id: upload
|
id: upload
|
||||||
env:
|
env:
|
||||||
|
|||||||
270
.github/workflows/run-ppa.yml
vendored
270
.github/workflows/run-ppa.yml
vendored
@@ -22,12 +22,13 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-updates:
|
check-updates:
|
||||||
name: Check for updates
|
name: Check package/series updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||||
packages: ${{ steps.check.outputs.packages }}
|
targets: ${{ steps.check.outputs.targets }}
|
||||||
|
targets_json: ${{ steps.check.outputs.targets_json }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -35,125 +36,57 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y jq curl git
|
||||||
|
|
||||||
- name: Check for updates
|
- name: Check for updates
|
||||||
id: check
|
id: check
|
||||||
run: |
|
run: |
|
||||||
# Helper function to check dms-git commit
|
chmod +x distro/scripts/ppa-sync-plan.sh
|
||||||
check_dms_git() {
|
|
||||||
local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
|
||||||
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/dms-git?ws.op=getPublishedSources&source_name=dms-git&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
|
|
||||||
local PPA_COMMIT=$(echo "$PPA_VERSION" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
|
||||||
|
|
||||||
if [[ -n "$PPA_COMMIT" && "$CURRENT_COMMIT" == "$PPA_COMMIT" ]]; then
|
|
||||||
echo "📋 dms-git: Commit $CURRENT_COMMIT already exists, skipping"
|
|
||||||
return 1 # No update needed
|
|
||||||
else
|
|
||||||
echo "📋 dms-git: New commit $CURRENT_COMMIT (PPA has ${PPA_COMMIT:-none})"
|
|
||||||
return 0 # Update needed
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Helper function to check stable package tag
|
|
||||||
check_stable_package() {
|
|
||||||
local PKG="$1"
|
|
||||||
local PPA_NAME="$2"
|
|
||||||
# Use git ls-remote to find the latest tag, sorted by version (descending)
|
|
||||||
local LATEST_TAG=$(git ls-remote --tags --refs --sort='-v:refname' https://github.com/AvengeMedia/DankMaterialShell.git | head -n1 | awk -F/ '{print $NF}' | sed 's/^v//')
|
|
||||||
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$PKG&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
|
|
||||||
local PPA_BASE_VERSION=$(echo "$PPA_VERSION" | sed 's/ppa[0-9]*$//')
|
|
||||||
|
|
||||||
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "$PPA_BASE_VERSION" ]]; then
|
|
||||||
echo "📋 $PKG: Tag $LATEST_TAG already exists, skipping"
|
|
||||||
return 1 # No update needed
|
|
||||||
else
|
|
||||||
echo "📋 $PKG: New tag ${LATEST_TAG:-unknown} (PPA has ${PPA_BASE_VERSION:-none})"
|
|
||||||
return 0 # Update needed
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main logic
|
|
||||||
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||||
# Scheduled run - check dms-git only
|
PACKAGE="dms-git"
|
||||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
|
||||||
if check_dms_git; then
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
|
||||||
# Manual workflow trigger
|
|
||||||
PKG="${{ github.event.inputs.package }}"
|
|
||||||
|
|
||||||
if [[ -n "$REBUILD" ]]; then
|
|
||||||
# Rebuild requested - always proceed
|
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "🔄 Manual rebuild requested: $PKG (ppa$REBUILD)"
|
|
||||||
|
|
||||||
elif [[ "$PKG" == "all" ]]; then
|
|
||||||
# Check each package and build list of those needing updates
|
|
||||||
PACKAGES_TO_UPDATE=()
|
|
||||||
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
|
|
||||||
check_stable_package "dms" "dms" && PACKAGES_TO_UPDATE+=("dms")
|
|
||||||
check_stable_package "dms-greeter" "danklinux" && PACKAGES_TO_UPDATE+=("dms-greeter")
|
|
||||||
|
|
||||||
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
|
|
||||||
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "✓ Packages to update: ${PACKAGES_TO_UPDATE[*]}"
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "✓ All packages up to date"
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [[ "$PKG" == "dms-git" ]]; then
|
|
||||||
if check_dms_git; then
|
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [[ "$PKG" == "dms" ]]; then
|
|
||||||
if check_stable_package "dms" "dms"; then
|
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [[ "$PKG" == "dms-greeter" ]]; then
|
|
||||||
if check_stable_package "dms-greeter" "danklinux"; then
|
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
else
|
|
||||||
# Unknown package - proceed anyway
|
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "Manual trigger: $PKG"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
# Fallback
|
PACKAGE="${{ github.event.inputs.package }}"
|
||||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
fi
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
|
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
||||||
|
ARGS=(--package "$PACKAGE" --json)
|
||||||
|
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||||
|
ARGS+=(--rebuild "$REBUILD_RELEASE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGETS_JSON=$(distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2> ppa-audit.log)
|
||||||
|
cat ppa-audit.log
|
||||||
|
|
||||||
|
TARGETS=$(echo "$TARGETS_JSON" | jq -r 'join(" ")')
|
||||||
|
if [[ "$TARGETS_JSON" != "[]" ]]; then
|
||||||
|
echo "has_updates=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "targets_json=$TARGETS_JSON" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Package/series targets: $TARGETS"
|
||||||
|
else
|
||||||
|
echo "has_updates=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "targets_json=[]" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No package/series uploads needed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
upload-ppa:
|
upload-ppa:
|
||||||
name: Upload to PPA
|
name: Upload ${{ matrix.target }}
|
||||||
needs: check-updates
|
needs: check-updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: needs.check-updates.outputs.has_updates == 'true'
|
if: needs.check-updates.outputs.has_updates == 'true'
|
||||||
|
timeout-minutes: 120
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: ${{ fromJson(needs.check-updates.outputs.targets_json) }}
|
||||||
|
concurrency:
|
||||||
|
group: ppa-dms-${{ matrix.target }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -177,7 +110,8 @@ jobs:
|
|||||||
lftp \
|
lftp \
|
||||||
build-essential \
|
build-essential \
|
||||||
fakeroot \
|
fakeroot \
|
||||||
dpkg-dev
|
dpkg-dev \
|
||||||
|
openssh-client
|
||||||
|
|
||||||
- name: Configure GPG
|
- name: Configure GPG
|
||||||
env:
|
env:
|
||||||
@@ -185,106 +119,32 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "$GPG_KEY" | gpg --import
|
echo "$GPG_KEY" | gpg --import
|
||||||
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
||||||
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
|
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Determine packages to upload
|
- name: Upload target
|
||||||
id: packages
|
env:
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
|
||||||
|
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
|
||||||
run: |
|
run: |
|
||||||
# Use packages determined by check-updates job
|
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
case "$PACKAGE" in
|
||||||
echo "Triggered by schedule: uploading git package"
|
dms) PPA_NAME="dms" ;;
|
||||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
dms-git) PPA_NAME="dms-git" ;;
|
||||||
echo "Manual trigger: ${{ needs.check-updates.outputs.packages }}"
|
dms-greeter) PPA_NAME="danklinux" ;;
|
||||||
fi
|
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
- name: Upload to PPA
|
echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
|
||||||
run: |
|
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
|
||||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
|
||||||
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
|
||||||
|
|
||||||
if [[ -z "$PACKAGES" ]]; then
|
|
||||||
echo "✓ No packages need uploading. All up to date!"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Export REBUILD_RELEASE so ppa-build.sh can use it
|
|
||||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
|
||||||
export REBUILD_RELEASE
|
|
||||||
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# PACKAGES can be space-separated list (e.g., "dms-git dms dms-greeter" from "all" check)
|
|
||||||
# Loop through each package and upload
|
|
||||||
for PKG in $PACKAGES; do
|
|
||||||
# Map package to PPA name
|
|
||||||
case "$PKG" in
|
|
||||||
dms)
|
|
||||||
PPA_NAME="dms"
|
|
||||||
;;
|
|
||||||
dms-git)
|
|
||||||
PPA_NAME="dms-git"
|
|
||||||
;;
|
|
||||||
dms-greeter)
|
|
||||||
PPA_NAME="danklinux"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "⚠️ Unknown package: $PKG, skipping"
|
|
||||||
continue
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo "Uploading $PKG to PPA $PPA_NAME..."
|
|
||||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
|
||||||
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
|
|
||||||
fi
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
# ppa-upload.sh uploads to questing + resolute when series is omitted
|
|
||||||
if ! bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}; then
|
|
||||||
echo "::error::Upload failed for $PKG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
echo "### PPA Package Upload Summary" >> $GITHUB_STEP_SUMMARY
|
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
if [[ -z "$PACKAGES" ]]; then
|
echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "**Status:** ✅ All packages up to date (no uploads needed)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "All packages are current. Run will complete successfully." >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
for PKG in $PACKAGES; do
|
|
||||||
case "$PKG" in
|
|
||||||
dms)
|
|
||||||
echo "- ✅ **dms** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
;;
|
|
||||||
dms-git)
|
|
||||||
echo "- ✅ **dms-git** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
;;
|
|
||||||
dms-greeter)
|
|
||||||
echo "- ✅ **dms-greeter** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
|
|
||||||
echo "**Rebuild Number:** ppa${{ github.event.inputs.rebuild_release }}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Builds will appear once Launchpad processes the uploads." >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -20,3 +20,11 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
files: ^core/.*\.(go|mod|sum)$
|
files: ^core/.*\.(go|mod|sum)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: no-console-in-qml
|
||||||
|
name: no console.* in QML (use Log service)
|
||||||
|
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
|
||||||
|
language: system
|
||||||
|
files: ^quickshell/.*\.qml$
|
||||||
|
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)
|
||||||
|
|||||||
@@ -1,26 +1,13 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: local
|
- repo: https://github.com/golangci/golangci-lint
|
||||||
|
rev: v2.10.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: golangci-lint-fmt
|
- id: golangci-lint-fmt
|
||||||
name: golangci-lint-fmt
|
|
||||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 fmt
|
|
||||||
language: system
|
|
||||||
require_serial: true
|
require_serial: true
|
||||||
types: [go]
|
|
||||||
pass_filenames: false
|
|
||||||
- id: golangci-lint-full
|
- id: golangci-lint-full
|
||||||
name: golangci-lint-full
|
|
||||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 run --fix
|
|
||||||
language: system
|
|
||||||
require_serial: true
|
|
||||||
types: [go]
|
|
||||||
pass_filenames: false
|
|
||||||
- id: golangci-lint-config-verify
|
- id: golangci-lint-config-verify
|
||||||
name: golangci-lint-config-verify
|
- repo: local
|
||||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 config verify
|
hooks:
|
||||||
language: system
|
|
||||||
files: \.golangci\.(?:yml|yaml|toml|json)
|
|
||||||
pass_filenames: false
|
|
||||||
- id: go-test
|
- id: go-test
|
||||||
name: go test
|
name: go test
|
||||||
entry: go test ./...
|
entry: go test ./...
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ var runCmd = &cobra.Command{
|
|||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
daemon, _ := cmd.Flags().GetBool("daemon")
|
daemon, _ := cmd.Flags().GetBool("daemon")
|
||||||
session, _ := cmd.Flags().GetBool("session")
|
session, _ := cmd.Flags().GetBool("session")
|
||||||
|
if v, _ := cmd.Flags().GetString("log-level"); v != "" {
|
||||||
|
if err := os.Setenv("DMS_LOG_LEVEL", v); err != nil {
|
||||||
|
log.Fatalf("Failed to set DMS_LOG_LEVEL: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetString("log-file"); v != "" {
|
||||||
|
if err := os.Setenv("DMS_LOG_FILE", v); err != nil {
|
||||||
|
log.Fatalf("Failed to set DMS_LOG_FILE: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.ApplyEnvOverrides()
|
||||||
if daemon {
|
if daemon {
|
||||||
runShellDaemon(session)
|
runShellDaemon(session)
|
||||||
} else {
|
} else {
|
||||||
@@ -526,5 +537,7 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
dlCmd,
|
dlCmd,
|
||||||
randrCmd,
|
randrCmd,
|
||||||
blurCmd,
|
blurCmd,
|
||||||
|
trashCmd,
|
||||||
|
systemCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ var (
|
|||||||
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
|
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
|
||||||
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
||||||
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
||||||
|
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
var doctorCmd = &cobra.Command{
|
var doctorCmd = &cobra.Command{
|
||||||
@@ -469,6 +470,7 @@ func checkWindowManagers() []checkResult {
|
|||||||
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
|
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
|
||||||
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
||||||
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
||||||
|
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []checkResult
|
var results []checkResult
|
||||||
@@ -501,7 +503,7 @@ func checkWindowManagers() []checkResult {
|
|||||||
results = append(results, checkResult{
|
results = append(results, checkResult{
|
||||||
catCompositor, "Compositor", statusError,
|
catCompositor, "Compositor", statusError,
|
||||||
"No supported Wayland compositor found",
|
"No supported Wayland compositor found",
|
||||||
"Install Hyprland, niri, Sway, River, or Wayfire",
|
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
|
||||||
doctorDocsURL + "#compositor-checks",
|
doctorDocsURL + "#compositor-checks",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -551,6 +553,8 @@ func detectRunningWM() string {
|
|||||||
return "Hyprland"
|
return "Hyprland"
|
||||||
case os.Getenv("NIRI_SOCKET") != "":
|
case os.Getenv("NIRI_SOCKET") != "":
|
||||||
return "niri"
|
return "niri"
|
||||||
|
case os.Getenv("MIRACLESOCK") != "":
|
||||||
|
return "Miracle WM"
|
||||||
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
|
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
|
||||||
return os.Getenv("XDG_CURRENT_DESKTOP")
|
return os.Getenv("XDG_CURRENT_DESKTOP")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -267,6 +269,8 @@ func runSetupDmsConfig(name string) error {
|
|||||||
func runSetup() error {
|
func runSetup() error {
|
||||||
fmt.Println("=== DMS Configuration Setup ===")
|
fmt.Println("=== DMS Configuration Setup ===")
|
||||||
|
|
||||||
|
ensureInputGroup()
|
||||||
|
|
||||||
wm, wmSelected := promptCompositor()
|
wm, wmSelected := promptCompositor()
|
||||||
terminal, terminalSelected := promptTerminal()
|
terminal, terminalSelected := promptTerminal()
|
||||||
useSystemd := promptSystemd()
|
useSystemd := promptSystemd()
|
||||||
@@ -340,6 +344,37 @@ func runSetup() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add user to the input group for the evdev manager for inut state tracking.
|
||||||
|
// Caps Lock OSD and the Caps Lock bar indicator.
|
||||||
|
func ensureInputGroup() {
|
||||||
|
if !utils.HasGroup("input") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentUser := os.Getenv("USER")
|
||||||
|
if currentUser == "" {
|
||||||
|
currentUser = os.Getenv("LOGNAME")
|
||||||
|
}
|
||||||
|
if currentUser == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := execGroups(currentUser)
|
||||||
|
if err == nil && strings.Contains(out, "input") {
|
||||||
|
fmt.Printf("✓ %s is already in the input group (Caps Lock OSD enabled)\n", currentUser)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Adding user to input group for Caps Lock OSD support...")
|
||||||
|
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "input", currentUser); err != nil {
|
||||||
|
fmt.Printf("⚠ Could not add %s to input group (Caps Lock OSD will be unavailable): %v\n", currentUser, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✓ Added %s to input group (logout/login required to take effect)\n", currentUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func execGroups(user string) (string, error) {
|
||||||
|
out, err := exec.Command("groups", user).Output()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
func promptCompositor() (deps.WindowManager, bool) {
|
func promptCompositor() (deps.WindowManager, bool) {
|
||||||
fmt.Println("Select compositor:")
|
fmt.Println("Select compositor:")
|
||||||
fmt.Println("1) Niri")
|
fmt.Println("1) Niri")
|
||||||
|
|||||||
365
core/cmd/dms/commands_system.go
Normal file
365
core/cmd/dms/commands_system.go
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var systemCmd = &cobra.Command{
|
||||||
|
Use: "system",
|
||||||
|
Short: "System operations",
|
||||||
|
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update",
|
||||||
|
Short: "Apply or list system updates",
|
||||||
|
Long: `Apply or list system updates across detected package managers.
|
||||||
|
|
||||||
|
Default behavior is to apply available updates after prompting for confirmation.
|
||||||
|
Use --check to list updates without applying.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
dms system update --check # list available updates
|
||||||
|
dms system update # apply updates (interactive prompt)
|
||||||
|
dms system update --noconfirm # apply updates without prompting
|
||||||
|
dms system update --dry # simulate without changing anything
|
||||||
|
dms system update --no-flatpak --noconfirm # apply system updates only
|
||||||
|
dms system update --interval 3600 # set the server poll interval to 1h`,
|
||||||
|
Run: runSystemUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sysUpdateCheck bool
|
||||||
|
sysUpdateNoConfirm bool
|
||||||
|
sysUpdateDry bool
|
||||||
|
sysUpdateJSON bool
|
||||||
|
sysUpdateNoFlatpak bool
|
||||||
|
sysUpdateNoAUR bool
|
||||||
|
sysUpdateIntervalS int
|
||||||
|
sysUpdateListPmTime = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
|
||||||
|
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||||
|
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||||
|
|
||||||
|
systemCmd.AddCommand(systemUpdateCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdate(cmd *cobra.Command, args []string) {
|
||||||
|
switch {
|
||||||
|
case sysUpdateIntervalS >= 0:
|
||||||
|
runSystemUpdateSetInterval(sysUpdateIntervalS)
|
||||||
|
case sysUpdateCheck:
|
||||||
|
runSystemUpdateCheck()
|
||||||
|
default:
|
||||||
|
runSystemUpdateApply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectBackends(ctx context.Context) []sysupdate.Backend {
|
||||||
|
sel := sysupdate.Select(ctx)
|
||||||
|
backends := sel.All()
|
||||||
|
if !sysUpdateNoFlatpak {
|
||||||
|
return backends
|
||||||
|
}
|
||||||
|
out := backends[:0]
|
||||||
|
for _, b := range backends {
|
||||||
|
if b.Repo() == sysupdate.RepoFlatpak {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateCheck() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
backends := selectBackends(ctx)
|
||||||
|
if len(backends) == 0 {
|
||||||
|
log.Fatal("No supported package manager found")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopSpin := startSpinner("Checking for updates… ")
|
||||||
|
allPkgs, firstErr := collectUpdates(ctx, backends)
|
||||||
|
stopSpin()
|
||||||
|
allPkgs = filterUpdateTargets(allPkgs)
|
||||||
|
|
||||||
|
if sysUpdateJSON {
|
||||||
|
out, _ := json.MarshalIndent(map[string]any{
|
||||||
|
"backends": backendResults(backends, allPkgs),
|
||||||
|
"packages": allPkgs,
|
||||||
|
"error": errOrEmpty(firstErr),
|
||||||
|
"count": len(allPkgs),
|
||||||
|
}, "", " ")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
printBackends(backends)
|
||||||
|
fmt.Printf("Updates: %d\n", len(allPkgs))
|
||||||
|
if firstErr != nil {
|
||||||
|
fmt.Printf("Error: %v\n", firstErr)
|
||||||
|
}
|
||||||
|
if len(allPkgs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
for _, p := range allPkgs {
|
||||||
|
printPackage(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type backendResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Display string `json:"displayName"`
|
||||||
|
Packages []sysupdate.Package `json:"packages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func backendResults(backends []sysupdate.Backend, pkgs []sysupdate.Package) []backendResult {
|
||||||
|
results := make([]backendResult, 0, len(backends))
|
||||||
|
for _, b := range backends {
|
||||||
|
var backendPkgs []sysupdate.Package
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if sysupdate.BackendHasTargets(b, []sysupdate.Package{p}, true, true) {
|
||||||
|
backendPkgs = append(backendPkgs, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: backendPkgs})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateApply() {
|
||||||
|
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||||
|
defer checkCancel()
|
||||||
|
|
||||||
|
backends := selectBackends(checkCtx)
|
||||||
|
if len(backends) == 0 {
|
||||||
|
log.Fatal("No supported package manager found")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopSpin := startSpinner("Checking for updates…")
|
||||||
|
pkgs, firstErr := collectUpdates(checkCtx, backends)
|
||||||
|
stopSpin()
|
||||||
|
pkgs = filterUpdateTargets(pkgs)
|
||||||
|
if firstErr != nil {
|
||||||
|
fmt.Printf("Warning: %v\n\n", firstErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
printBackends(backends)
|
||||||
|
fmt.Printf("Updates: %d\n", len(pkgs))
|
||||||
|
if len(pkgs) == 0 {
|
||||||
|
fmt.Println("Nothing to upgrade.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
for _, p := range pkgs {
|
||||||
|
printPackage(p)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if !sysUpdateNoConfirm && !sysUpdateDry {
|
||||||
|
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
|
||||||
|
fmt.Println("Aborted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
opts := sysupdate.UpgradeOptions{
|
||||||
|
Targets: pkgs,
|
||||||
|
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||||
|
IncludeAUR: !sysUpdateNoAUR,
|
||||||
|
DryRun: sysUpdateDry,
|
||||||
|
UseSudo: true,
|
||||||
|
}
|
||||||
|
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
|
||||||
|
|
||||||
|
onLine := func(line string) { fmt.Println(line) }
|
||||||
|
ran := false
|
||||||
|
for _, b := range backends {
|
||||||
|
if !sysupdate.BackendHasTargets(b, pkgs, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ran = true
|
||||||
|
fmt.Printf("\n== %s ==\n", b.DisplayName())
|
||||||
|
if err := b.Upgrade(ctx, opts, onLine); err != nil {
|
||||||
|
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ran {
|
||||||
|
fmt.Println("Nothing to upgrade.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sysUpdateDry {
|
||||||
|
fmt.Println("\nDry run complete (no changes applied).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("\nUpgrade complete.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
|
||||||
|
var all []sysupdate.Package
|
||||||
|
var firstErr error
|
||||||
|
for _, b := range backends {
|
||||||
|
pkgs, err := b.CheckUpdates(ctx)
|
||||||
|
if err != nil && firstErr == nil {
|
||||||
|
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
|
||||||
|
}
|
||||||
|
all = append(all, pkgs...)
|
||||||
|
}
|
||||||
|
return all, firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
||||||
|
if !sysUpdateNoAUR {
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
out := pkgs[:0]
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if p.Repo == sysupdate.RepoAUR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateSetInterval(seconds int) {
|
||||||
|
resp, err := sendServerRequest(models.Request{
|
||||||
|
ID: 1,
|
||||||
|
Method: "sysupdate.setInterval",
|
||||||
|
Params: map[string]any{"seconds": float64(seconds)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed: %v (is dms server running?)", err)
|
||||||
|
}
|
||||||
|
if resp.Error != "" {
|
||||||
|
log.Fatalf("Error: %s", resp.Error)
|
||||||
|
}
|
||||||
|
fmt.Printf("Interval set to %d seconds.\n", seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptYesNo(prompt string) bool {
|
||||||
|
if !stdinIsTTY() {
|
||||||
|
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
|
||||||
|
}
|
||||||
|
fmt.Print(prompt)
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||||
|
case "n", "no":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printBackends(backends []sysupdate.Backend) {
|
||||||
|
if len(backends) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(backends))
|
||||||
|
for _, b := range backends {
|
||||||
|
names = append(names, b.DisplayName())
|
||||||
|
}
|
||||||
|
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stdinIsTTY() bool {
|
||||||
|
fi, err := os.Stdin.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func stdoutIsTTY() bool {
|
||||||
|
fi, err := os.Stdout.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSpinner prints an animated spinner to stdout for progress indication
|
||||||
|
func startSpinner(msg string) func() {
|
||||||
|
if !stdoutIsTTY() {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
fmt.Print("\r\033[K")
|
||||||
|
return
|
||||||
|
case <-time.After(80 * time.Millisecond):
|
||||||
|
fmt.Printf("\r%s %s", frames[i%len(frames)], msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return func() { close(done) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
styleRepo = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Bold(false)
|
||||||
|
styleName = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
|
||||||
|
styleFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
|
||||||
|
styleArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||||
|
styleTo = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true)
|
||||||
|
)
|
||||||
|
|
||||||
|
func printPackage(p sysupdate.Package) {
|
||||||
|
if !stdoutIsTTY() {
|
||||||
|
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s %s %s %s\n",
|
||||||
|
styleRepo.Render("["+string(p.Repo)+"]"),
|
||||||
|
styleName.Render(p.Name),
|
||||||
|
styleFrom.Render(defaultIfEmpty(p.FromVersion, "?")),
|
||||||
|
styleArrow.Render("->"),
|
||||||
|
styleTo.Render(defaultIfEmpty(p.ToVersion, "?")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errOrEmpty(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultIfEmpty(s, def string) string {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
122
core/cmd/dms/commands_trash.go
Normal file
122
core/cmd/dms/commands_trash.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var trashCmd = &cobra.Command{
|
||||||
|
Use: "trash",
|
||||||
|
Short: "Manage the user's trash (XDG Trash spec 1.0)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashPutCmd = &cobra.Command{
|
||||||
|
Use: "put <path...>",
|
||||||
|
Short: "Move files or directories into the trash",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
Run: runTrashPut,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List trashed items across all known trash directories",
|
||||||
|
Run: runTrashList,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashCountCmd = &cobra.Command{
|
||||||
|
Use: "count",
|
||||||
|
Short: "Print the total number of trashed items",
|
||||||
|
Run: runTrashCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashEmptyCmd = &cobra.Command{
|
||||||
|
Use: "empty",
|
||||||
|
Short: "Permanently delete every trashed item",
|
||||||
|
Run: runTrashEmpty,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashRestoreCmd = &cobra.Command{
|
||||||
|
Use: "restore <name>",
|
||||||
|
Short: "Restore a trashed item to its original location",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: runTrashRestore,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
trashJSONOutput bool
|
||||||
|
trashRestoreDir string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON")
|
||||||
|
trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)")
|
||||||
|
trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashPut(cmd *cobra.Command, args []string) {
|
||||||
|
var failed int
|
||||||
|
for _, p := range args {
|
||||||
|
if _, err := trash.Put(p); err != nil {
|
||||||
|
log.Errorf("trash %s: %v", p, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Println(p)
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashList(cmd *cobra.Command, args []string) {
|
||||||
|
entries, err := trash.List()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list trash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if trashJSONOutput {
|
||||||
|
if entries == nil {
|
||||||
|
entries = []trash.Entry{}
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(entries, "", " ")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("Trash is empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
marker := "F"
|
||||||
|
if e.IsDir {
|
||||||
|
marker = "D"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashCount(cmd *cobra.Command, args []string) {
|
||||||
|
n, err := trash.Count()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("count trash: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashEmpty(cmd *cobra.Command, args []string) {
|
||||||
|
if err := trash.Empty(); err != nil {
|
||||||
|
log.Fatalf("empty trash: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashRestore(cmd *cobra.Command, args []string) {
|
||||||
|
if err := trash.Restore(args[0], trashRestoreDir); err != nil {
|
||||||
|
log.Fatalf("restore: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ func init() {
|
|||||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
||||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||||
|
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
|
||||||
|
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ func init() {
|
|||||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
||||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||||
|
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
|
||||||
|
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ func getRuntimeDir() string {
|
|||||||
return os.TempDir()
|
return os.TempDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendLogEnv(env []string) []string {
|
||||||
|
if v := os.Getenv("DMS_LOG_LEVEL"); v != "" {
|
||||||
|
env = append(env, "DMS_LOG_LEVEL="+v)
|
||||||
|
}
|
||||||
|
if v := os.Getenv("DMS_LOG_FILE"); v != "" {
|
||||||
|
env = append(env, "DMS_LOG_FILE="+v)
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
func hasSystemdRun() bool {
|
func hasSystemdRun() bool {
|
||||||
_, err := exec.LookPath("systemd-run")
|
_, err := exec.LookPath("systemd-run")
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -192,9 +202,6 @@ func runShellInteractive(session bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ! TODO - remove when QS 0.3 is up and we can use the pragma
|
|
||||||
cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms")
|
|
||||||
|
|
||||||
if isSessionManaged && hasSystemdRun() {
|
if isSessionManaged && hasSystemdRun() {
|
||||||
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
|
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
|
||||||
}
|
}
|
||||||
@@ -216,6 +223,8 @@ func runShellInteractive(session bool) {
|
|||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Env = appendLogEnv(cmd.Env)
|
||||||
|
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
@@ -459,6 +468,8 @@ func runShellDaemon(session bool) {
|
|||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Env = appendLogEnv(cmd.Env)
|
||||||
|
|
||||||
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error opening /dev/null: %v", err)
|
log.Fatalf("Error opening /dev/null: %v", err)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func isReadOnlyCommand(args []string) bool {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch arg {
|
switch arg {
|
||||||
case "completion", "help", "__complete":
|
case "completion", "help", "__complete", "system":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
74
core/go.mod
74
core/go.mod
@@ -1,84 +1,100 @@
|
|||||||
module github.com/AvengeMedia/DankMaterialShell/core
|
module github.com/AvengeMedia/DankMaterialShell/core
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.1
|
||||||
|
|
||||||
toolchain go1.26.1
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1
|
github.com/alecthomas/chroma/v2 v2.24.1
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/charmbracelet/log v0.4.2
|
github.com/charmbracelet/log v1.0.0
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/godbus/dbus/v5 v5.2.2
|
github.com/godbus/dbus/v5 v5.2.2
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
|
||||||
github.com/pilebones/go-udev v0.9.1
|
github.com/pilebones/go-udev v0.9.1
|
||||||
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
||||||
github.com/yuin/goldmark v1.7.16
|
github.com/yuin/goldmark v1.8.2
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
||||||
golang.org/x/image v0.36.0
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||||
|
golang.org/x/image v0.39.0
|
||||||
|
tailscale.com v1.96.5
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
github.com/akutz/memconn v0.1.0 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/fogleman/gg v1.3.0 // indirect
|
github.com/fogleman/gg v1.3.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
github.com/mdlayher/netlink v1.11.1 // indirect
|
||||||
|
github.com/mdlayher/socket v0.6.0 // indirect
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.3 // indirect
|
github.com/stretchr/objx v0.5.3 // indirect
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
|
golang.org/x/net v0.53.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0
|
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.22
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/afero v1.15.0
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.41.0
|
golang.org/x/sys v0.43.0
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.36.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
158
core/go.sum
158
core/go.sum
@@ -1,14 +1,18 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
||||||
|
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||||
|
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
@@ -24,54 +28,71 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f
|
|||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
|
||||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||||
|
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d h1:QRKpU+9ZBDs62LyBfwhZkJdB5DJX2Sm3p4kUh7l1aA0=
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU=
|
||||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||||
|
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 h1:wH21vHuv323v9x78JNFNJ6P7HEAsdwr9yq2k9/o4zEE=
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
|
||||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
|
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
|
||||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
|
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
@@ -84,12 +105,16 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc=
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
||||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
@@ -101,14 +126,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
|
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
|
||||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/mdlayher/netlink v1.11.1 h1:T136gDS6Gkt+hLncaBwKdW5GpEC8Z0ykqimOebVoal0=
|
||||||
|
github.com/mdlayher/netlink v1.11.1/go.mod h1:ao4LjamyK4Uq9L8+fQzqFYpAncbeCdwbvd9Edv/pYnc=
|
||||||
|
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
|
||||||
|
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
@@ -117,12 +148,13 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
|||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
||||||
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
||||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
@@ -146,6 +178,12 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||||
|
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
|
||||||
|
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||||
@@ -155,37 +193,47 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR
|
|||||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||||
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||||
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
|
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||||
|
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
|
||||||
|
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
|
||||||
|
|||||||
@@ -107,10 +107,6 @@ 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)$
|
||||||
windowrule = float on, match:class ^(zoom)$
|
windowrule = float on, match:class ^(zoom)$
|
||||||
|
|
||||||
# DMS windows floating by default
|
|
||||||
# ! Hyprland doesn't size these windows correctly so disabling by default here
|
|
||||||
# windowrule = float on, match:class ^(org.quickshell)$
|
|
||||||
|
|
||||||
layerrule = no_anim on, match:namespace ^(quickshell)$
|
layerrule = no_anim on, match:namespace ^(quickshell)$
|
||||||
layerrule = no_anim on, match:namespace ^dms:.*
|
layerrule = no_anim on, match:namespace ^dms:.*
|
||||||
|
|
||||||
|
|||||||
@@ -250,12 +250,6 @@ window-rule {
|
|||||||
match app-id="zoom"
|
match app-id="zoom"
|
||||||
open-floating true
|
open-floating true
|
||||||
}
|
}
|
||||||
// Open dms windows as floating by default
|
|
||||||
window-rule {
|
|
||||||
match app-id=r#"org.quickshell$"#
|
|
||||||
match app-id=r#"com.danklinux.dms$"#
|
|
||||||
open-floating true
|
|
||||||
}
|
|
||||||
debug {
|
debug {
|
||||||
honor-xdg-activation-with-invalid-serial
|
honor-xdg-activation-with-invalid-serial
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,8 +208,7 @@ func (a *ArchDistribution) getQuickshellMapping(variant deps.PackageVariant) Pac
|
|||||||
if forceQuickshellGit || variant == deps.VariantGit {
|
if forceQuickshellGit || variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
// ! TODO - for now we're only forcing quickshell-git on ARCH, as other distros use DL repos which pin a newer quickshell
|
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
||||||
@@ -332,6 +331,12 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
|
|||||||
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
|
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if slices.Contains(systemPkgs, "quickshell") && a.packageInstalled("quickshell-git") {
|
||||||
|
if err := a.removeQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove quickshell-git: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: System Packages
|
// Phase 3: System Packages
|
||||||
if len(systemPkgs) > 0 {
|
if len(systemPkgs) > 0 {
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
@@ -449,6 +454,20 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
|
|||||||
return systemPkgs, aurPkgs, manualPkgs, variantMap
|
return systemPkgs, aurPkgs, manualPkgs, variantMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) removeQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.33,
|
||||||
|
Step: "Removing quickshell-git...",
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell-git",
|
||||||
|
LogOutput: "Removing quickshell-git so stable quickshell can be installed",
|
||||||
|
}
|
||||||
|
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell-git")
|
||||||
|
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.33, 0.35)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
if a.packageInstalled("quickshell-git") {
|
if a.packageInstalled("quickshell-git") {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ func (b *BaseDistribution) detectQuickshell() deps.Dependency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
versionStr := string(output)
|
versionStr := string(output)
|
||||||
versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
|
versionRegex := regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
|
||||||
matches := versionRegex.FindStringSubmatch(versionStr)
|
matches := versionRegex.FindStringSubmatch(versionStr)
|
||||||
|
|
||||||
if len(matches) < 2 {
|
if len(matches) < 2 {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package log
|
package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
cblog "github.com/charmbracelet/log"
|
cblog "github.com/charmbracelet/log"
|
||||||
|
"github.com/mattn/go-isatty"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Logger embeds the Charm Logger and adds Printf/Fatalf
|
// Logger embeds the Charm Logger and adds Printf/Fatalf
|
||||||
@@ -21,8 +25,26 @@ func (l *Logger) Fatalf(format string, v ...any) { l.Logger.Fatalf(format, v...)
|
|||||||
var (
|
var (
|
||||||
logger *Logger
|
logger *Logger
|
||||||
initLogger sync.Once
|
initLogger sync.Once
|
||||||
|
|
||||||
|
logMu sync.Mutex
|
||||||
|
logFile *os.File
|
||||||
|
logStderr io.Writer = os.Stderr
|
||||||
|
|
||||||
|
ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ansiStripWriter strips ANSI escape sequences before forwarding to w. Used
|
||||||
|
// for the file sink so colored stderr stays colored while the file stays plain.
|
||||||
|
type ansiStripWriter struct{ w io.Writer }
|
||||||
|
|
||||||
|
func (a *ansiStripWriter) Write(p []byte) (int, error) {
|
||||||
|
stripped := ansiRe.ReplaceAll(p, nil)
|
||||||
|
if _, err := a.w.Write(stripped); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseLogLevel(level string) cblog.Level {
|
func parseLogLevel(level string) cblog.Level {
|
||||||
switch strings.ToLower(level) {
|
switch strings.ToLower(level) {
|
||||||
case "debug":
|
case "debug":
|
||||||
@@ -86,7 +108,7 @@ func GetLogger() *Logger {
|
|||||||
SetString(" DEBUG").
|
SetString(" DEBUG").
|
||||||
Foreground(lipgloss.Color("4"))
|
Foreground(lipgloss.Color("4"))
|
||||||
|
|
||||||
base := cblog.New(os.Stderr)
|
base := cblog.New(logStderr)
|
||||||
base.SetStyles(styles)
|
base.SetStyles(styles)
|
||||||
base.SetReportTimestamp(false)
|
base.SetReportTimestamp(false)
|
||||||
|
|
||||||
@@ -98,10 +120,85 @@ func GetLogger() *Logger {
|
|||||||
base.SetPrefix(" go")
|
base.SetPrefix(" go")
|
||||||
|
|
||||||
logger = &Logger{base}
|
logger = &Logger{base}
|
||||||
|
|
||||||
|
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||||
|
_ = SetLogFile(path)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return logger
|
return logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLevel updates the active log level. Accepts the same strings as
|
||||||
|
// DMS_LOG_LEVEL. Unknown values default to info.
|
||||||
|
func SetLevel(level string) {
|
||||||
|
GetLogger().SetLevel(parseLogLevel(level))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLogFile makes the logger append to path in addition to stderr. Passing an
|
||||||
|
// empty string detaches the file sink. Atomic per-line writes (≤PIPE_BUF) on
|
||||||
|
// O_APPEND keep concurrent Go and QML writers from corrupting each other.
|
||||||
|
//
|
||||||
|
// Color handling: charmbracelet/log auto-detects color support from its
|
||||||
|
// io.Writer, and io.MultiWriter doesn't pass that through, so we force the ANSI
|
||||||
|
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
||||||
|
// the file stays plain while stderr keeps its colors.
|
||||||
|
func SetLogFile(path string) error {
|
||||||
|
logMu.Lock()
|
||||||
|
defer logMu.Unlock()
|
||||||
|
|
||||||
|
if logFile != nil {
|
||||||
|
logFile.Close()
|
||||||
|
logFile = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
l := GetLogger()
|
||||||
|
if path == "" {
|
||||||
|
l.SetOutput(logStderr)
|
||||||
|
applyColorProfile(l, logStderr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logFile = f
|
||||||
|
out := io.MultiWriter(logStderr, &ansiStripWriter{w: f})
|
||||||
|
l.SetOutput(out)
|
||||||
|
applyColorProfile(l, logStderr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyColorProfile forces the renderer's color profile to match what stderr
|
||||||
|
// would produce on its own, undoing the auto-downgrade triggered by wrapping
|
||||||
|
// stderr in a non-TTY writer (e.g. io.MultiWriter).
|
||||||
|
func applyColorProfile(l *Logger, stderr io.Writer) {
|
||||||
|
f, ok := stderr.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
l.SetColorProfile(termenv.Ascii)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isatty.IsTerminal(f.Fd()) {
|
||||||
|
l.SetColorProfile(termenv.ANSI)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.SetColorProfile(termenv.Ascii)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyEnvOverrides re-reads DMS_LOG_LEVEL and DMS_LOG_FILE and reconfigures
|
||||||
|
// the singleton. Safe to call after CLI flags have rewritten the environment.
|
||||||
|
func ApplyEnvOverrides() {
|
||||||
|
GetLogger()
|
||||||
|
if level := os.Getenv("DMS_LOG_LEVEL"); level != "" {
|
||||||
|
SetLevel(level)
|
||||||
|
}
|
||||||
|
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||||
|
if err := SetLogFile(path); err != nil {
|
||||||
|
Warnf("Failed to open log file %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// * Convenience wrappers
|
// * Convenience wrappers
|
||||||
|
|
||||||
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }
|
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ var templateRegistry = []TemplateDef{
|
|||||||
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
||||||
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
||||||
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
||||||
|
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
|
||||||
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
||||||
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
||||||
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,7 +103,7 @@ func GetOutputDir() string {
|
|||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
if xdgPics := getXDGPicturesDir(); xdgPics != "" {
|
if xdgPics := utils.XDGPicturesDir(); xdgPics != "" {
|
||||||
screenshotDir := filepath.Join(xdgPics, "Screenshots")
|
screenshotDir := filepath.Join(xdgPics, "Screenshots")
|
||||||
if err := os.MkdirAll(screenshotDir, 0o755); err == nil {
|
if err := os.MkdirAll(screenshotDir, 0o755); err == nil {
|
||||||
return screenshotDir
|
return screenshotDir
|
||||||
@@ -113,42 +111,12 @@ func GetOutputDir() string {
|
|||||||
return xdgPics
|
return xdgPics
|
||||||
}
|
}
|
||||||
|
|
||||||
if home := os.Getenv("HOME"); home != "" {
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
return home
|
return home
|
||||||
}
|
}
|
||||||
return "."
|
return "."
|
||||||
}
|
}
|
||||||
|
|
||||||
func getXDGPicturesDir() string {
|
|
||||||
userConfigDir, err := os.UserConfigDir()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("failed to get user config dir", "err", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
userDirsFile := filepath.Join(userConfigDir, "user-dirs.dirs")
|
|
||||||
data, err := os.ReadFile(userDirsFile)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
if len(line) == 0 || line[0] == '#' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const prefix = "XDG_PICTURES_DIR="
|
|
||||||
if !strings.HasPrefix(line, prefix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path := strings.Trim(line[len(prefix):], "\"")
|
|
||||||
expanded, err := utils.ExpandPath(path)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return expanded
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func WriteToFile(buf *ShmBuffer, path string, format Format, quality int) error {
|
func WriteToFile(buf *ShmBuffer, path string, format Format, quality int) error {
|
||||||
return WriteToFileWithFormat(buf, path, format, quality, uint32(FormatARGB8888))
|
return WriteToFileWithFormat(buf, path, format, quality, uint32(FormatARGB8888))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,12 +64,13 @@ func SendNotification(result NotifyResult) {
|
|||||||
|
|
||||||
summary := "Screenshot captured"
|
summary := "Screenshot captured"
|
||||||
body := ""
|
body := ""
|
||||||
if result.Clipboard && result.FilePath != "" {
|
switch {
|
||||||
body = fmt.Sprintf("Copied to clipboard\n%s", filepath.Base(result.FilePath))
|
case result.FilePath != "" && result.Clipboard:
|
||||||
} else if result.Clipboard {
|
body = fmt.Sprintf("%s\nCopied to clipboard", filepath.Base(result.FilePath))
|
||||||
body = "Copied to clipboard"
|
case result.FilePath != "":
|
||||||
} else if result.FilePath != "" {
|
|
||||||
body = filepath.Base(result.FilePath)
|
body = filepath.Base(result.FilePath)
|
||||||
|
case result.Clipboard:
|
||||||
|
body = "Copied to clipboard"
|
||||||
}
|
}
|
||||||
|
|
||||||
obj := conn.Object(notifyDest, notifyPath)
|
obj := conn.Object(notifyDest, notifyPath)
|
||||||
|
|||||||
@@ -212,9 +212,10 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var offer any
|
var offer any
|
||||||
if e.Id != nil {
|
switch {
|
||||||
|
case e.Id != nil:
|
||||||
offer = e.Id
|
offer = e.Id
|
||||||
} else if e.OfferId != 0 {
|
case e.OfferId != 0:
|
||||||
m.offerMutex.RLock()
|
m.offerMutex.RLock()
|
||||||
offer = m.offerRegistry[e.OfferId]
|
offer = m.offerRegistry[e.OfferId]
|
||||||
m.offerMutex.RUnlock()
|
m.offerMutex.RUnlock()
|
||||||
@@ -224,10 +225,6 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
wasOwner := m.isOwner
|
wasOwner := m.isOwner
|
||||||
m.ownerLock.Unlock()
|
m.ownerLock.Unlock()
|
||||||
|
|
||||||
if offer == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if wasOwner {
|
if wasOwner {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -236,9 +233,11 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
m.currentOffer = offer
|
m.currentOffer = offer
|
||||||
|
|
||||||
if prevOffer != nil && prevOffer != offer {
|
if prevOffer != nil && prevOffer != offer {
|
||||||
m.offerMutex.Lock()
|
m.releaseOffer(prevOffer)
|
||||||
delete(m.offerMimeTypes, prevOffer)
|
}
|
||||||
m.offerMutex.Unlock()
|
|
||||||
|
if offer == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m.offerMutex.RLock()
|
m.offerMutex.RLock()
|
||||||
@@ -292,6 +291,33 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
log.Info("Data device setup complete")
|
log.Info("Data device setup complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) releaseOffer(offer any) {
|
||||||
|
if offer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
typedOffer, ok := offer.(*ext_data_control.ExtDataControlOfferV1)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.offerMutex.Lock()
|
||||||
|
delete(m.offerMimeTypes, offer)
|
||||||
|
delete(m.offerRegistry, typedOffer.ID())
|
||||||
|
m.offerMutex.Unlock()
|
||||||
|
typedOffer.Destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) releaseCurrentSource() {
|
||||||
|
if m.currentSource == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source, ok := m.currentSource.(*ext_data_control.ExtDataControlSourceV1)
|
||||||
|
m.currentSource = nil
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source.Destroy()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) readAndStore(r *os.File, mimeType string) {
|
func (m *Manager) readAndStore(r *os.File, mimeType string) {
|
||||||
defer r.Close()
|
defer r.Close()
|
||||||
|
|
||||||
@@ -395,7 +421,7 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
|
|||||||
if extractHash(v) != hash {
|
if extractHash(v) != hash {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entry, err := decodeEntry(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err == nil && entry.Pinned {
|
if err == nil && entry.Pinned {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -413,7 +439,7 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
|
|||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
var count int
|
var count int
|
||||||
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
||||||
entry, err := decodeEntry(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err == nil && entry.Pinned {
|
if err == nil && entry.Pinned {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -456,6 +482,14 @@ func encodeEntry(e Entry) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decodeEntry(data []byte) (Entry, error) {
|
func decodeEntry(data []byte) (Entry, error) {
|
||||||
|
return decodeEntryFields(data, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeEntryMeta(data []byte) (Entry, error) {
|
||||||
|
return decodeEntryFields(data, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeEntryFields(data []byte, withData bool) (Entry, error) {
|
||||||
buf := bytes.NewReader(data)
|
buf := bytes.NewReader(data)
|
||||||
var e Entry
|
var e Entry
|
||||||
|
|
||||||
@@ -463,8 +497,15 @@ func decodeEntry(data []byte) (Entry, error) {
|
|||||||
|
|
||||||
var dataLen uint32
|
var dataLen uint32
|
||||||
binary.Read(buf, binary.BigEndian, &dataLen)
|
binary.Read(buf, binary.BigEndian, &dataLen)
|
||||||
e.Data = make([]byte, dataLen)
|
switch {
|
||||||
buf.Read(e.Data)
|
case withData:
|
||||||
|
e.Data = make([]byte, dataLen)
|
||||||
|
buf.Read(e.Data)
|
||||||
|
default:
|
||||||
|
if _, err := buf.Seek(int64(dataLen), io.SeekCurrent); err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var mimeLen uint32
|
var mimeLen uint32
|
||||||
binary.Read(buf, binary.BigEndian, &mimeLen)
|
binary.Read(buf, binary.BigEndian, &mimeLen)
|
||||||
@@ -668,14 +709,9 @@ func sizeStr(size int) string {
|
|||||||
func (m *Manager) updateState() {
|
func (m *Manager) updateState() {
|
||||||
history := m.GetHistory()
|
history := m.GetHistory()
|
||||||
|
|
||||||
for i := range history {
|
|
||||||
history[i].Data = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var current *Entry
|
var current *Entry
|
||||||
if len(history) > 0 {
|
if len(history) > 0 {
|
||||||
c := history[0]
|
c := history[0]
|
||||||
c.Data = nil
|
|
||||||
current = &c
|
current = &c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,7 +786,7 @@ func (m *Manager) GetHistory() []Entry {
|
|||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
|
|
||||||
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
||||||
entry, err := decodeEntry(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -935,7 +971,7 @@ func (m *Manager) ClearHistory() {
|
|||||||
var toDelete [][]byte
|
var toDelete [][]byte
|
||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
entry, err := decodeEntry(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err != nil || !entry.Pinned {
|
if err != nil || !entry.Pinned {
|
||||||
toDelete = append(toDelete, k)
|
toDelete = append(toDelete, k)
|
||||||
}
|
}
|
||||||
@@ -958,7 +994,7 @@ func (m *Manager) ClearHistory() {
|
|||||||
if b != nil {
|
if b != nil {
|
||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
entry, _ := decodeEntry(v)
|
entry, _ := decodeEntryMeta(v)
|
||||||
if entry.Pinned {
|
if entry.Pinned {
|
||||||
pinnedCount++
|
pinnedCount++
|
||||||
}
|
}
|
||||||
@@ -1066,6 +1102,7 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
|||||||
m.ownerLock.Unlock()
|
m.ownerLock.Unlock()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
m.releaseCurrentSource()
|
||||||
m.currentSource = source
|
m.currentSource = source
|
||||||
m.sourceMutex.Lock()
|
m.sourceMutex.Lock()
|
||||||
m.sourceMimeTypes = []string{mimeType}
|
m.sourceMimeTypes = []string{mimeType}
|
||||||
@@ -1145,9 +1182,11 @@ func (m *Manager) Close() {
|
|||||||
m.subscribers = make(map[string]chan State)
|
m.subscribers = make(map[string]chan State)
|
||||||
m.subMutex.Unlock()
|
m.subMutex.Unlock()
|
||||||
|
|
||||||
if m.currentSource != nil {
|
m.releaseCurrentSource()
|
||||||
source := m.currentSource.(*ext_data_control.ExtDataControlSourceV1)
|
|
||||||
source.Destroy()
|
if m.currentOffer != nil {
|
||||||
|
m.releaseOffer(m.currentOffer)
|
||||||
|
m.currentOffer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.dataDevice != nil {
|
if m.dataDevice != nil {
|
||||||
@@ -1191,11 +1230,10 @@ func (m *Manager) clearOldEntries(days int) error {
|
|||||||
var toDelete [][]byte
|
var toDelete [][]byte
|
||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
entry, err := decodeEntry(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Skip pinned entries
|
|
||||||
if entry.Pinned {
|
if entry.Pinned {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1310,7 +1348,7 @@ func (m *Manager) Search(params SearchParams) SearchResult {
|
|||||||
|
|
||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
||||||
entry, err := decodeEntry(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1335,7 +1373,6 @@ func (m *Manager) Search(params SearchParams) SearchResult {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.Data = nil
|
|
||||||
all = append(all, entry)
|
all = append(all, entry)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1510,7 +1547,7 @@ func (m *Manager) PinEntry(id uint64) error {
|
|||||||
}
|
}
|
||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
entry, err := decodeEntry(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err != nil || !entry.Pinned {
|
if err != nil || !entry.Pinned {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1528,7 +1565,6 @@ func (m *Manager) PinEntry(id uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check pinned count
|
|
||||||
cfg := m.getConfig()
|
cfg := m.getConfig()
|
||||||
pinnedCount := 0
|
pinnedCount := 0
|
||||||
if err := m.db.View(func(tx *bolt.Tx) error {
|
if err := m.db.View(func(tx *bolt.Tx) error {
|
||||||
@@ -1538,7 +1574,7 @@ func (m *Manager) PinEntry(id uint64) error {
|
|||||||
}
|
}
|
||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
entry, err := decodeEntry(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err == nil && entry.Pinned {
|
if err == nil && entry.Pinned {
|
||||||
pinnedCount++
|
pinnedCount++
|
||||||
}
|
}
|
||||||
@@ -1629,12 +1665,11 @@ func (m *Manager) GetPinnedEntries() []Entry {
|
|||||||
|
|
||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
||||||
entry, err := decodeEntry(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if entry.Pinned {
|
if entry.Pinned {
|
||||||
entry.Data = nil
|
|
||||||
pinned = append(pinned, entry)
|
pinned = append(pinned, entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1660,7 +1695,7 @@ func (m *Manager) GetPinnedCount() int {
|
|||||||
|
|
||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
entry, err := decodeEntry(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err == nil && entry.Pinned {
|
if err == nil && entry.Pinned {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
@@ -1779,6 +1814,7 @@ func (m *Manager) CopyFile(filePath string) error {
|
|||||||
m.ownerLock.Unlock()
|
m.ownerLock.Unlock()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
m.releaseCurrentSource()
|
||||||
m.currentSource = source
|
m.currentSource = source
|
||||||
|
|
||||||
m.ownerLock.Lock()
|
m.ownerLock.Lock()
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ func (m *Manager) Close() {
|
|||||||
|
|
||||||
func InitializeManager() (*Manager, error) {
|
func InitializeManager() (*Manager, error) {
|
||||||
if os.Getuid() != 0 && !hasInputGroupAccess() {
|
if os.Getuid() != 0 && !hasInputGroupAccess() {
|
||||||
return nil, fmt.Errorf("insufficient permissions to access input devices")
|
return nil, fmt.Errorf("insufficient permissions to access input devices. Add your user to the 'input' group: `sudo usermod -a -G input $USER` or run `dms setup`")
|
||||||
}
|
}
|
||||||
|
|
||||||
return NewManager()
|
return NewManager()
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if reply != dbus.RequestNameReplyPrimaryOwner {
|
if reply != dbus.RequestNameReplyPrimaryOwner {
|
||||||
log.Warnf("Screensaver name %s already owned by another process", name)
|
log.Infof("Screensaver name %s already owned by another process (e.g. hypridle/swayidle)", name)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {
|
if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {
|
||||||
|
|||||||
@@ -35,12 +35,7 @@ type SessionState struct {
|
|||||||
type EventType string
|
type EventType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
EventStateChanged EventType = "state_changed"
|
EventStateChanged EventType = "state_changed"
|
||||||
EventLock EventType = "lock"
|
|
||||||
EventUnlock EventType = "unlock"
|
|
||||||
EventPrepareForSleep EventType = "prepare_for_sleep"
|
|
||||||
EventIdleHintChanged EventType = "idle_hint_changed"
|
|
||||||
EventLockedHintChanged EventType = "locked_hint_changed"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SessionEvent struct {
|
type SessionEvent struct {
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ import (
|
|||||||
|
|
||||||
func TestEventType_Constants(t *testing.T) {
|
func TestEventType_Constants(t *testing.T) {
|
||||||
assert.Equal(t, EventType("state_changed"), EventStateChanged)
|
assert.Equal(t, EventType("state_changed"), EventStateChanged)
|
||||||
assert.Equal(t, EventType("lock"), EventLock)
|
|
||||||
assert.Equal(t, EventType("unlock"), EventUnlock)
|
|
||||||
assert.Equal(t, EventType("prepare_for_sleep"), EventPrepareForSleep)
|
|
||||||
assert.Equal(t, EventType("idle_hint_changed"), EventIdleHintChanged)
|
|
||||||
assert.Equal(t, EventType("locked_hint_changed"), EventLockedHintChanged)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionState_Struct(t *testing.T) {
|
func TestSessionState_Struct(t *testing.T) {
|
||||||
@@ -40,11 +35,11 @@ func TestSessionEvent_Struct(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
event := SessionEvent{
|
event := SessionEvent{
|
||||||
Type: EventLock,
|
Type: EventStateChanged,
|
||||||
Data: state,
|
Data: state,
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, EventLock, event.Type)
|
assert.Equal(t, EventStateChanged, event.Type)
|
||||||
assert.Equal(t, "1", event.Data.SessionID)
|
assert.Equal(t, "1", event.Data.SessionID)
|
||||||
assert.True(t, event.Data.Locked)
|
assert.True(t, event.Data.Locked)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||||
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||||
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
|
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||||
@@ -109,6 +111,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(req.Method, "tailscale.") {
|
||||||
|
if tailscaleManager == nil {
|
||||||
|
models.RespondError(conn, req.ID, "Tailscale not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tailscale.HandleRequest(conn, req, tailscaleManager)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(req.Method, "dwl.") {
|
if strings.HasPrefix(req.Method, "dwl.") {
|
||||||
if dwlManager == nil {
|
if dwlManager == nil {
|
||||||
models.RespondError(conn, req.ID, "dwl manager not initialized")
|
models.RespondError(conn, req.ID, "dwl manager not initialized")
|
||||||
@@ -202,6 +213,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(req.Method, "sysupdate.") {
|
||||||
|
if sysUpdateManager == nil {
|
||||||
|
models.RespondError(conn, req.ID, "sysupdate manager not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sysupdate.HandleRequest(conn, req, sysUpdateManager)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch req.Method {
|
switch req.Method {
|
||||||
case "ping":
|
case "ping":
|
||||||
models.Respond(conn, req.ID, "pong")
|
models.Respond(conn, req.ID, "pong")
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||||
@@ -64,6 +66,7 @@ var waylandManager *wayland.Manager
|
|||||||
var bluezManager *bluez.Manager
|
var bluezManager *bluez.Manager
|
||||||
var appPickerManager *apppicker.Manager
|
var appPickerManager *apppicker.Manager
|
||||||
var cupsManager *cups.Manager
|
var cupsManager *cups.Manager
|
||||||
|
var tailscaleManager *tailscale.Manager
|
||||||
var dwlManager *dwl.Manager
|
var dwlManager *dwl.Manager
|
||||||
var extWorkspaceManager *extworkspace.Manager
|
var extWorkspaceManager *extworkspace.Manager
|
||||||
var brightnessManager *brightness.Manager
|
var brightnessManager *brightness.Manager
|
||||||
@@ -75,6 +78,7 @@ var wlContext *wlcontext.SharedContext
|
|||||||
var themeModeManager *thememode.Manager
|
var themeModeManager *thememode.Manager
|
||||||
var trayRecoveryManager *trayrecovery.Manager
|
var trayRecoveryManager *trayrecovery.Manager
|
||||||
var locationManager *location.Manager
|
var locationManager *location.Manager
|
||||||
|
var sysUpdateManager *sysupdate.Manager
|
||||||
var geoClientInstance geolocation.Client
|
var geoClientInstance geolocation.Client
|
||||||
|
|
||||||
const dbusClientID = "dms-dbus-client"
|
const dbusClientID = "dms-dbus-client"
|
||||||
@@ -421,6 +425,19 @@ func InitializeLocationManager(geoClient geolocation.Client) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func InitializeSysUpdateManager() error {
|
||||||
|
manager, err := sysupdate.NewManager()
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("Failed to initialize sysupdate manager: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sysUpdateManager = manager
|
||||||
|
|
||||||
|
log.Info("Sysupdate manager initialized")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func handleConnection(conn net.Conn) {
|
func handleConnection(conn net.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -474,6 +491,10 @@ func getCapabilities() Capabilities {
|
|||||||
caps = append(caps, "cups")
|
caps = append(caps, "cups")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||||
|
caps = append(caps, "tailscale")
|
||||||
|
}
|
||||||
|
|
||||||
if dwlManager != nil {
|
if dwlManager != nil {
|
||||||
caps = append(caps, "dwl")
|
caps = append(caps, "dwl")
|
||||||
}
|
}
|
||||||
@@ -506,6 +527,10 @@ func getCapabilities() Capabilities {
|
|||||||
caps = append(caps, "dbus")
|
caps = append(caps, "dbus")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sysUpdateManager != nil {
|
||||||
|
caps = append(caps, "sysupdate")
|
||||||
|
}
|
||||||
|
|
||||||
return Capabilities{Capabilities: caps}
|
return Capabilities{Capabilities: caps}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +565,10 @@ func getServerInfo() ServerInfo {
|
|||||||
caps = append(caps, "cups")
|
caps = append(caps, "cups")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||||
|
caps = append(caps, "tailscale")
|
||||||
|
}
|
||||||
|
|
||||||
if dwlManager != nil {
|
if dwlManager != nil {
|
||||||
caps = append(caps, "dwl")
|
caps = append(caps, "dwl")
|
||||||
}
|
}
|
||||||
@@ -576,6 +605,10 @@ func getServerInfo() ServerInfo {
|
|||||||
caps = append(caps, "dbus")
|
caps = append(caps, "dbus")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sysUpdateManager != nil {
|
||||||
|
caps = append(caps, "sysupdate")
|
||||||
|
}
|
||||||
|
|
||||||
return ServerInfo{
|
return ServerInfo{
|
||||||
APIVersion: APIVersion,
|
APIVersion: APIVersion,
|
||||||
CLIVersion: CLIVersion,
|
CLIVersion: CLIVersion,
|
||||||
@@ -1016,6 +1049,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldSubscribe("tailscale") && tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||||
|
wg.Add(1)
|
||||||
|
tailscaleChan := tailscaleManager.Subscribe(clientID + "-tailscale")
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer tailscaleManager.Unsubscribe(clientID + "-tailscale")
|
||||||
|
|
||||||
|
initialState := tailscaleManager.GetState()
|
||||||
|
select {
|
||||||
|
case eventChan <- ServiceEvent{Service: "tailscale", Data: initialState}:
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case state, ok := <-tailscaleChan:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case eventChan <- ServiceEvent{Service: "tailscale", Data: state}:
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
if shouldSubscribe("dwl") && dwlManager != nil {
|
if shouldSubscribe("dwl") && dwlManager != nil {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
dwlChan := dwlManager.Subscribe(clientID + "-dwl")
|
dwlChan := dwlManager.Subscribe(clientID + "-dwl")
|
||||||
@@ -1243,6 +1308,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer sysUpdateManager.Unsubscribe(clientID + "-sysupdate")
|
||||||
|
|
||||||
|
initialState := sysUpdateManager.GetState()
|
||||||
|
select {
|
||||||
|
case eventChan <- ServiceEvent{Service: "sysupdate", Data: initialState}:
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case state, ok := <-sysupdateChan:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case eventChan <- ServiceEvent{Service: "sysupdate", Data: state}:
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
if shouldSubscribe("dbus") && dbusManager != nil {
|
if shouldSubscribe("dbus") && dbusManager != nil {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
|
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
|
||||||
@@ -1348,14 +1445,28 @@ func cleanupManagers() {
|
|||||||
if locationManager != nil {
|
if locationManager != nil {
|
||||||
locationManager.Close()
|
locationManager.Close()
|
||||||
}
|
}
|
||||||
|
if sysUpdateManager != nil {
|
||||||
|
sysUpdateManager.Close()
|
||||||
|
}
|
||||||
if geoClientInstance != nil {
|
if geoClientInstance != nil {
|
||||||
geoClientInstance.Close()
|
geoClientInstance.Close()
|
||||||
}
|
}
|
||||||
|
if tailscaleManager != nil {
|
||||||
|
tailscaleManager.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(printDocs bool) error {
|
func Start(printDocs bool) error {
|
||||||
cleanupStaleSockets()
|
cleanupStaleSockets()
|
||||||
|
|
||||||
|
// Tailscale manager always starts — reconnects internally via WatchIPNBus.
|
||||||
|
// The capability is only advertised once tailscaled is reachable; the
|
||||||
|
// callback wakes capability subscribers so QML clients see it transition.
|
||||||
|
tailscaleManager = tailscale.NewManager("")
|
||||||
|
tailscaleManager.SetAvailabilityCallback(func(bool) {
|
||||||
|
notifyCapabilityChange()
|
||||||
|
})
|
||||||
|
|
||||||
socketPath := GetSocketPath()
|
socketPath := GetSocketPath()
|
||||||
os.Remove(socketPath)
|
os.Remove(socketPath)
|
||||||
|
|
||||||
@@ -1733,6 +1844,10 @@ func Start(printDocs bool) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
if err := InitializeSysUpdateManager(); err != nil {
|
||||||
|
log.Warnf("Sysupdate manager unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("")
|
log.Info("")
|
||||||
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
|
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
|
||||||
|
|
||||||
|
|||||||
96
core/internal/server/sysupdate/backend.go
Normal file
96
core/internal/server/sysupdate/backend.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Backend interface {
|
||||||
|
ID() string
|
||||||
|
DisplayName() string
|
||||||
|
Repo() RepoKind
|
||||||
|
IsAvailable(ctx context.Context) bool
|
||||||
|
NeedsAuth() bool
|
||||||
|
RunsInTerminal() bool
|
||||||
|
CheckUpdates(ctx context.Context) ([]Package, error)
|
||||||
|
Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Selection struct {
|
||||||
|
System Backend
|
||||||
|
Overlay []Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Selection) All() []Backend {
|
||||||
|
if s.System == nil {
|
||||||
|
return s.Overlay
|
||||||
|
}
|
||||||
|
out := make([]Backend, 0, 1+len(s.Overlay))
|
||||||
|
out = append(out, s.System)
|
||||||
|
out = append(out, s.Overlay...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Selection) Info() []BackendInfo {
|
||||||
|
all := s.All()
|
||||||
|
out := make([]BackendInfo, 0, len(all))
|
||||||
|
for _, b := range all {
|
||||||
|
out = append(out, BackendInfo{
|
||||||
|
ID: b.ID(),
|
||||||
|
DisplayName: b.DisplayName(),
|
||||||
|
Repo: b.Repo(),
|
||||||
|
NeedsAuth: b.NeedsAuth(),
|
||||||
|
RunsInTerminal: b.RunsInTerminal(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
registryMu sync.RWMutex
|
||||||
|
systemCandidates []func() Backend
|
||||||
|
overlayCandidate []func() Backend
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterSystemBackend(factory func() Backend) {
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
systemCandidates = append(systemCandidates, factory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterOverlayBackend(factory func() Backend) {
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
overlayCandidate = append(overlayCandidate, factory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Select(ctx context.Context) Selection {
|
||||||
|
registryMu.RLock()
|
||||||
|
sys := append([]func() Backend(nil), systemCandidates...)
|
||||||
|
ov := append([]func() Backend(nil), overlayCandidate...)
|
||||||
|
registryMu.RUnlock()
|
||||||
|
|
||||||
|
var sel Selection
|
||||||
|
for _, factory := range sys {
|
||||||
|
b := factory()
|
||||||
|
if !b.IsAvailable(ctx) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sel.System = b
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, factory := range ov {
|
||||||
|
b := factory()
|
||||||
|
if !b.IsAvailable(ctx) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sel.Overlay = append(sel.Overlay, b)
|
||||||
|
}
|
||||||
|
return sel
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandExists(name string) bool {
|
||||||
|
_, err := exec.LookPath(name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
81
core/internal/server/sysupdate/backend_apt.go
Normal file
81
core/internal/server/sysupdate/backend_apt.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &aptBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
var aptUpgradableLine = regexp.MustCompile(`^([^/]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s+([^\]]+)\]`)
|
||||||
|
|
||||||
|
type aptBackend struct{}
|
||||||
|
|
||||||
|
func (aptBackend) ID() string { return "apt" }
|
||||||
|
func (aptBackend) DisplayName() string { return "APT" }
|
||||||
|
func (aptBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (aptBackend) NeedsAuth() bool { return true }
|
||||||
|
func (aptBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (aptBackend) IsAvailable(_ context.Context) bool {
|
||||||
|
return commandExists("apt") || commandExists("apt-get")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "apt", "list", "--upgradable")
|
||||||
|
cmd.Env = append(cmd.Environ(), "LC_ALL=C")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseAptUpgradable(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
bin := "apt-get"
|
||||||
|
if !commandExists(bin) {
|
||||||
|
bin = "apt"
|
||||||
|
}
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{bin, "upgrade", "--dry-run"}, RunOptions{
|
||||||
|
Env: []string{"DEBIAN_FRONTEND=noninteractive", "LC_ALL=C"},
|
||||||
|
OnLine: onLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(aptBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, aptUpgradeArgv(bin, opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
|
||||||
|
func aptUpgradeArgv(bin string, opts UpgradeOptions) []string {
|
||||||
|
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAptUpgradable(text string) []Package {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pkgs []Package
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := aptUpgradableLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, Package{
|
||||||
|
Name: m[1],
|
||||||
|
Repo: RepoSystem,
|
||||||
|
Backend: "apt",
|
||||||
|
FromVersion: m[3],
|
||||||
|
ToVersion: m[2],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
72
core/internal/server/sysupdate/backend_apt_test.go
Normal file
72
core/internal/server/sysupdate/backend_apt_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseAptUpgradable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "header line only",
|
||||||
|
input: `Listing... Done
|
||||||
|
`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single upgradable",
|
||||||
|
input: `Listing... Done
|
||||||
|
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple architectures and suites",
|
||||||
|
input: `Listing... Done
|
||||||
|
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]
|
||||||
|
libfoo/stable-security 1.0.0-2 amd64 [upgradable from: 1.0.0-1]
|
||||||
|
zsh/testing 5.9-6 arm64 [upgradable from: 5.9-5]`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||||
|
{Name: "libfoo", Repo: RepoSystem, Backend: "apt", FromVersion: "1.0.0-1", ToVersion: "1.0.0-2"},
|
||||||
|
{Name: "zsh", Repo: RepoSystem, Backend: "apt", FromVersion: "5.9-5", ToVersion: "5.9-6"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "package name with hyphens, dots, plus signs",
|
||||||
|
input: `Listing... Done
|
||||||
|
g++/stable 4:13.3.0-1 amd64 [upgradable from: 4:13.2.0-1]
|
||||||
|
libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "g++", Repo: RepoSystem, Backend: "apt", FromVersion: "4:13.2.0-1", ToVersion: "4:13.3.0-1"},
|
||||||
|
{Name: "libsdl2-2.0-0", Repo: RepoSystem, Backend: "apt", FromVersion: "2.28.5+dfsg-1", ToVersion: "2.30.0+dfsg-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-matching lines ignored",
|
||||||
|
input: "WARNING: this is some warning\nbash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]",
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseAptUpgradable(tt.input)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseAptUpgradable() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
136
core/internal/server/sysupdate/backend_dnf.go
Normal file
136
core/internal/server/sysupdate/backend_dnf.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf5"} })
|
||||||
|
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf"} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type dnfBackend struct {
|
||||||
|
bin string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b dnfBackend) ID() string { return b.bin }
|
||||||
|
func (b dnfBackend) DisplayName() string { return strings.ToUpper(b.bin) }
|
||||||
|
func (b dnfBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (b dnfBackend) NeedsAuth() bool { return true }
|
||||||
|
func (b dnfBackend) RunsInTerminal() bool { return false }
|
||||||
|
|
||||||
|
func (b dnfBackend) IsAvailable(ctx context.Context) bool {
|
||||||
|
if !commandExists(b.bin) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if commandExists("rpm-ostree") && ostreeBooted(ctx) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b dnfBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
out, err := dnfListUpgrades(ctx, b.bin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
installed := rpmInstalledVersions(ctx)
|
||||||
|
return parseDnfList(out, b.bin, installed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, dnfUpgradeArgv(b.bin, opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string {
|
||||||
|
return privilegedArgv(opts, bin, "upgrade", "--refresh", "-y")
|
||||||
|
}
|
||||||
|
|
||||||
|
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
|
||||||
|
argv := dnfCheckUpdatesArgv(bin)
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 {
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dnfCheckUpdatesArgv(bin string) []string {
|
||||||
|
subcommand := "check-update"
|
||||||
|
if bin == "dnf5" {
|
||||||
|
subcommand = "check-upgrade"
|
||||||
|
}
|
||||||
|
return []string{bin, subcommand, "--refresh", "--quiet"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpmInstalledVersions(ctx context.Context) map[string]string {
|
||||||
|
out, err := exec.CommandContext(ctx, "rpm", "-qa", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}\n`).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := make(map[string]string)
|
||||||
|
for line := range strings.SplitSeq(string(out), "\n") {
|
||||||
|
name, ver, ok := strings.Cut(line, "\t")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[name] = ver
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDnfList(text, backendID string, installed map[string]string) []Package {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pkgs []Package
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nameArch := fields[0]
|
||||||
|
version := fields[1]
|
||||||
|
dot := strings.LastIndex(nameArch, ".")
|
||||||
|
if dot <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !looksLikeRpmVersion(version) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := nameArch[:dot]
|
||||||
|
pkgs = append(pkgs, Package{
|
||||||
|
Name: nameArch,
|
||||||
|
Repo: RepoSystem,
|
||||||
|
Backend: backendID,
|
||||||
|
FromVersion: installed[name],
|
||||||
|
ToVersion: version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeRpmVersion(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
114
core/internal/server/sysupdate/backend_dnf_test.go
Normal file
114
core/internal/server/sysupdate/backend_dnf_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDnfList(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
backendID string
|
||||||
|
installed map[string]string
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single package with installed cross-ref",
|
||||||
|
input: "bash.x86_64 5.2.40-1.fc41 updates",
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: map[string]string{"bash": "5.2.39-1.fc41"},
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "noarch package and missing installed entry",
|
||||||
|
input: `bash.x86_64 5.2.40-1.fc41 updates
|
||||||
|
fonts-misc.noarch 1.0.5-2.fc41 updates`,
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: map[string]string{"bash": "5.2.39-1.fc41"},
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
|
||||||
|
{Name: "fonts-misc.noarch", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "1.0.5-2.fc41"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips header rows",
|
||||||
|
input: `Available
|
||||||
|
Upgrades
|
||||||
|
bash.x86_64 5.2.40-1.fc41 updates`,
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: nil,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips lines with too few fields",
|
||||||
|
input: "incomplete",
|
||||||
|
backendID: "dnf",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips dnf5 banner / column header lines",
|
||||||
|
input: `Updates available
|
||||||
|
Last metadata expiration check: 0:01:23 ago on Tue Apr 29 14:00:00 2026.
|
||||||
|
Package Version Repository Size
|
||||||
|
bash.x86_64 5.2.40-1.fc41 updates`,
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: nil,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips dnf warning lines while keeping package rows",
|
||||||
|
input: `Failed to expire repository cache in path "/home/user/.cache/libdnf5/updates": cannot open file
|
||||||
|
example-driver.x86_64 2:9.8.7-1.fc99 updates
|
||||||
|
example-tool.noarch 1.2.3^45.gitabcdef-1.fc99 copr`,
|
||||||
|
backendID: "dnf5",
|
||||||
|
installed: map[string]string{
|
||||||
|
"example-driver": "2:9.8.6-1.fc99",
|
||||||
|
"example-tool": "1.2.2^44.gitabcdef-1.fc99",
|
||||||
|
},
|
||||||
|
want: []Package{
|
||||||
|
{Name: "example-driver.x86_64", Repo: RepoSystem, Backend: "dnf5", FromVersion: "2:9.8.6-1.fc99", ToVersion: "2:9.8.7-1.fc99"},
|
||||||
|
{Name: "example-tool.noarch", Repo: RepoSystem, Backend: "dnf5", FromVersion: "1.2.2^44.gitabcdef-1.fc99", ToVersion: "1.2.3^45.gitabcdef-1.fc99"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseDnfList(tt.input, tt.backendID, tt.installed)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseDnfList() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDnfCheckUpdatesArgv(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
bin string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{bin: "dnf5", want: []string{"dnf5", "check-upgrade", "--refresh", "--quiet"}},
|
||||||
|
{bin: "dnf", want: []string{"dnf", "check-update", "--refresh", "--quiet"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.bin, func(t *testing.T) {
|
||||||
|
got := dnfCheckUpdatesArgv(tt.bin)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("dnfCheckUpdatesArgv(%q) = %#v, want %#v", tt.bin, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
172
core/internal/server/sysupdate/backend_flatpak.go
Normal file
172
core/internal/server/sysupdate/backend_flatpak.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterOverlayBackend(func() Backend { return &flatpakBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type flatpakBackend struct{}
|
||||||
|
|
||||||
|
func (flatpakBackend) ID() string { return "flatpak" }
|
||||||
|
func (flatpakBackend) DisplayName() string { return "Flatpak" }
|
||||||
|
func (flatpakBackend) Repo() RepoKind { return RepoFlatpak }
|
||||||
|
func (flatpakBackend) NeedsAuth() bool { return false }
|
||||||
|
func (flatpakBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
|
||||||
|
|
||||||
|
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
// Run `flatpak update`
|
||||||
|
cmd := exec.CommandContext(ctx, "flatpak", "update")
|
||||||
|
cmd.Stdin = strings.NewReader("n\nn\n") // decline up to 2 installation prompts
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 && len(out) > 0 {
|
||||||
|
} else if len(out) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installed := flatpakInstalled(ctx)
|
||||||
|
return parseFlatpakUpdateOutput(string(out), installed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type flatpakInstalledEntry struct {
|
||||||
|
version string
|
||||||
|
branch string
|
||||||
|
}
|
||||||
|
|
||||||
|
func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
|
||||||
|
m := flatpakListInstalled(ctx, false)
|
||||||
|
if m == nil {
|
||||||
|
m = make(map[string]flatpakInstalledEntry)
|
||||||
|
}
|
||||||
|
for k, v := range flatpakListInstalled(ctx, true) {
|
||||||
|
if _, exists := m[k]; !exists {
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func flatpakListInstalled(ctx context.Context, system bool) map[string]flatpakInstalledEntry {
|
||||||
|
args := []string{"flatpak", "list", "--columns=application,version,branch"}
|
||||||
|
if system {
|
||||||
|
args = append(args, "--system")
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, args[0], args[1:]...).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := make(map[string]flatpakInstalledEntry)
|
||||||
|
for line := range strings.SplitSeq(string(out), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
if len(fields) == 0 || fields[0] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appID := fields[0]
|
||||||
|
entry := flatpakInstalledEntry{}
|
||||||
|
if len(fields) > 1 {
|
||||||
|
entry.version = fields[1]
|
||||||
|
}
|
||||||
|
if len(fields) > 2 {
|
||||||
|
entry.branch = fields[2]
|
||||||
|
}
|
||||||
|
key := appID
|
||||||
|
if entry.branch != "" {
|
||||||
|
key = appID + "//" + entry.branch
|
||||||
|
}
|
||||||
|
m[key] = entry
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{"flatpak", "update", "--no-deploy", "-y"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, flatpakUpgradeArgv(), RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
|
||||||
|
func flatpakUpgradeArgv() []string {
|
||||||
|
return []string{"flatpak", "update", "-y", "--noninteractive"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlatpakUpdateOutput(text string, installed map[string]flatpakInstalledEntry) []Package {
|
||||||
|
var pkgs []Package
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
p := parseFlatpakUpdateRow(strings.TrimRight(line, "\r"), installed)
|
||||||
|
if p == nil || seen[p.Ref] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p.Ref] = true
|
||||||
|
pkgs = append(pkgs, *p)
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlatpakUpdateRow(line string, installed map[string]flatpakInstalledEntry) *Package {
|
||||||
|
// Row format: " N.\t<name>\t<appID>\t<branch>\t<op>\t<remote>\t<size>"
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
if len(fields) < 5 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// First field must look like " N." (optional whitespace, digits, period)
|
||||||
|
rowField := strings.TrimSpace(fields[0])
|
||||||
|
if len(rowField) < 2 || rowField[len(rowField)-1] != '.' {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, c := range rowField[:len(rowField)-1] {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appID := strings.TrimSpace(fields[2])
|
||||||
|
branch := strings.TrimSpace(fields[3])
|
||||||
|
op := strings.TrimSpace(fields[4])
|
||||||
|
if appID == "" || op == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch op {
|
||||||
|
case "i", "u", "r": // install, update, reinstall
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := appID
|
||||||
|
if branch != "" {
|
||||||
|
ref = appID + "//" + branch
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(fields[1])
|
||||||
|
if name == "" {
|
||||||
|
name = appID
|
||||||
|
}
|
||||||
|
|
||||||
|
var from string
|
||||||
|
if op != "i" {
|
||||||
|
if inst, ok := installed[ref]; ok {
|
||||||
|
from = inst.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Package{
|
||||||
|
Name: name,
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: from,
|
||||||
|
Ref: ref,
|
||||||
|
}
|
||||||
|
}
|
||||||
107
core/internal/server/sysupdate/backend_flatpak_test.go
Normal file
107
core/internal/server/sysupdate/backend_flatpak_test.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFlatpakUpdateOutput(t *testing.T) {
|
||||||
|
realOutput := "Looking for updates…\n\n\n 1.\t \torg.gtk.Gtk3theme.adw-gtk3-dark\t3.22\ti\tflathub\t< 131.4 kB\n\nProceed with these changes to the system installation? [Y/n]: n\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
installed map[string]flatpakInstalledEntry
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty output",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nothing to do",
|
||||||
|
input: "Looking for updates…\n\nNothing to do.\n",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "real flatpak update output — new install",
|
||||||
|
input: realOutput,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "org.gtk.Gtk3theme.adw-gtk3-dark",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: "",
|
||||||
|
Ref: "org.gtk.Gtk3theme.adw-gtk3-dark//3.22",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update with installed version",
|
||||||
|
input: "Looking for updates…\n\n 1.\tSlack\tcom.slack.Slack\tstable\tu\tflathub\t< 5.2 MB\n\nProceed? [Y/n]: n\n",
|
||||||
|
installed: map[string]flatpakInstalledEntry{
|
||||||
|
"com.slack.Slack//stable": {version: "4.40.0"},
|
||||||
|
},
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "Slack",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: "4.40.0",
|
||||||
|
Ref: "com.slack.Slack//stable",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reinstall op included",
|
||||||
|
input: " 1.\t\torg.freedesktop.Platform\t25.08\tr\tflathub\t< 100 MB\n",
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "org.freedesktop.Platform",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
Ref: "org.freedesktop.Platform//25.08",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown op excluded",
|
||||||
|
input: " 1.\t\torg.freedesktop.Platform\t25.08\te\tflathub\t0\n",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deduplicates same ref",
|
||||||
|
input: " 1.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\n 2.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\n",
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "com.example.App",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
Ref: "com.example.App//stable",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-table lines ignored",
|
||||||
|
input: "Looking for updates…\nSome warning line\nID\tBranch\tOp\n 1.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\nProceed? [Y/n]: n\n",
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "com.example.App",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
Ref: "com.example.App//stable",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseFlatpakUpdateOutput(tt.input, tt.installed)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseFlatpakUpdateOutput() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
253
core/internal/server/sysupdate/backend_pacman.go
Normal file
253
core/internal/server/sysupdate/backend_pacman.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
|
||||||
|
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
|
||||||
|
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
var archUpdateLine = regexp.MustCompile(`^(\S+)\s+(\S+)\s+->\s+(\S+)`)
|
||||||
|
|
||||||
|
type pacmanBackend struct{}
|
||||||
|
|
||||||
|
func (pacmanBackend) ID() string { return "pacman" }
|
||||||
|
func (pacmanBackend) DisplayName() string { return "Pacman" }
|
||||||
|
func (pacmanBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (pacmanBackend) NeedsAuth() bool { return true }
|
||||||
|
func (pacmanBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (pacmanBackend) IsAvailable(_ context.Context) bool { return commandExists("pacman") }
|
||||||
|
|
||||||
|
func (b pacmanBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
out, err := pacmanRepoUpdates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseArchUpdates(out, b.ID(), RepoSystem), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, pacmanUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
||||||
|
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
||||||
|
}
|
||||||
|
|
||||||
|
type archHelperBackend struct {
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b archHelperBackend) ID() string { return b.id }
|
||||||
|
func (b archHelperBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (b archHelperBackend) NeedsAuth() bool { return true }
|
||||||
|
func (b archHelperBackend) RunsInTerminal() bool {
|
||||||
|
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
|
||||||
|
}
|
||||||
|
func (b archHelperBackend) IsAvailable(_ context.Context) bool { return commandExists(b.id) }
|
||||||
|
|
||||||
|
func (b archHelperBackend) DisplayName() string {
|
||||||
|
switch b.id {
|
||||||
|
case "paru":
|
||||||
|
return "Paru (AUR)"
|
||||||
|
case "yay":
|
||||||
|
return "Yay (AUR)"
|
||||||
|
default:
|
||||||
|
return b.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b archHelperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
repoOut, err := pacmanRepoUpdates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgs := parseArchUpdates(repoOut, b.id, RepoSystem)
|
||||||
|
|
||||||
|
aurOut, err := capturePermissive(ctx, b.id, "-Qua")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, parseArchUpdates(aurOut, b.id, RepoAUR)...)
|
||||||
|
return pkgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
|
||||||
|
argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR)...)
|
||||||
|
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
term := findTerminal(opts.Terminal)
|
||||||
|
if term == "" {
|
||||||
|
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||||
|
}
|
||||||
|
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR), " ")
|
||||||
|
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
||||||
|
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
|
||||||
|
func archHelperUpgradeArgv(id string, includeAUR bool) []string {
|
||||||
|
argv := []string{id, "-Syu", "--noconfirm", "--needed"}
|
||||||
|
if !includeAUR {
|
||||||
|
argv = append(argv, "--repo")
|
||||||
|
}
|
||||||
|
return argv
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacmanRepoUpdates(ctx context.Context) (string, error) {
|
||||||
|
if commandExists("checkupdates") {
|
||||||
|
return capturePermissive(ctx, "checkupdates")
|
||||||
|
}
|
||||||
|
if commandExists("fakeroot") {
|
||||||
|
out, err := pacmanCheckViaFakeroot(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
log.Warnf("[sysupdate] fakeroot db refresh failed, falling back to stale pacman -Qu: %v", err)
|
||||||
|
}
|
||||||
|
return capturePermissive(ctx, "pacman", "-Qu")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacmanCheckViaFakeroot(ctx context.Context) (string, error) {
|
||||||
|
dir, err := pacmanPrivateDB()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := seedPacmanDB(dir); err != nil {
|
||||||
|
return "", fmt.Errorf("seed sync db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh := exec.CommandContext(ctx, "fakeroot", "--", "pacman", "-Sy", "--dbpath", dir, "--logfile", "/dev/null", "--disable-sandbox")
|
||||||
|
if out, err := refresh.CombinedOutput(); err != nil {
|
||||||
|
return "", fmt.Errorf("fakeroot pacman -Sy: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return capturePermissive(ctx, "pacman", "-Qu", "--dbpath", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedPacmanDB(dir string) error {
|
||||||
|
syncDir := filepath.Join(dir, "sync")
|
||||||
|
if err := os.MkdirAll(syncDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dbs, err := filepath.Glob("/var/lib/pacman/sync/*.db")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, src := range dbs {
|
||||||
|
if err := copyFile(src, filepath.Join(syncDir, filepath.Base(src))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localLink := filepath.Join(dir, "local")
|
||||||
|
if fi, err := os.Lstat(localLink); err == nil {
|
||||||
|
if fi.Mode()&os.ModeSymlink == 0 {
|
||||||
|
if err := os.RemoveAll(localLink); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.Symlink("/var/lib/pacman/local", localLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacmanPrivateDB() (string, error) {
|
||||||
|
tmp := os.Getenv("TMPDIR")
|
||||||
|
if tmp == "" {
|
||||||
|
tmp = "/tmp"
|
||||||
|
}
|
||||||
|
dir := filepath.Join(tmp, fmt.Sprintf("dms-checkup-db-%d", os.Getuid()))
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func capturePermissive(ctx context.Context, argv ...string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
|
switch exitErr.ExitCode() {
|
||||||
|
case 1, 2:
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pkgs []Package
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := archUpdateLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := Package{
|
||||||
|
Name: m[1],
|
||||||
|
Repo: repo,
|
||||||
|
Backend: backendID,
|
||||||
|
FromVersion: m[2],
|
||||||
|
ToVersion: m[3],
|
||||||
|
}
|
||||||
|
if repo == RepoAUR {
|
||||||
|
p.ChangelogURL = "https://aur.archlinux.org/packages/" + p.Name
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, p)
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
114
core/internal/server/sysupdate/backend_pacman_test.go
Normal file
114
core/internal/server/sysupdate/backend_pacman_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseArchUpdates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
backendID string
|
||||||
|
repo RepoKind
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace only",
|
||||||
|
input: " \n\n \n",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single repo update",
|
||||||
|
input: "bat 0.26.0-1 -> 0.26.1-2",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple updates with epoch versions",
|
||||||
|
input: `cups 2:2.4.18-1 -> 2:2.4.19-1
|
||||||
|
linux 6.18.0-1 -> 6.18.1-1
|
||||||
|
mesa 26.4.0-1 -> 26.4.1-1`,
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "cups", Repo: RepoSystem, Backend: "paru", FromVersion: "2:2.4.18-1", ToVersion: "2:2.4.19-1"},
|
||||||
|
{Name: "linux", Repo: RepoSystem, Backend: "paru", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
|
||||||
|
{Name: "mesa", Repo: RepoSystem, Backend: "paru", FromVersion: "26.4.0-1", ToVersion: "26.4.1-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AUR update with changelog url",
|
||||||
|
input: "google-chrome 147.0.7727.116-1 -> 147.0.7727.137-1",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoAUR,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "google-chrome",
|
||||||
|
Repo: RepoAUR,
|
||||||
|
Backend: "paru",
|
||||||
|
FromVersion: "147.0.7727.116-1",
|
||||||
|
ToVersion: "147.0.7727.137-1",
|
||||||
|
ChangelogURL: "https://aur.archlinux.org/packages/google-chrome",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git package latest-commit marker",
|
||||||
|
input: "niri-git 26.04.r5.ga85b922-1 -> latest-commit",
|
||||||
|
backendID: "yay",
|
||||||
|
repo: RepoAUR,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "niri-git",
|
||||||
|
Repo: RepoAUR,
|
||||||
|
Backend: "yay",
|
||||||
|
FromVersion: "26.04.r5.ga85b922-1",
|
||||||
|
ToVersion: "latest-commit",
|
||||||
|
ChangelogURL: "https://aur.archlinux.org/packages/niri-git",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips lines that don't match arrow format",
|
||||||
|
input: `bat 0.26.0-1 -> 0.26.1-2
|
||||||
|
this is not an update line
|
||||||
|
foo`,
|
||||||
|
backendID: "pacman",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extra whitespace tolerated",
|
||||||
|
input: " bat 0.26.0-1 -> 0.26.1-2 ",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseArchUpdates(tt.input, tt.backendID, tt.repo)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseArchUpdates() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
132
core/internal/server/sysupdate/backend_rpmostree.go
Normal file
132
core/internal/server/sysupdate/backend_rpmostree.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ostreeExitUpdateAvailable = 77
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &rpmOstreeBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpmOstreeBackend struct{}
|
||||||
|
|
||||||
|
func (rpmOstreeBackend) ID() string { return "rpm-ostree" }
|
||||||
|
func (rpmOstreeBackend) DisplayName() string { return "rpm-ostree" }
|
||||||
|
func (rpmOstreeBackend) Repo() RepoKind { return RepoOSTree }
|
||||||
|
func (rpmOstreeBackend) NeedsAuth() bool { return true }
|
||||||
|
func (rpmOstreeBackend) RunsInTerminal() bool { return false }
|
||||||
|
|
||||||
|
func (b rpmOstreeBackend) IsAvailable(ctx context.Context) bool {
|
||||||
|
if !commandExists("rpm-ostree") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ostreeBooted(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ostreeStatus struct {
|
||||||
|
Deployments []ostreeDeployment `json:"deployments"`
|
||||||
|
CachedUpdate *ostreeCached `json:"cached-update"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ostreeDeployment struct {
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Booted bool `json:"booted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ostreeCached struct {
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ostreeBooted(ctx context.Context) bool {
|
||||||
|
cmd := exec.CommandContext(ctx, "rpm-ostree", "status", "--json")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var s ostreeStatus
|
||||||
|
if err := json.Unmarshal(out, &s); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(s.Deployments) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rpmOstreeBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "rpm-ostree", "upgrade", "--check")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||||
|
if !ok || exitErr.ExitCode() != ostreeExitUpdateAvailable {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusOut, err := exec.CommandContext(ctx, "rpm-ostree", "status", "--json").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseRpmOstreeStatus(statusOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRpmOstreeStatus(statusOut []byte) ([]Package, error) {
|
||||||
|
var s ostreeStatus
|
||||||
|
if err := json.Unmarshal(statusOut, &s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.CachedUpdate == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
booted := bootedDeployment(s.Deployments)
|
||||||
|
from := ""
|
||||||
|
if booted != nil {
|
||||||
|
from = booted.Version
|
||||||
|
}
|
||||||
|
if from == s.CachedUpdate.Version {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
name := s.CachedUpdate.Origin
|
||||||
|
if name == "" {
|
||||||
|
name = "system"
|
||||||
|
}
|
||||||
|
return []Package{{
|
||||||
|
Name: name,
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: from,
|
||||||
|
ToVersion: s.CachedUpdate.Version,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootedDeployment(deps []ostreeDeployment) *ostreeDeployment {
|
||||||
|
for i := range deps {
|
||||||
|
if deps[i].Booted {
|
||||||
|
return &deps[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rpmOstreeBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if !BackendHasTargets(rpmOstreeBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, rpmOstreeUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpmOstreeUpgradeArgv(opts UpgradeOptions) []string {
|
||||||
|
argv := []string{"rpm-ostree", "upgrade"}
|
||||||
|
if opts.DryRun {
|
||||||
|
argv = append(argv, "--check")
|
||||||
|
}
|
||||||
|
return argv
|
||||||
|
}
|
||||||
104
core/internal/server/sysupdate/backend_rpmostree_test.go
Normal file
104
core/internal/server/sysupdate/backend_rpmostree_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRpmOstreeStatus(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []Package
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no cached update",
|
||||||
|
input: `{"deployments":[{"version":"39.20240101.0","booted":true}],"cached-update":null}`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cached update available, booted version differs",
|
||||||
|
input: `{
|
||||||
|
"deployments": [
|
||||||
|
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20240101.0", "booted": true},
|
||||||
|
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20231215.0", "booted": false}
|
||||||
|
],
|
||||||
|
"cached-update": {
|
||||||
|
"origin": "fedora:fedora/x86_64/silverblue",
|
||||||
|
"version": "39.20240115.0",
|
||||||
|
"checksum": "abc123"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "fedora:fedora/x86_64/silverblue",
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: "39.20240101.0",
|
||||||
|
ToVersion: "39.20240115.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cached update equals booted version (no real update)",
|
||||||
|
input: `{
|
||||||
|
"deployments": [{"version": "39.20240101.0", "booted": true}],
|
||||||
|
"cached-update": {"origin": "x", "version": "39.20240101.0"}
|
||||||
|
}`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no booted deployment falls back to empty from",
|
||||||
|
input: `{
|
||||||
|
"deployments": [{"version": "39.20240101.0", "booted": false}],
|
||||||
|
"cached-update": {"origin": "fedora:silverblue", "version": "39.20240115.0"}
|
||||||
|
}`,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "fedora:silverblue",
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: "",
|
||||||
|
ToVersion: "39.20240115.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing origin defaults to system",
|
||||||
|
input: `{
|
||||||
|
"deployments": [{"version": "1.0", "booted": true}],
|
||||||
|
"cached-update": {"version": "1.1"}
|
||||||
|
}`,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "system",
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: "1.0",
|
||||||
|
ToVersion: "1.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed JSON",
|
||||||
|
input: `{not json`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseRpmOstreeStatus([]byte(tt.input))
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("parseRpmOstreeStatus() err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseRpmOstreeStatus() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
85
core/internal/server/sysupdate/backend_zypper.go
Normal file
85
core/internal/server/sysupdate/backend_zypper.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &zypperBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type zypperBackend struct{}
|
||||||
|
|
||||||
|
func (zypperBackend) ID() string { return "zypper" }
|
||||||
|
func (zypperBackend) DisplayName() string { return "Zypper" }
|
||||||
|
func (zypperBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (zypperBackend) NeedsAuth() bool { return true }
|
||||||
|
func (zypperBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (zypperBackend) IsAvailable(_ context.Context) bool { return commandExists("zypper") }
|
||||||
|
|
||||||
|
type zypperUpdateList struct {
|
||||||
|
XMLName xml.Name `xml:"stream"`
|
||||||
|
Updates []zypperUpdate `xml:"update-list>update"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zypperUpdate struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Edition string `xml:"edition,attr"`
|
||||||
|
EditionOld string `xml:"edition-old,attr"`
|
||||||
|
Kind string `xml:"kind,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (zypperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "zypper", "--non-interactive", "--xmlout", "list-updates")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
|
switch exitErr.ExitCode() {
|
||||||
|
case 100, 101, 102, 103:
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseZypperXML(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseZypperXML(out []byte) ([]Package, error) {
|
||||||
|
var list zypperUpdateList
|
||||||
|
if err := xml.Unmarshal(out, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgs := make([]Package, 0, len(list.Updates))
|
||||||
|
for _, u := range list.Updates {
|
||||||
|
if u.Kind != "" && u.Kind != "package" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, Package{
|
||||||
|
Name: u.Name,
|
||||||
|
Repo: RepoSystem,
|
||||||
|
Backend: "zypper",
|
||||||
|
FromVersion: u.EditionOld,
|
||||||
|
ToVersion: u.Edition,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(zypperBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, zypperUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
|
||||||
|
func zypperUpgradeArgv(opts UpgradeOptions) []string {
|
||||||
|
return privilegedArgv(opts, "zypper", "--non-interactive", "update")
|
||||||
|
}
|
||||||
80
core/internal/server/sysupdate/backend_zypper_test.go
Normal file
80
core/internal/server/sysupdate/backend_zypper_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseZypperXML(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []Package
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty stream",
|
||||||
|
input: `<?xml version="1.0"?><stream><update-list></update-list></stream>`,
|
||||||
|
want: []Package{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single package update",
|
||||||
|
input: `<?xml version="1.0"?>
|
||||||
|
<stream>
|
||||||
|
<update-list>
|
||||||
|
<update name="zsh" edition="5.9-6" edition-old="5.9-5" kind="package" arch="x86_64">
|
||||||
|
<source url="https://download.opensuse.org/" alias="repo-oss"/>
|
||||||
|
</update>
|
||||||
|
</update-list>
|
||||||
|
</stream>`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "zsh", Repo: RepoSystem, Backend: "zypper", FromVersion: "5.9-5", ToVersion: "5.9-6"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips non-package kinds",
|
||||||
|
input: `<?xml version="1.0"?>
|
||||||
|
<stream>
|
||||||
|
<update-list>
|
||||||
|
<update name="foo" edition="2.0" edition-old="1.0" kind="package"/>
|
||||||
|
<update name="security-patch" edition="1" edition-old="0" kind="patch"/>
|
||||||
|
<update name="bar" edition="3.0" edition-old="2.0" kind="package"/>
|
||||||
|
</update-list>
|
||||||
|
</stream>`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "foo", Repo: RepoSystem, Backend: "zypper", FromVersion: "1.0", ToVersion: "2.0"},
|
||||||
|
{Name: "bar", Repo: RepoSystem, Backend: "zypper", FromVersion: "2.0", ToVersion: "3.0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "treats missing kind as package",
|
||||||
|
input: `<?xml version="1.0"?>
|
||||||
|
<stream><update-list>
|
||||||
|
<update name="kernel" edition="6.18.1-1" edition-old="6.18.0-1"/>
|
||||||
|
</update-list></stream>`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "kernel", Repo: RepoSystem, Backend: "zypper", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed XML returns error",
|
||||||
|
input: `not xml at all`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseZypperXML([]byte(tt.input))
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("parseZypperXML() err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseZypperXML() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
152
core/internal/server/sysupdate/executor.go
Normal file
152
core/internal/server/sysupdate/executor.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RunOptions struct {
|
||||||
|
Env []string
|
||||||
|
OnLine func(string)
|
||||||
|
AttachStdio bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, argv []string, opts RunOptions) error {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return fmt.Errorf("sysupdate.Run: empty argv")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
if len(opts.Env) > 0 {
|
||||||
|
cmd.Env = append(cmd.Environ(), opts.Env...)
|
||||||
|
}
|
||||||
|
if opts.AttachStdio {
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go pump(stdout, opts.OnLine, &wg)
|
||||||
|
go pump(stderr, opts.OnLine, &wg)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
return cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pump(r io.Reader, onLine func(string), wg *sync.WaitGroup) {
|
||||||
|
defer wg.Done()
|
||||||
|
if onLine == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
onLine(scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Capture(ctx context.Context, argv []string) (string, error) {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return "", fmt.Errorf("sysupdate.Capture: empty argv")
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// privescBin returns the binary to use for privilege escalation.
|
||||||
|
// When useSudo is true it auto-detects the best available tool (sudo/doas/run0).
|
||||||
|
// When false it falls back to pkexec for GUI callers.
|
||||||
|
func privescBin(useSudo bool) string {
|
||||||
|
if useSudo {
|
||||||
|
if t, err := privesc.Detect(); err == nil {
|
||||||
|
return t.Name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "pkexec"
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTerminal(override string) string {
|
||||||
|
if override != "" && commandExists(override) {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
if t := os.Getenv("TERMINAL"); t != "" && commandExists(t) {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
for _, t := range []string{"ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"} {
|
||||||
|
if commandExists(t) {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapInTerminal(term, title, shellCmd string) []string {
|
||||||
|
const appID = "dms-sysupdate"
|
||||||
|
banner := fmt.Sprintf(
|
||||||
|
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
||||||
|
title, shellCmd,
|
||||||
|
)
|
||||||
|
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
|
||||||
|
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||||
|
full := export + banner + "; " + shellCmd + "; " + closer
|
||||||
|
|
||||||
|
switch term {
|
||||||
|
case "kitty":
|
||||||
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "alacritty":
|
||||||
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "foot":
|
||||||
|
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||||
|
case "ghostty":
|
||||||
|
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||||
|
case "wezterm":
|
||||||
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "xterm":
|
||||||
|
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "konsole":
|
||||||
|
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
|
||||||
|
case "gnome-terminal":
|
||||||
|
return []string{term, "--title=" + title, "--", "sh", "-c", full}
|
||||||
|
default:
|
||||||
|
return []string{term, "-e", "sh", "-c", full}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
core/internal/server/sysupdate/handlers.go
Normal file
55
core/internal/server/sysupdate/handlers.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
|
||||||
|
switch req.Method {
|
||||||
|
case "sysupdate.getState":
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
case "sysupdate.refresh":
|
||||||
|
force := params.BoolOpt(req.Params, "force", false)
|
||||||
|
m.Refresh(RefreshOptions{Force: force})
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
case "sysupdate.upgrade":
|
||||||
|
handleUpgrade(conn, req, m)
|
||||||
|
case "sysupdate.cancel":
|
||||||
|
m.Cancel()
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
case "sysupdate.acquire":
|
||||||
|
m.Acquire()
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||||
|
case "sysupdate.release":
|
||||||
|
m.Release()
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||||
|
case "sysupdate.setInterval":
|
||||||
|
seconds, err := params.Int(req.Params, "seconds")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.SetInterval(seconds)
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
default:
|
||||||
|
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
|
||||||
|
opts := UpgradeOptions{
|
||||||
|
IncludeFlatpak: params.BoolOpt(req.Params, "includeFlatpak", true),
|
||||||
|
IncludeAUR: params.BoolOpt(req.Params, "includeAUR", true),
|
||||||
|
DryRun: params.BoolOpt(req.Params, "dry", false),
|
||||||
|
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
||||||
|
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
||||||
|
}
|
||||||
|
if err := m.Upgrade(opts); err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
}
|
||||||
527
core/internal/server/sysupdate/manager.go
Normal file
527
core/internal/server/sysupdate/manager.go
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultIntervalSeconds = 30 * 60
|
||||||
|
minIntervalSeconds = 5 * 60
|
||||||
|
recentLogCapacity = 200
|
||||||
|
checkTimeout = 5 * time.Minute
|
||||||
|
upgradeTimeout = 30 * time.Minute
|
||||||
|
postUpgradeCompleteDelay = 3 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
state State
|
||||||
|
subscribers syncmap.Map[string, chan State]
|
||||||
|
|
||||||
|
selection Selection
|
||||||
|
|
||||||
|
notifyDirty chan struct{}
|
||||||
|
stopChan chan struct{}
|
||||||
|
notifierWG sync.WaitGroup
|
||||||
|
schedulerWG sync.WaitGroup
|
||||||
|
|
||||||
|
acquireCount int32
|
||||||
|
wakeSched chan struct{}
|
||||||
|
|
||||||
|
refreshSerial sync.Mutex
|
||||||
|
|
||||||
|
opMu sync.Mutex
|
||||||
|
opCtx context.Context
|
||||||
|
opCancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager() (*Manager, error) {
|
||||||
|
m := &Manager{
|
||||||
|
notifyDirty: make(chan struct{}, 1),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
wakeSched: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
m.state = State{
|
||||||
|
Phase: PhaseIdle,
|
||||||
|
IntervalSeconds: defaultIntervalSeconds,
|
||||||
|
Backends: []BackendInfo{},
|
||||||
|
Packages: []Package{},
|
||||||
|
}
|
||||||
|
|
||||||
|
id, pretty := readOSRelease()
|
||||||
|
m.state.Distro = id
|
||||||
|
m.state.DistroPretty = pretty
|
||||||
|
|
||||||
|
m.selection = Select(context.Background())
|
||||||
|
m.state.Backends = m.selection.Info()
|
||||||
|
if len(m.state.Backends) == 0 {
|
||||||
|
m.state.Error = &ErrorInfo{
|
||||||
|
Code: ErrCodeNoBackend,
|
||||||
|
Message: "no supported package manager found",
|
||||||
|
Hint: "install a supported package manager (pacman, dnf, apt, zypper) or flatpak",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.notifierWG.Add(1)
|
||||||
|
go m.notifier()
|
||||||
|
|
||||||
|
m.schedulerWG.Add(1)
|
||||||
|
go m.scheduler()
|
||||||
|
|
||||||
|
go m.runRefresh(context.Background())
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetState() State {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return cloneState(m.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Subscribe(id string) chan State {
|
||||||
|
ch := make(chan State, 16)
|
||||||
|
m.subscribers.Store(id, ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Unsubscribe(id string) {
|
||||||
|
if val, ok := m.subscribers.LoadAndDelete(id); ok {
|
||||||
|
close(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
close(m.stopChan)
|
||||||
|
}
|
||||||
|
m.opMu.Lock()
|
||||||
|
if m.opCancel != nil {
|
||||||
|
m.opCancel()
|
||||||
|
}
|
||||||
|
m.opMu.Unlock()
|
||||||
|
select {
|
||||||
|
case m.wakeSched <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
m.schedulerWG.Wait()
|
||||||
|
m.notifierWG.Wait()
|
||||||
|
m.subscribers.Range(func(key string, ch chan State) bool {
|
||||||
|
close(ch)
|
||||||
|
m.subscribers.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetInterval(seconds int) {
|
||||||
|
if seconds < minIntervalSeconds {
|
||||||
|
seconds = minIntervalSeconds
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.IntervalSeconds = seconds
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Refresh(opts RefreshOptions) {
|
||||||
|
m.mu.RLock()
|
||||||
|
phase := m.state.Phase
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case phase == PhaseUpgrading:
|
||||||
|
return
|
||||||
|
case phase == PhaseRefreshing && !opts.Force:
|
||||||
|
m.refreshSerial.Lock()
|
||||||
|
m.refreshSerial.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Upgrade(opts UpgradeOptions) error {
|
||||||
|
if len(m.selection.All()) == 0 {
|
||||||
|
return errors.New("no backend available")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.opMu.Lock()
|
||||||
|
if m.opCancel != nil {
|
||||||
|
m.opMu.Unlock()
|
||||||
|
return errors.New("operation already running")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), upgradeTimeout)
|
||||||
|
m.opCtx = ctx
|
||||||
|
m.opCancel = cancel
|
||||||
|
m.opMu.Unlock()
|
||||||
|
|
||||||
|
go m.runUpgrade(ctx, opts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Cancel() {
|
||||||
|
m.opMu.Lock()
|
||||||
|
cancel := m.opCancel
|
||||||
|
m.opMu.Unlock()
|
||||||
|
if cancel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Acquire() {
|
||||||
|
first := atomic.AddInt32(&m.acquireCount, 1) == 1
|
||||||
|
select {
|
||||||
|
case m.wakeSched <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if first {
|
||||||
|
go m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Release() {
|
||||||
|
if atomic.AddInt32(&m.acquireCount, -1) < 0 {
|
||||||
|
atomic.StoreInt32(&m.acquireCount, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) scheduler() {
|
||||||
|
defer m.schedulerWG.Done()
|
||||||
|
for {
|
||||||
|
if atomic.LoadInt32(&m.acquireCount) == 0 {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
case <-m.wakeSched:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
interval := m.state.IntervalSeconds
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if interval < minIntervalSeconds {
|
||||||
|
interval = minIntervalSeconds
|
||||||
|
}
|
||||||
|
t := time.NewTimer(time.Duration(interval) * time.Second)
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
t.Stop()
|
||||||
|
return
|
||||||
|
case <-m.wakeSched:
|
||||||
|
t.Stop()
|
||||||
|
case <-t.C:
|
||||||
|
m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) runRefresh(parent context.Context) {
|
||||||
|
m.refreshSerial.Lock()
|
||||||
|
defer m.refreshSerial.Unlock()
|
||||||
|
|
||||||
|
if len(m.selection.All()) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(parent, checkTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.state.Phase == PhaseUpgrading {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.state.Phase = PhaseRefreshing
|
||||||
|
m.state.Error = nil
|
||||||
|
m.state.RecentLog = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
|
||||||
|
type backendResult struct {
|
||||||
|
pkgs []Package
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
backends := m.selection.All()
|
||||||
|
results := make([]backendResult, len(backends))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, b := range backends {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, b Backend) {
|
||||||
|
defer wg.Done()
|
||||||
|
pkgs, err := b.CheckUpdates(ctx)
|
||||||
|
results[i] = backendResult{pkgs: pkgs, err: err}
|
||||||
|
}(i, b)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.LastCheckUnix = now
|
||||||
|
m.state.Packages = m.state.Packages[:0]
|
||||||
|
var firstErr error
|
||||||
|
for i, r := range results {
|
||||||
|
if r.err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.state.Packages = append(m.state.Packages, r.pkgs...)
|
||||||
|
}
|
||||||
|
m.state.Count = len(m.state.Packages)
|
||||||
|
if firstErr != nil {
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
|
||||||
|
} else {
|
||||||
|
m.state.Phase = PhaseIdle
|
||||||
|
m.state.LastSuccessUnix = now
|
||||||
|
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||||
|
defer func() {
|
||||||
|
m.opMu.Lock()
|
||||||
|
if m.opCancel != nil {
|
||||||
|
m.opCancel = nil
|
||||||
|
m.opCtx = nil
|
||||||
|
}
|
||||||
|
m.opMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if opts.CustomCommand != "" {
|
||||||
|
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.Targets) == 0 {
|
||||||
|
m.mu.RLock()
|
||||||
|
opts.Targets = append([]Package(nil), m.state.Packages...)
|
||||||
|
m.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
backends := upgradeBackends(m.selection, opts)
|
||||||
|
if len(backends) == 0 {
|
||||||
|
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseUpgrading
|
||||||
|
m.state.OperationID = opID
|
||||||
|
m.state.OperationStarted = time.Now().Unix()
|
||||||
|
m.state.RecentLog = m.state.RecentLog[:0]
|
||||||
|
m.state.Error = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
|
||||||
|
onLine := func(line string) { m.appendLog(line) }
|
||||||
|
for _, b := range backends {
|
||||||
|
m.appendLog(fmt.Sprintf("== %s ==", b.DisplayName()))
|
||||||
|
if err := b.Upgrade(ctx, opts, onLine); err != nil {
|
||||||
|
code := ErrCodeBackendFailed
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
code = ErrCodeTimeout
|
||||||
|
} else if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
code = ErrCodeCancelled
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: code, Message: fmt.Sprintf("%s: %v", b.ID(), err)}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.finishSuccessfulUpgrade(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
|
||||||
|
term := findTerminal(terminalOverride)
|
||||||
|
if term == "" {
|
||||||
|
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseUpgrading
|
||||||
|
m.state.OperationID = opID
|
||||||
|
m.state.OperationStarted = time.Now().Unix()
|
||||||
|
m.state.RecentLog = m.state.RecentLog[:0]
|
||||||
|
m.state.Error = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
|
||||||
|
onLine := func(line string) { m.appendLog(line) }
|
||||||
|
argv := wrapInTerminal(term, "DMS — System Update (custom)", command)
|
||||||
|
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
|
||||||
|
code := ErrCodeBackendFailed
|
||||||
|
switch {
|
||||||
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||||
|
code = ErrCodeTimeout
|
||||||
|
case errors.Is(ctx.Err(), context.Canceled):
|
||||||
|
code = ErrCodeCancelled
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.finishSuccessfulUpgrade(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
|
||||||
|
m.appendLog("Upgrade complete.")
|
||||||
|
|
||||||
|
timer := time.NewTimer(postUpgradeCompleteDelay)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseIdle
|
||||||
|
m.state.OperationID = ""
|
||||||
|
m.state.OperationStarted = 0
|
||||||
|
if clearPackages {
|
||||||
|
m.state.Packages = m.state.Packages[:0]
|
||||||
|
m.state.Count = 0
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
go m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
|
||||||
|
var out []Backend
|
||||||
|
if sel.System != nil {
|
||||||
|
out = appendUpgradeBackend(out, sel.System, opts)
|
||||||
|
}
|
||||||
|
for _, b := range sel.Overlay {
|
||||||
|
switch {
|
||||||
|
case b.Repo() == RepoFlatpak && !opts.IncludeFlatpak:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = appendUpgradeBackend(out, b, opts)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUpgradeBackend(out []Backend, b Backend, opts UpgradeOptions) []Backend {
|
||||||
|
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return append(out, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) appendLog(line string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if cap(m.state.RecentLog) == 0 {
|
||||||
|
m.state.RecentLog = make([]string, 0, recentLogCapacity)
|
||||||
|
}
|
||||||
|
if len(m.state.RecentLog) >= recentLogCapacity {
|
||||||
|
copy(m.state.RecentLog, m.state.RecentLog[1:])
|
||||||
|
m.state.RecentLog = m.state.RecentLog[:recentLogCapacity-1]
|
||||||
|
}
|
||||||
|
m.state.RecentLog = append(m.state.RecentLog, line)
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) setError(code ErrorCode, msg string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: code, Message: msg}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) markDirty() {
|
||||||
|
select {
|
||||||
|
case m.notifyDirty <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) notifier() {
|
||||||
|
defer m.notifierWG.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
case <-m.notifyDirty:
|
||||||
|
snap := m.GetState()
|
||||||
|
m.subscribers.Range(func(key string, ch chan State) bool {
|
||||||
|
select {
|
||||||
|
case ch <- snap:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneState(s State) State {
|
||||||
|
out := s
|
||||||
|
out.Backends = append([]BackendInfo(nil), s.Backends...)
|
||||||
|
out.Packages = append([]Package(nil), s.Packages...)
|
||||||
|
out.RecentLog = append([]string(nil), s.RecentLog...)
|
||||||
|
if s.Error != nil {
|
||||||
|
errCopy := *s.Error
|
||||||
|
out.Error = &errCopy
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func readOSRelease() (id, pretty string) {
|
||||||
|
f, err := os.Open("/etc/os-release")
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
k, v, ok := strings.Cut(scanner.Text(), "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v = strings.Trim(v, "\"")
|
||||||
|
switch k {
|
||||||
|
case "ID":
|
||||||
|
id = v
|
||||||
|
case "PRETTY_NAME":
|
||||||
|
pretty = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
log.Debugf("[sysupdate] read os-release: %v", err)
|
||||||
|
}
|
||||||
|
return id, pretty
|
||||||
|
}
|
||||||
60
core/internal/server/sysupdate/targets.go
Normal file
60
core/internal/server/sysupdate/targets.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
func BackendHasTargets(b Backend, targets []Package, includeAUR, includeFlatpak bool) bool {
|
||||||
|
if b == nil || len(targets) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
id := b.ID()
|
||||||
|
repo := b.Repo()
|
||||||
|
for _, p := range targets {
|
||||||
|
switch p.Repo {
|
||||||
|
case RepoFlatpak:
|
||||||
|
if !includeFlatpak {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case RepoAUR:
|
||||||
|
if !includeAUR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch repo {
|
||||||
|
case RepoFlatpak:
|
||||||
|
if p.Repo == RepoFlatpak || p.Backend == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case RepoOSTree:
|
||||||
|
if p.Repo == RepoOSTree || p.Backend == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if p.Backend == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpgradeNeedsPrivilege(backends []Backend, targets []Package, opts UpgradeOptions) bool {
|
||||||
|
if opts.DryRun {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, b := range backends {
|
||||||
|
if b == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.NeedsAuth() && BackendHasTargets(b, targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func privilegedArgv(opts UpgradeOptions, argv ...string) []string {
|
||||||
|
privesc := privescBin(opts.UseSudo)
|
||||||
|
out := make([]string, 0, len(argv)+1)
|
||||||
|
out = append(out, privesc)
|
||||||
|
out = append(out, argv...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
88
core/internal/server/sysupdate/types.go
Normal file
88
core/internal/server/sysupdate/types.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
type Phase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhaseIdle Phase = "idle"
|
||||||
|
PhaseRefreshing Phase = "refreshing"
|
||||||
|
PhaseUpgrading Phase = "upgrading"
|
||||||
|
PhaseError Phase = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepoKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RepoSystem RepoKind = "system"
|
||||||
|
RepoAUR RepoKind = "aur"
|
||||||
|
RepoFlatpak RepoKind = "flatpak"
|
||||||
|
RepoOSTree RepoKind = "ostree"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrCodeNone ErrorCode = ""
|
||||||
|
ErrCodeNoBackend ErrorCode = "no-backend"
|
||||||
|
ErrCodeBusy ErrorCode = "busy"
|
||||||
|
ErrCodeBackendFailed ErrorCode = "backend-failed"
|
||||||
|
ErrCodeTimeout ErrorCode = "timeout"
|
||||||
|
ErrCodeCancelled ErrorCode = "cancelled"
|
||||||
|
ErrCodeInvalidRequest ErrorCode = "invalid-request"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Package struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Repo RepoKind `json:"repo"`
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
FromVersion string `json:"fromVersion,omitempty"`
|
||||||
|
ToVersion string `json:"toVersion,omitempty"`
|
||||||
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||||
|
ChangelogURL string `json:"changelogUrl,omitempty"`
|
||||||
|
Ref string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackendInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Repo RepoKind `json:"repo"`
|
||||||
|
NeedsAuth bool `json:"needsAuth"`
|
||||||
|
RunsInTerminal bool `json:"runsInTerminal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorInfo struct {
|
||||||
|
Code ErrorCode `json:"code,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
Phase Phase `json:"phase"`
|
||||||
|
Distro string `json:"distro,omitempty"`
|
||||||
|
DistroPretty string `json:"distroPretty,omitempty"`
|
||||||
|
Backends []BackendInfo `json:"backends"`
|
||||||
|
Packages []Package `json:"packages"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
IntervalSeconds int `json:"intervalSeconds"`
|
||||||
|
LastCheckUnix int64 `json:"lastCheckUnix,omitempty"`
|
||||||
|
LastSuccessUnix int64 `json:"lastSuccessUnix,omitempty"`
|
||||||
|
NextCheckUnix int64 `json:"nextCheckUnix,omitempty"`
|
||||||
|
OperationID string `json:"operationId,omitempty"`
|
||||||
|
OperationStarted int64 `json:"operationStartedUnix,omitempty"`
|
||||||
|
RecentLog []string `json:"recentLog,omitempty"`
|
||||||
|
Error *ErrorInfo `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpgradeOptions struct {
|
||||||
|
IncludeFlatpak bool
|
||||||
|
IncludeAUR bool
|
||||||
|
DryRun bool
|
||||||
|
UseSudo bool
|
||||||
|
AttachStdio bool
|
||||||
|
CustomCommand string
|
||||||
|
Terminal string
|
||||||
|
Targets []Package
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshOptions struct {
|
||||||
|
Force bool
|
||||||
|
}
|
||||||
147
core/internal/server/sysupdate/upgrade_commands_test.go
Normal file
147
core/internal/server/sysupdate/upgrade_commands_test.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpgradeCommandBuilders(t *testing.T) {
|
||||||
|
pkexecOpts := UpgradeOptions{UseSudo: false}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "dnf full upgrade",
|
||||||
|
got: dnfUpgradeArgv("dnf5", pkexecOpts),
|
||||||
|
want: []string{"pkexec", "dnf5", "upgrade", "--refresh", "-y"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "apt full upgrade",
|
||||||
|
got: aptUpgradeArgv("apt-get", pkexecOpts),
|
||||||
|
want: []string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get", "upgrade", "-y"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zypper full update",
|
||||||
|
got: zypperUpgradeArgv(pkexecOpts),
|
||||||
|
want: []string{"pkexec", "zypper", "--non-interactive", "update"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pacman full sync upgrade",
|
||||||
|
got: pacmanUpgradeArgv(pkexecOpts),
|
||||||
|
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "aur helper full update with aur",
|
||||||
|
got: archHelperUpgradeArgv("paru", true),
|
||||||
|
want: []string{"paru", "-Syu", "--noconfirm", "--needed"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "aur helper repo-only full update",
|
||||||
|
got: archHelperUpgradeArgv("yay", false),
|
||||||
|
want: []string{"yay", "-Syu", "--noconfirm", "--needed", "--repo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flatpak full update",
|
||||||
|
got: flatpakUpgradeArgv(),
|
||||||
|
want: []string{"flatpak", "update", "-y", "--noninteractive"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rpm-ostree upgrade",
|
||||||
|
got: rpmOstreeUpgradeArgv(UpgradeOptions{}),
|
||||||
|
want: []string{"rpm-ostree", "upgrade"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rpm-ostree check",
|
||||||
|
got: rpmOstreeUpgradeArgv(UpgradeOptions{DryRun: true}),
|
||||||
|
want: []string{"rpm-ostree", "upgrade", "--check"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if !reflect.DeepEqual(tt.got, tt.want) {
|
||||||
|
t.Fatalf("argv = %#v, want %#v", tt.got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendHasTargetsRespectsBackendAndOptions(t *testing.T) {
|
||||||
|
targets := []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
|
||||||
|
{Name: "google-chrome", Repo: RepoAUR, Backend: "paru"},
|
||||||
|
{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"},
|
||||||
|
{Name: "silverblue", Repo: RepoOSTree, Backend: "rpm-ostree"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !BackendHasTargets(dnfBackend{bin: "dnf5"}, targets, true, true) {
|
||||||
|
t.Fatal("dnf5 target was not detected")
|
||||||
|
}
|
||||||
|
if BackendHasTargets(dnfBackend{bin: "dnf"}, targets, true, true) {
|
||||||
|
t.Fatal("dnf target should not match dnf5 package targets")
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(archHelperBackend{id: "paru"}, targets, true, true) {
|
||||||
|
t.Fatal("AUR helper target was not detected")
|
||||||
|
}
|
||||||
|
if BackendHasTargets(archHelperBackend{id: "paru"}, targets, false, true) {
|
||||||
|
t.Fatal("AUR helper should not match AUR-only target when AUR is disabled")
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(flatpakBackend{}, targets, true, true) {
|
||||||
|
t.Fatal("Flatpak target was not detected")
|
||||||
|
}
|
||||||
|
if BackendHasTargets(flatpakBackend{}, targets, true, false) {
|
||||||
|
t.Fatal("Flatpak target should not match when Flatpak is disabled")
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(rpmOstreeBackend{}, targets, true, true) {
|
||||||
|
t.Fatal("rpm-ostree target was not detected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeNeedsPrivilegeSkipsFlatpakOnly(t *testing.T) {
|
||||||
|
backends := []Backend{dnfBackend{bin: "dnf5"}, flatpakBackend{}}
|
||||||
|
opts := UpgradeOptions{IncludeAUR: true, IncludeFlatpak: true}
|
||||||
|
|
||||||
|
flatpakOnly := []Package{{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"}}
|
||||||
|
if UpgradeNeedsPrivilege(backends, flatpakOnly, opts) {
|
||||||
|
t.Fatal("Flatpak-only updates should not need privileged auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
mixed := []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
|
||||||
|
{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"},
|
||||||
|
}
|
||||||
|
if !UpgradeNeedsPrivilege(backends, mixed, opts) {
|
||||||
|
t.Fatal("mixed system updates should need privileged auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.DryRun = true
|
||||||
|
if UpgradeNeedsPrivilege(backends, mixed, opts) {
|
||||||
|
t.Fatal("dry-run updates should not need privileged auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeBackendsFiltersFlatpakOnly(t *testing.T) {
|
||||||
|
sel := Selection{
|
||||||
|
System: dnfBackend{bin: "dnf5"},
|
||||||
|
Overlay: []Backend{flatpakBackend{}},
|
||||||
|
}
|
||||||
|
opts := UpgradeOptions{
|
||||||
|
IncludeAUR: true,
|
||||||
|
IncludeFlatpak: true,
|
||||||
|
Targets: []Package{{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := upgradeBackends(sel, opts)
|
||||||
|
if len(got) != 1 || got[0].ID() != "flatpak" {
|
||||||
|
t.Fatalf("upgradeBackends(flatpak-only) = %#v, want only flatpak", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.Targets = append(opts.Targets, Package{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"})
|
||||||
|
got = upgradeBackends(sel, opts)
|
||||||
|
if len(got) != 2 || got[0].ID() != "dnf5" || got[1].ID() != "flatpak" {
|
||||||
|
t.Fatalf("upgradeBackends(mixed) = %#v, want dnf5 then flatpak", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
135
core/internal/server/tailscale/client.go
Normal file
135
core/internal/server/tailscale/client.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// convertStatus converts an ipnstate.Status into our TailscaleState IPC type.
|
||||||
|
func convertStatus(status *ipnstate.Status) *TailscaleState {
|
||||||
|
connected := status.BackendState == "Running"
|
||||||
|
|
||||||
|
state := &TailscaleState{
|
||||||
|
Connected: connected,
|
||||||
|
BackendState: status.BackendState,
|
||||||
|
Version: status.Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.CurrentTailnet != nil {
|
||||||
|
state.TailnetName = status.CurrentTailnet.Name
|
||||||
|
state.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
users := status.User
|
||||||
|
|
||||||
|
if status.Self != nil {
|
||||||
|
state.Self = convertPeerStatus(status.Self, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(status.Peer) > 0 {
|
||||||
|
peers := make([]Peer, 0, len(status.Peer))
|
||||||
|
for _, ps := range status.Peer {
|
||||||
|
peers = append(peers, convertPeerStatus(ps, users))
|
||||||
|
}
|
||||||
|
sort.Slice(peers, func(i, j int) bool {
|
||||||
|
if peers[i].Online != peers[j].Online {
|
||||||
|
return peers[i].Online
|
||||||
|
}
|
||||||
|
return strings.ToLower(peers[i].Hostname) < strings.ToLower(peers[j].Hostname)
|
||||||
|
})
|
||||||
|
state.Peers = peers
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertPeerStatus converts an ipnstate.PeerStatus into our Peer IPC type.
|
||||||
|
func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg.UserProfile) Peer {
|
||||||
|
dnsName := strings.TrimSuffix(ps.DNSName, ".")
|
||||||
|
|
||||||
|
// DNSName first label is unique per node; OS HostName is not.
|
||||||
|
hostname := ps.HostName
|
||||||
|
if dnsName != "" {
|
||||||
|
parts := strings.SplitN(dnsName, ".", 2)
|
||||||
|
if len(parts) > 0 && parts[0] != "" {
|
||||||
|
hostname = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peer := Peer{
|
||||||
|
ID: string(ps.ID),
|
||||||
|
Hostname: hostname,
|
||||||
|
DNSName: dnsName,
|
||||||
|
OS: ps.OS,
|
||||||
|
Online: ps.Online,
|
||||||
|
Active: ps.Active,
|
||||||
|
ExitNode: ps.ExitNode,
|
||||||
|
Relay: ps.Relay,
|
||||||
|
RxBytes: ps.RxBytes,
|
||||||
|
TxBytes: ps.TxBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ip := range ps.TailscaleIPs {
|
||||||
|
if ip.Is4() {
|
||||||
|
if peer.TailscaleIP == "" {
|
||||||
|
peer.TailscaleIP = ip.String()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if peer.TailscaleIPv6 == "" {
|
||||||
|
peer.TailscaleIPv6 = ip.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.Tags != nil {
|
||||||
|
peer.Tags = ps.Tags.AsSlice()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.UserID > 0 {
|
||||||
|
if user, ok := users[ps.UserID]; ok {
|
||||||
|
peer.Owner = user.LoginName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ps.LastSeen.IsZero() {
|
||||||
|
peer.LastSeen = formatRelativeTime(ps.LastSeen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRelativeTime formats a time as a human-readable relative duration (e.g., "5 minutes ago").
|
||||||
|
func formatRelativeTime(t time.Time) string {
|
||||||
|
d := time.Since(t)
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "just now"
|
||||||
|
case d < time.Hour:
|
||||||
|
m := int(d.Minutes())
|
||||||
|
if m == 1 {
|
||||||
|
return "1 minute ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d minutes ago", m)
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
h := int(d.Hours())
|
||||||
|
if h == 1 {
|
||||||
|
return "1 hour ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hours ago", h)
|
||||||
|
default:
|
||||||
|
days := int(d.Hours() / 24)
|
||||||
|
if days == 1 {
|
||||||
|
return "1 day ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d days ago", days)
|
||||||
|
}
|
||||||
|
}
|
||||||
223
core/internal/server/tailscale/client_test.go
Normal file
223
core/internal/server/tailscale/client_test.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go4.org/mem"
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/types/key"
|
||||||
|
"tailscale.com/types/views"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeTestStatus() *ipnstate.Status {
|
||||||
|
return &ipnstate.Status{
|
||||||
|
Version: "1.94.2",
|
||||||
|
BackendState: "Running",
|
||||||
|
MagicDNSSuffix: "example.ts.net",
|
||||||
|
CurrentTailnet: &ipnstate.TailnetStatus{
|
||||||
|
Name: "user@example.com",
|
||||||
|
MagicDNSSuffix: "example.ts.net",
|
||||||
|
},
|
||||||
|
Self: &ipnstate.PeerStatus{
|
||||||
|
ID: "node1",
|
||||||
|
HostName: "cachyos",
|
||||||
|
DNSName: "cachyos.example.ts.net.",
|
||||||
|
OS: "linux",
|
||||||
|
TailscaleIPs: []netip.Addr{
|
||||||
|
netip.MustParseAddr("100.85.254.40"),
|
||||||
|
netip.MustParseAddr("fd7a:115c:a1e0::1"),
|
||||||
|
},
|
||||||
|
Online: true,
|
||||||
|
UserID: 12345,
|
||||||
|
},
|
||||||
|
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
|
||||||
|
key.NodePublicFromRaw32(mem.B(make([]byte, 32))): {
|
||||||
|
ID: "node2",
|
||||||
|
HostName: "thinkpad-x390",
|
||||||
|
DNSName: "thinkpad-x390.example.ts.net.",
|
||||||
|
OS: "linux",
|
||||||
|
TailscaleIPs: []netip.Addr{
|
||||||
|
netip.MustParseAddr("100.97.21.17"),
|
||||||
|
netip.MustParseAddr("fd7a:115c:a1e0::2"),
|
||||||
|
},
|
||||||
|
Online: true,
|
||||||
|
Active: true,
|
||||||
|
Relay: "fra",
|
||||||
|
RxBytes: 1024,
|
||||||
|
TxBytes: 2048,
|
||||||
|
UserID: 12345,
|
||||||
|
ExitNode: false,
|
||||||
|
LastSeen: time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
User: map[tailcfg.UserID]tailcfg.UserProfile{
|
||||||
|
12345: {
|
||||||
|
ID: 12345,
|
||||||
|
LoginName: "user@example.com",
|
||||||
|
DisplayName: "User",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertStatus_Running(t *testing.T) {
|
||||||
|
status := makeTestStatus()
|
||||||
|
state := convertStatus(status)
|
||||||
|
|
||||||
|
require.NotNil(t, state)
|
||||||
|
assert.True(t, state.Connected)
|
||||||
|
assert.Equal(t, "1.94.2", state.Version)
|
||||||
|
assert.Equal(t, "Running", state.BackendState)
|
||||||
|
assert.Equal(t, "example.ts.net", state.MagicDNSSuffix)
|
||||||
|
assert.Equal(t, "user@example.com", state.TailnetName)
|
||||||
|
|
||||||
|
// Self
|
||||||
|
assert.Equal(t, "cachyos", state.Self.Hostname)
|
||||||
|
assert.Equal(t, "cachyos.example.ts.net", state.Self.DNSName)
|
||||||
|
assert.Equal(t, "100.85.254.40", state.Self.TailscaleIP)
|
||||||
|
assert.Equal(t, "fd7a:115c:a1e0::1", state.Self.TailscaleIPv6)
|
||||||
|
assert.Equal(t, "linux", state.Self.OS)
|
||||||
|
assert.True(t, state.Self.Online)
|
||||||
|
|
||||||
|
// Peers
|
||||||
|
require.Len(t, state.Peers, 1)
|
||||||
|
peer := state.Peers[0]
|
||||||
|
assert.Equal(t, "thinkpad-x390", peer.Hostname)
|
||||||
|
assert.Equal(t, "100.97.21.17", peer.TailscaleIP)
|
||||||
|
assert.Equal(t, "fra", peer.Relay)
|
||||||
|
assert.Equal(t, "user@example.com", peer.Owner)
|
||||||
|
assert.Equal(t, int64(1024), peer.RxBytes)
|
||||||
|
assert.True(t, peer.Online)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertStatus_NotRunning(t *testing.T) {
|
||||||
|
status := &ipnstate.Status{
|
||||||
|
BackendState: "Stopped",
|
||||||
|
}
|
||||||
|
|
||||||
|
state := convertStatus(status)
|
||||||
|
assert.False(t, state.Connected)
|
||||||
|
assert.Equal(t, "Stopped", state.BackendState)
|
||||||
|
assert.Empty(t, state.Peers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertStatus_NilSelf(t *testing.T) {
|
||||||
|
status := &ipnstate.Status{
|
||||||
|
BackendState: "Running",
|
||||||
|
}
|
||||||
|
|
||||||
|
state := convertStatus(status)
|
||||||
|
assert.True(t, state.Connected)
|
||||||
|
assert.Equal(t, Peer{}, state.Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPeerStatus_Tags(t *testing.T) {
|
||||||
|
tags := views.SliceOf([]string{"tag:k8s", "tag:server"})
|
||||||
|
ps := &ipnstate.PeerStatus{
|
||||||
|
ID: "node3",
|
||||||
|
HostName: "k8s-node",
|
||||||
|
DNSName: "k8s-node.example.ts.net.",
|
||||||
|
OS: "linux",
|
||||||
|
Online: false,
|
||||||
|
Tags: &tags,
|
||||||
|
}
|
||||||
|
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||||
|
|
||||||
|
peer := convertPeerStatus(ps, users)
|
||||||
|
assert.Equal(t, "k8s-node", peer.Hostname)
|
||||||
|
assert.Contains(t, peer.Tags, "tag:k8s")
|
||||||
|
assert.Contains(t, peer.Tags, "tag:server")
|
||||||
|
assert.Equal(t, "", peer.Owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPeerStatus_HostnameFromDNS(t *testing.T) {
|
||||||
|
// Hostname should always be derived from DNSName, not OS HostName
|
||||||
|
ps := &ipnstate.PeerStatus{
|
||||||
|
HostName: "GL-MT6000",
|
||||||
|
DNSName: "gl-mt6000-2.example.ts.net.",
|
||||||
|
}
|
||||||
|
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||||
|
|
||||||
|
peer := convertPeerStatus(ps, users)
|
||||||
|
assert.Equal(t, "gl-mt6000-2", peer.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPeerStatus_FallbackToHostName(t *testing.T) {
|
||||||
|
// When DNSName is empty, fall back to OS HostName
|
||||||
|
ps := &ipnstate.PeerStatus{
|
||||||
|
HostName: "my-device",
|
||||||
|
}
|
||||||
|
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||||
|
|
||||||
|
peer := convertPeerStatus(ps, users)
|
||||||
|
assert.Equal(t, "my-device", peer.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPeerStatus_LastSeen(t *testing.T) {
|
||||||
|
ps := &ipnstate.PeerStatus{
|
||||||
|
HostName: "recent-node",
|
||||||
|
LastSeen: time.Now().Add(-5 * time.Minute),
|
||||||
|
}
|
||||||
|
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||||
|
|
||||||
|
peer := convertPeerStatus(ps, users)
|
||||||
|
assert.NotEmpty(t, peer.LastSeen)
|
||||||
|
assert.Contains(t, peer.LastSeen, "minutes ago")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeerSorting(t *testing.T) {
|
||||||
|
b1 := make([]byte, 32)
|
||||||
|
b2 := make([]byte, 32)
|
||||||
|
b2[0] = 1
|
||||||
|
b3 := make([]byte, 32)
|
||||||
|
b3[0] = 2
|
||||||
|
|
||||||
|
k1 := key.NodePublicFromRaw32(mem.B(b1))
|
||||||
|
k2 := key.NodePublicFromRaw32(mem.B(b2))
|
||||||
|
k3 := key.NodePublicFromRaw32(mem.B(b3))
|
||||||
|
|
||||||
|
status := &ipnstate.Status{
|
||||||
|
BackendState: "Running",
|
||||||
|
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
|
||||||
|
k1: {HostName: "zebra", Online: false},
|
||||||
|
k2: {HostName: "alpha", Online: true},
|
||||||
|
k3: {HostName: "beta", Online: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
state := convertStatus(status)
|
||||||
|
|
||||||
|
// Online peers first (alpha, beta), then offline (zebra)
|
||||||
|
require.Len(t, state.Peers, 3)
|
||||||
|
assert.True(t, state.Peers[0].Online)
|
||||||
|
assert.True(t, state.Peers[1].Online)
|
||||||
|
assert.False(t, state.Peers[2].Online)
|
||||||
|
assert.Equal(t, "alpha", state.Peers[0].Hostname)
|
||||||
|
assert.Equal(t, "beta", state.Peers[1].Hostname)
|
||||||
|
assert.Equal(t, "zebra", state.Peers[2].Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRelativeTime(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
duration string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{"minutes", "5m", "minutes ago"},
|
||||||
|
{"hours", "3h", "hours ago"},
|
||||||
|
{"days", "48h", "days ago"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
d, _ := time.ParseDuration(tt.duration)
|
||||||
|
result := formatRelativeTime(time.Now().Add(-d))
|
||||||
|
assert.Contains(t, result, tt.contains)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
30
core/internal/server/tailscale/handlers.go
Normal file
30
core/internal/server/tailscale/handlers.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleRequest routes an IPC request to the appropriate handler.
|
||||||
|
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
||||||
|
switch req.Method {
|
||||||
|
case "tailscale.getStatus":
|
||||||
|
handleGetStatus(conn, req, manager)
|
||||||
|
case "tailscale.refresh":
|
||||||
|
handleRefresh(conn, req, manager)
|
||||||
|
default:
|
||||||
|
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetStatus(conn net.Conn, req models.Request, manager *Manager) {
|
||||||
|
state := manager.GetState()
|
||||||
|
models.Respond(conn, req.ID, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRefresh(conn net.Conn, req models.Request, manager *Manager) {
|
||||||
|
manager.RefreshState()
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"})
|
||||||
|
}
|
||||||
97
core/internal/server/tailscale/handlers_test.go
Normal file
97
core/internal/server/tailscale/handlers_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockConn struct {
|
||||||
|
*bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConn) Close() error { return nil }
|
||||||
|
func (m *mockConn) LocalAddr() net.Addr { return nil }
|
||||||
|
func (m *mockConn) RemoteAddr() net.Addr { return nil }
|
||||||
|
func (m *mockConn) SetDeadline(t time.Time) error { return nil }
|
||||||
|
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
|
||||||
|
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||||
|
|
||||||
|
func handlerTestManager() *Manager {
|
||||||
|
client := &mockClient{
|
||||||
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
},
|
||||||
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
return runningStatus(), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m := newManager(client)
|
||||||
|
m.RefreshState()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetStatus(t *testing.T) {
|
||||||
|
m := handlerTestManager()
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
conn := &mockConn{Buffer: buf}
|
||||||
|
|
||||||
|
req := models.Request{ID: 1, Method: "tailscale.getStatus"}
|
||||||
|
handleGetStatus(conn, req, m)
|
||||||
|
|
||||||
|
var resp models.Response[TailscaleState]
|
||||||
|
err := json.NewDecoder(buf).Decode(&resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, resp.ID)
|
||||||
|
assert.NotNil(t, resp.Result)
|
||||||
|
assert.True(t, resp.Result.Connected)
|
||||||
|
assert.Equal(t, "cachyos", resp.Result.Self.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRefresh(t *testing.T) {
|
||||||
|
m := handlerTestManager()
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
conn := &mockConn{Buffer: buf}
|
||||||
|
|
||||||
|
req := models.Request{ID: 1, Method: "tailscale.refresh"}
|
||||||
|
handleRefresh(conn, req, m)
|
||||||
|
|
||||||
|
var resp models.Response[models.SuccessResult]
|
||||||
|
err := json.NewDecoder(buf).Decode(&resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, resp.ID)
|
||||||
|
assert.NotNil(t, resp.Result)
|
||||||
|
assert.True(t, resp.Result.Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequest_UnknownMethod(t *testing.T) {
|
||||||
|
m := handlerTestManager()
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
conn := &mockConn{Buffer: buf}
|
||||||
|
|
||||||
|
req := models.Request{ID: 1, Method: "tailscale.unknownMethod"}
|
||||||
|
HandleRequest(conn, req, m)
|
||||||
|
|
||||||
|
var resp models.Response[any]
|
||||||
|
err := json.NewDecoder(buf).Decode(&resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, resp.Result)
|
||||||
|
assert.NotEmpty(t, resp.Error)
|
||||||
|
assert.Contains(t, resp.Error, "unknown method")
|
||||||
|
}
|
||||||
277
core/internal/server/tailscale/manager.go
Normal file
277
core/internal/server/tailscale/manager.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||||
|
"tailscale.com/client/local"
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
statusTimeout = 3 * time.Second
|
||||||
|
debounceWindow = 150 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// tailscaleClient abstracts the Tailscale local API for testing.
|
||||||
|
type tailscaleClient interface {
|
||||||
|
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||||
|
Status(ctx context.Context) (*ipnstate.Status, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipnBusWatcher abstracts the IPN bus watcher for testing.
|
||||||
|
type ipnBusWatcher interface {
|
||||||
|
Next() (ipn.Notify, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// localClientWrapper wraps local.Client to satisfy tailscaleClient.
|
||||||
|
type localClientWrapper struct {
|
||||||
|
client *local.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *localClientWrapper) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
return w.client.WatchIPNBus(ctx, mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *localClientWrapper) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
return w.client.Status(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager manages Tailscale state via IPN bus events and subscriber notifications.
|
||||||
|
type Manager struct {
|
||||||
|
state *TailscaleState
|
||||||
|
stateMutex sync.RWMutex
|
||||||
|
subscribers syncmap.Map[string, chan TailscaleState]
|
||||||
|
client tailscaleClient
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
watchWG sync.WaitGroup
|
||||||
|
closed atomic.Bool
|
||||||
|
dirty chan struct{}
|
||||||
|
available atomic.Bool
|
||||||
|
availabilityCallback atomic.Pointer[func(bool)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new Tailscale manager and starts watching the IPN bus.
|
||||||
|
func NewManager(socketPath string) *Manager {
|
||||||
|
lc := &local.Client{Socket: socketPath}
|
||||||
|
return newManager(&localClientWrapper{client: lc})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newManager(client tailscaleClient) *Manager {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
m := &Manager{
|
||||||
|
state: &TailscaleState{},
|
||||||
|
client: client,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
dirty: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
m.watchWG.Add(2)
|
||||||
|
go m.watchLoop(ctx)
|
||||||
|
go m.debounceLoop(ctx)
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) watchLoop(ctx context.Context) {
|
||||||
|
defer m.watchWG.Done()
|
||||||
|
|
||||||
|
mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
|
||||||
|
backoff := time.Second
|
||||||
|
unreachableSent := false
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher, err := m.client.WatchIPNBus(ctx, mask)
|
||||||
|
if err != nil {
|
||||||
|
if !unreachableSent {
|
||||||
|
m.updateState(&TailscaleState{Connected: false, BackendState: "Unreachable"})
|
||||||
|
unreachableSent = true
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
backoff = min(backoff*2, 30*time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unreachableSent = false
|
||||||
|
backoff = time.Second
|
||||||
|
log.Info("[Tailscale] Connected to IPN bus")
|
||||||
|
m.markAvailable()
|
||||||
|
|
||||||
|
for {
|
||||||
|
notify, err := watcher.Next()
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("[Tailscale] IPN bus error: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if notify.State == nil && notify.NetMap == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case m.dirty <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// debounceLoop coalesces rapid bus notifications into a single Status RPC
|
||||||
|
// per debounceWindow, since NetMap events can fire many times per second
|
||||||
|
// on busy tailnets.
|
||||||
|
func (m *Manager) debounceLoop(ctx context.Context) {
|
||||||
|
defer m.watchWG.Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-m.dirty:
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := time.NewTimer(debounceWindow)
|
||||||
|
collecting := true
|
||||||
|
for collecting {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-m.dirty:
|
||||||
|
case <-timer.C:
|
||||||
|
collecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.fetchAndBroadcast(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchAndBroadcast(ctx context.Context) {
|
||||||
|
statusCtx, cancel := context.WithTimeout(ctx, statusTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
status, err := m.client.Status(statusCtx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("[Tailscale] Failed to fetch status: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := convertStatus(status)
|
||||||
|
m.updateState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) updateState(state *TailscaleState) {
|
||||||
|
m.stateMutex.Lock()
|
||||||
|
m.state = state
|
||||||
|
m.stateMutex.Unlock()
|
||||||
|
|
||||||
|
m.broadcastState(*state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) broadcastState(state TailscaleState) {
|
||||||
|
if m.closed.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
|
||||||
|
select {
|
||||||
|
case ch <- state:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAvailable reports whether tailscaled has been reachable via the IPN bus
|
||||||
|
// at least once since the manager started. False means tailscaled appears
|
||||||
|
// to not be installed or has never been running.
|
||||||
|
func (m *Manager) IsAvailable() bool {
|
||||||
|
return m.available.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAvailabilityCallback registers a callback fired when the manager
|
||||||
|
// transitions from unavailable to available. Replaces any previously set
|
||||||
|
// callback. Must be set before the manager has a chance to detect tailscaled.
|
||||||
|
func (m *Manager) SetAvailabilityCallback(cb func(bool)) {
|
||||||
|
m.availabilityCallback.Store(&cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) markAvailable() {
|
||||||
|
if m.available.Swap(true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cb := m.availabilityCallback.Load(); cb != nil {
|
||||||
|
(*cb)(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState returns a copy of the current Tailscale state.
|
||||||
|
func (m *Manager) GetState() TailscaleState {
|
||||||
|
m.stateMutex.RLock()
|
||||||
|
defer m.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
if m.state == nil {
|
||||||
|
return TailscaleState{}
|
||||||
|
}
|
||||||
|
return *m.state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe creates a buffered channel for the given client ID.
|
||||||
|
func (m *Manager) Subscribe(clientID string) chan TailscaleState {
|
||||||
|
ch := make(chan TailscaleState, 64)
|
||||||
|
m.subscribers.Store(clientID, ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes and closes the subscriber channel.
|
||||||
|
func (m *Manager) Unsubscribe(clientID string) {
|
||||||
|
if val, ok := m.subscribers.LoadAndDelete(clientID); ok {
|
||||||
|
close(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the watch loop and closes all subscriber channels.
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
m.closed.Store(true)
|
||||||
|
m.cancel()
|
||||||
|
m.watchWG.Wait()
|
||||||
|
|
||||||
|
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
|
||||||
|
close(ch)
|
||||||
|
m.subscribers.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshState triggers an immediate status fetch and broadcasts.
|
||||||
|
func (m *Manager) RefreshState() {
|
||||||
|
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
status, err := m.client.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("[Tailscale] Failed to refresh state: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := convertStatus(status)
|
||||||
|
m.updateState(state)
|
||||||
|
}
|
||||||
307
core/internal/server/tailscale/manager_test.go
Normal file
307
core/internal/server/tailscale/manager_test.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel.
|
||||||
|
type mockWatcher struct {
|
||||||
|
events []ipn.Notify
|
||||||
|
idx int
|
||||||
|
err error
|
||||||
|
done chan struct{}
|
||||||
|
ctx context.Context
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockWatcher(ctx context.Context, events []ipn.Notify, err error) *mockWatcher {
|
||||||
|
return &mockWatcher{
|
||||||
|
events: events,
|
||||||
|
err: err,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
ctx: ctx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *mockWatcher) Next() (ipn.Notify, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.idx < len(w.events) {
|
||||||
|
n := w.events[w.idx]
|
||||||
|
w.idx++
|
||||||
|
w.mu.Unlock()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
if w.err != nil {
|
||||||
|
err := w.err
|
||||||
|
w.mu.Unlock()
|
||||||
|
return ipn.Notify{}, err
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-w.done:
|
||||||
|
return ipn.Notify{}, fmt.Errorf("watcher closed")
|
||||||
|
case <-w.ctx.Done():
|
||||||
|
return ipn.Notify{}, w.ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *mockWatcher) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if !w.closed {
|
||||||
|
w.closed = true
|
||||||
|
close(w.done)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockClient implements tailscaleClient for testing.
|
||||||
|
type mockClient struct {
|
||||||
|
watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||||
|
statusFn func(ctx context.Context) (*ipnstate.Status, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mockClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
return c.watchFn(ctx, mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
return c.statusFn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runningStatus() *ipnstate.Status {
|
||||||
|
return &ipnstate.Status{
|
||||||
|
Version: "1.94.2",
|
||||||
|
BackendState: "Running",
|
||||||
|
MagicDNSSuffix: "example.ts.net",
|
||||||
|
CurrentTailnet: &ipnstate.TailnetStatus{
|
||||||
|
Name: "user@example.com",
|
||||||
|
MagicDNSSuffix: "example.ts.net",
|
||||||
|
},
|
||||||
|
Self: &ipnstate.PeerStatus{
|
||||||
|
HostName: "cachyos",
|
||||||
|
DNSName: "cachyos.example.ts.net.",
|
||||||
|
OS: "linux",
|
||||||
|
Online: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchLoop_StateChange(t *testing.T) {
|
||||||
|
stateVal := ipn.Running
|
||||||
|
statusCalled := make(chan struct{}, 4)
|
||||||
|
var watchCount int32
|
||||||
|
|
||||||
|
client := &mockClient{
|
||||||
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
watchCount++
|
||||||
|
if watchCount == 1 {
|
||||||
|
return newMockWatcher(ctx,
|
||||||
|
[]ipn.Notify{{State: &stateVal}},
|
||||||
|
fmt.Errorf("done"),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
return newMockWatcher(ctx, nil, nil), nil
|
||||||
|
},
|
||||||
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
select {
|
||||||
|
case statusCalled <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return runningStatus(), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := newManager(client)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return len(statusCalled) > 0
|
||||||
|
}, 2*time.Second, 10*time.Millisecond)
|
||||||
|
|
||||||
|
state := m.GetState()
|
||||||
|
assert.True(t, state.Connected)
|
||||||
|
assert.Equal(t, "Running", state.BackendState)
|
||||||
|
assert.Equal(t, "cachyos", state.Self.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchLoop_CoalescesNotifies(t *testing.T) {
|
||||||
|
stateVal := ipn.Running
|
||||||
|
var statusCalls atomic.Int32
|
||||||
|
|
||||||
|
notifies := make([]ipn.Notify, 0, 20)
|
||||||
|
for range 20 {
|
||||||
|
notifies = append(notifies, ipn.Notify{State: &stateVal})
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &mockClient{
|
||||||
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
return newMockWatcher(ctx, notifies, nil), nil
|
||||||
|
},
|
||||||
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
statusCalls.Add(1)
|
||||||
|
return runningStatus(), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := newManager(client)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
// Wait for the debounce window to expire plus margin so the burst settles.
|
||||||
|
time.Sleep(debounceWindow + 100*time.Millisecond)
|
||||||
|
|
||||||
|
calls := statusCalls.Load()
|
||||||
|
assert.Less(t, int(calls), 5,
|
||||||
|
"20 rapid notifies should coalesce to a small number of Status RPCs, got %d", calls)
|
||||||
|
assert.Greater(t, int(calls), 0, "expected at least one Status RPC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchLoop_Reconnect(t *testing.T) {
|
||||||
|
watchCalled := make(chan struct{}, 4)
|
||||||
|
|
||||||
|
client := &mockClient{
|
||||||
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
select {
|
||||||
|
case watchCalled <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if len(watchCalled) <= 1 {
|
||||||
|
return nil, fmt.Errorf("connection refused")
|
||||||
|
}
|
||||||
|
return newMockWatcher(ctx, nil, nil), nil
|
||||||
|
},
|
||||||
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
return runningStatus(), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := newManager(client)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
state := m.GetState()
|
||||||
|
return state.BackendState == "Unreachable"
|
||||||
|
}, 2*time.Second, 10*time.Millisecond)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return len(watchCalled) >= 2
|
||||||
|
}, 3*time.Second, 50*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Subscribe(t *testing.T) {
|
||||||
|
client := &mockClient{
|
||||||
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
},
|
||||||
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
return runningStatus(), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := newManager(client)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
ch := m.Subscribe("test-1")
|
||||||
|
assert.NotNil(t, ch)
|
||||||
|
|
||||||
|
ch2 := m.Subscribe("test-2")
|
||||||
|
assert.NotNil(t, ch2)
|
||||||
|
|
||||||
|
m.Unsubscribe("test-1")
|
||||||
|
m.Unsubscribe("test-2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Close(t *testing.T) {
|
||||||
|
client := &mockClient{
|
||||||
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
},
|
||||||
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
return runningStatus(), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := newManager(client)
|
||||||
|
|
||||||
|
ch := m.Subscribe("test")
|
||||||
|
assert.NotNil(t, ch)
|
||||||
|
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
m.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Availability(t *testing.T) {
|
||||||
|
var watchAttempts atomic.Int32
|
||||||
|
|
||||||
|
client := &mockClient{
|
||||||
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
n := watchAttempts.Add(1)
|
||||||
|
if n == 1 {
|
||||||
|
return nil, fmt.Errorf("tailscaled socket not found")
|
||||||
|
}
|
||||||
|
return newMockWatcher(ctx, nil, nil), nil
|
||||||
|
},
|
||||||
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
return runningStatus(), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := newManager(client)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
cbFired := make(chan bool, 1)
|
||||||
|
m.SetAvailabilityCallback(func(b bool) {
|
||||||
|
select {
|
||||||
|
case cbFired <- b:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.False(t, m.IsAvailable())
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return m.IsAvailable()
|
||||||
|
}, 3*time.Second, 50*time.Millisecond)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case b := <-cbFired:
|
||||||
|
assert.True(t, b)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("availability callback did not fire")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_RefreshState(t *testing.T) {
|
||||||
|
client := &mockClient{
|
||||||
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
},
|
||||||
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
return runningStatus(), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := newManager(client)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
m.RefreshState()
|
||||||
|
|
||||||
|
state := m.GetState()
|
||||||
|
assert.True(t, state.Connected)
|
||||||
|
assert.Equal(t, "cachyos", state.Self.Hostname)
|
||||||
|
}
|
||||||
31
core/internal/server/tailscale/types.go
Normal file
31
core/internal/server/tailscale/types.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
// TailscaleState represents the current state of the Tailscale daemon.
|
||||||
|
type TailscaleState struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
BackendState string `json:"backendState"`
|
||||||
|
MagicDNSSuffix string `json:"magicDnsSuffix"`
|
||||||
|
TailnetName string `json:"tailnetName"`
|
||||||
|
Self Peer `json:"self"`
|
||||||
|
Peers []Peer `json:"peers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peer represents a single node in the Tailscale network.
|
||||||
|
type Peer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
DNSName string `json:"dnsName"`
|
||||||
|
TailscaleIP string `json:"tailscaleIp"`
|
||||||
|
TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
LastSeen string `json:"lastSeen,omitempty"`
|
||||||
|
ExitNode bool `json:"exitNode"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Relay string `json:"relay,omitempty"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
RxBytes int64 `json:"rxBytes"`
|
||||||
|
TxBytes int64 `json:"txBytes"`
|
||||||
|
}
|
||||||
455
core/internal/trash/trash.go
Normal file
455
core/internal/trash/trash.go
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
// Package trash implements the FreeDesktop.org Trash specification 1.0.
|
||||||
|
// See: https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html
|
||||||
|
package trash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const trashInfoExt = ".trashinfo"
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
OriginalPath string `json:"originalPath"`
|
||||||
|
DeletionDate string `json:"deletionDate"`
|
||||||
|
TrashDir string `json:"trashDir"`
|
||||||
|
FilesPath string `json:"filesPath"`
|
||||||
|
InfoPath string `json:"infoPath"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
IsDir bool `json:"isDir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func homeTrashDir() (string, error) {
|
||||||
|
xdg := os.Getenv("XDG_DATA_HOME")
|
||||||
|
if xdg == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
xdg = filepath.Join(home, ".local", "share")
|
||||||
|
}
|
||||||
|
return filepath.Join(xdg, "Trash"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureTrashDirs(trashDir string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Join(trashDir, "files"), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.MkdirAll(filepath.Join(trashDir, "info"), 0o700)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fsDevice(path string) (uint64, error) {
|
||||||
|
var st syscall.Stat_t
|
||||||
|
if err := syscall.Lstat(path, &st); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint64(st.Dev), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fsDeviceWalkUp(start string) (uint64, error) {
|
||||||
|
cur := start
|
||||||
|
for {
|
||||||
|
if dev, err := fsDevice(cur); err == nil {
|
||||||
|
return dev, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(cur)
|
||||||
|
if parent == cur {
|
||||||
|
return 0, fmt.Errorf("no existing ancestor for %s", start)
|
||||||
|
}
|
||||||
|
cur = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTopDir(path string) (string, error) {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dev, err := fsDevice(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cur := abs
|
||||||
|
for {
|
||||||
|
parent := filepath.Dir(cur)
|
||||||
|
if parent == cur {
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
pdev, err := fsDevice(parent)
|
||||||
|
if err != nil {
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
if pdev != dev {
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
cur = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidSharedTrash enforces the spec's checks on $topdir/.Trash:
|
||||||
|
// must exist, must be a directory, must not be a symlink, must have sticky bit.
|
||||||
|
func isValidSharedTrash(p string) bool {
|
||||||
|
info, err := os.Lstat(p)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return info.Mode()&os.ModeSticky != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// trashDirForPath chooses the correct trash dir per spec and returns the value
|
||||||
|
// to store in the .trashinfo Path field (absolute for home, relative-to-topdir
|
||||||
|
// for per-mountpoint trash).
|
||||||
|
func trashDirForPath(absPath string) (trashDir string, storedPath string, err error) {
|
||||||
|
home, err := homeTrashDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
pathDev, err := fsDevice(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
homeDev, err := fsDeviceWalkUp(home)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pathDev == homeDev {
|
||||||
|
return home, absPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
topDir, err := findTopDir(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := strconv.Itoa(os.Getuid())
|
||||||
|
stored, rerr := filepath.Rel(topDir, absPath)
|
||||||
|
if rerr != nil || strings.HasPrefix(stored, "..") {
|
||||||
|
stored = absPath
|
||||||
|
}
|
||||||
|
|
||||||
|
shared := filepath.Join(topDir, ".Trash")
|
||||||
|
if isValidSharedTrash(shared) {
|
||||||
|
return filepath.Join(shared, uid), stored, nil
|
||||||
|
}
|
||||||
|
return filepath.Join(topDir, ".Trash-"+uid), stored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueName returns a basename in trashDir that does not collide with an
|
||||||
|
// existing entry in either files/ or info/.
|
||||||
|
func uniqueName(trashDir, basename string) (string, error) {
|
||||||
|
filesDir := filepath.Join(trashDir, "files")
|
||||||
|
infoDir := filepath.Join(trashDir, "info")
|
||||||
|
if !exists(filepath.Join(filesDir, basename)) && !exists(filepath.Join(infoDir, basename+trashInfoExt)) {
|
||||||
|
return basename, nil
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(basename)
|
||||||
|
stem := strings.TrimSuffix(basename, ext)
|
||||||
|
for i := 2; i < 100000; i++ {
|
||||||
|
candidate := fmt.Sprintf("%s.%d%s", stem, i, ext)
|
||||||
|
if !exists(filepath.Join(filesDir, candidate)) && !exists(filepath.Join(infoDir, candidate+trashInfoExt)) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("could not find unique trash name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func exists(p string) bool {
|
||||||
|
_, err := os.Lstat(p)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathEncode percent-escapes a POSIX path per RFC 2396, preserving "/".
|
||||||
|
func pathEncode(p string) string {
|
||||||
|
parts := strings.Split(p, "/")
|
||||||
|
for i, seg := range parts {
|
||||||
|
parts[i] = url.PathEscape(seg)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathDecode(p string) string {
|
||||||
|
if d, err := url.PathUnescape(p); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTrashInfo(infoPath, storedPath string, when time.Time) error {
|
||||||
|
body := "[Trash Info]\nPath=" + pathEncode(storedPath) +
|
||||||
|
"\nDeletionDate=" + when.Format("2006-01-02T15:04:05") + "\n"
|
||||||
|
f, err := os.OpenFile(infoPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = f.WriteString(body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put trashes a single file or directory.
|
||||||
|
func Put(path string) (Entry, error) {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
trashDir, storedPath, err := trashDirForPath(abs)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
if err := ensureTrashDirs(trashDir); err != nil {
|
||||||
|
return Entry{}, fmt.Errorf("create trash dir %s: %w", trashDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err := uniqueName(trashDir, filepath.Base(abs))
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
infoPath := filepath.Join(trashDir, "info", name+trashInfoExt)
|
||||||
|
when := time.Now()
|
||||||
|
if err := writeTrashInfo(infoPath, storedPath, when); err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(trashDir, "files", name)
|
||||||
|
if err := os.Rename(abs, target); err != nil {
|
||||||
|
os.Remove(infoPath)
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Entry{
|
||||||
|
Name: name,
|
||||||
|
OriginalPath: storedPath,
|
||||||
|
DeletionDate: when.Format("2006-01-02T15:04:05"),
|
||||||
|
TrashDir: trashDir,
|
||||||
|
FilesPath: target,
|
||||||
|
InfoPath: infoPath,
|
||||||
|
Size: info.Size(),
|
||||||
|
IsDir: info.IsDir(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allTrashDirs returns the home trash plus every per-mountpoint trash dir
|
||||||
|
// that exists (and passes the spec's safety checks for $topdir/.Trash).
|
||||||
|
func allTrashDirs() []string {
|
||||||
|
var dirs []string
|
||||||
|
if h, err := homeTrashDir(); err == nil {
|
||||||
|
dirs = append(dirs, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := strconv.Itoa(os.Getuid())
|
||||||
|
for _, mount := range readMountPoints() {
|
||||||
|
shared := filepath.Join(mount, ".Trash")
|
||||||
|
if isValidSharedTrash(shared) {
|
||||||
|
candidate := filepath.Join(shared, uid)
|
||||||
|
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||||
|
dirs = append(dirs, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidate := filepath.Join(mount, ".Trash-"+uid)
|
||||||
|
if info, err := os.Lstat(candidate); err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
dirs = append(dirs, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMountPoints returns user-visible mount points from /proc/self/mountinfo,
|
||||||
|
// skipping pseudo and system filesystems.
|
||||||
|
func readMountPoints() []string {
|
||||||
|
data, err := os.ReadFile("/proc/self/mountinfo")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
skipPrefixes := []string{"/proc", "/sys", "/dev"}
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for line := range strings.SplitSeq(string(data), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mp := fields[4]
|
||||||
|
if mp == "/" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skip := false
|
||||||
|
for _, p := range skipPrefixes {
|
||||||
|
if mp == p || strings.HasPrefix(mp, p+"/") {
|
||||||
|
skip = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skip || seen[mp] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[mp] = true
|
||||||
|
out = append(out, mp)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func List() ([]Entry, error) {
|
||||||
|
var entries []Entry
|
||||||
|
for _, d := range allTrashDirs() {
|
||||||
|
es, _ := listOne(d)
|
||||||
|
entries = append(entries, es...)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listOne(trashDir string) ([]Entry, error) {
|
||||||
|
infoDir := filepath.Join(trashDir, "info")
|
||||||
|
filesDir := filepath.Join(trashDir, "files")
|
||||||
|
dir, err := os.ReadDir(infoDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var entries []Entry
|
||||||
|
for _, ent := range dir {
|
||||||
|
if !strings.HasSuffix(ent.Name(), trashInfoExt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(ent.Name(), trashInfoExt)
|
||||||
|
infoPath := filepath.Join(infoDir, ent.Name())
|
||||||
|
filesPath := filepath.Join(filesDir, name)
|
||||||
|
|
||||||
|
body, err := os.ReadFile(infoPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
e := Entry{Name: name, TrashDir: trashDir, InfoPath: infoPath, FilesPath: filesPath}
|
||||||
|
for line := range strings.SplitSeq(string(body), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "Path="); ok {
|
||||||
|
e.OriginalPath = pathDecode(v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := strings.CutPrefix(line, "DeletionDate="); ok {
|
||||||
|
e.DeletionDate = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info, err := os.Lstat(filesPath); err == nil {
|
||||||
|
e.Size = info.Size()
|
||||||
|
e.IsDir = info.IsDir()
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Count() (int, error) {
|
||||||
|
n := 0
|
||||||
|
for _, d := range allTrashDirs() {
|
||||||
|
ents, err := os.ReadDir(filepath.Join(d, "info"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range ents {
|
||||||
|
if strings.HasSuffix(e.Name(), trashInfoExt) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Empty() error {
|
||||||
|
var firstErr error
|
||||||
|
for _, d := range allTrashDirs() {
|
||||||
|
if err := emptyOne(d); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyOne(trashDir string) error {
|
||||||
|
var firstErr error
|
||||||
|
for _, sub := range []string{"files", "info"} {
|
||||||
|
path := filepath.Join(trashDir, sub)
|
||||||
|
ents, err := os.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range ents {
|
||||||
|
if err := os.RemoveAll(filepath.Join(path, e.Name())); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os.Remove(filepath.Join(trashDir, "directorysizes"))
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore returns a trashed item to its original location.
|
||||||
|
func Restore(name, trashDir string) error {
|
||||||
|
if trashDir == "" {
|
||||||
|
h, err := homeTrashDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
trashDir = h
|
||||||
|
}
|
||||||
|
|
||||||
|
infoPath := filepath.Join(trashDir, "info", name+trashInfoExt)
|
||||||
|
filesPath := filepath.Join(trashDir, "files", name)
|
||||||
|
|
||||||
|
body, err := os.ReadFile(infoPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored string
|
||||||
|
for line := range strings.SplitSeq(string(body), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "Path="); ok {
|
||||||
|
stored = pathDecode(v)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stored == "" {
|
||||||
|
return errors.New("invalid .trashinfo: missing Path")
|
||||||
|
}
|
||||||
|
|
||||||
|
target := stored
|
||||||
|
if !filepath.IsAbs(stored) {
|
||||||
|
topDir := filepath.Dir(trashDir)
|
||||||
|
if filepath.Base(topDir) == ".Trash" {
|
||||||
|
topDir = filepath.Dir(topDir)
|
||||||
|
}
|
||||||
|
target = filepath.Join(topDir, stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists(target) {
|
||||||
|
return fmt.Errorf("restore target already exists: %s", target)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(filesPath, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
os.Remove(infoPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
315
core/internal/trash/trash_test.go
Normal file
315
core/internal/trash/trash_test.go
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
package trash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupHomeTrash(t *testing.T) (homeRoot string, trashDir string) {
|
||||||
|
t.Helper()
|
||||||
|
homeRoot = t.TempDir()
|
||||||
|
xdg := filepath.Join(homeRoot, ".local", "share")
|
||||||
|
if err := os.MkdirAll(xdg, 0o700); err != nil {
|
||||||
|
t.Fatalf("mkdir xdg: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("XDG_DATA_HOME", xdg)
|
||||||
|
t.Setenv("HOME", homeRoot)
|
||||||
|
trashDir = filepath.Join(xdg, "Trash")
|
||||||
|
return homeRoot, trashDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutHomeTrashAbsolutePath(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
src := filepath.Join(homeRoot, "doc.txt")
|
||||||
|
writeFile(t, src, "hi")
|
||||||
|
|
||||||
|
entry, err := Put(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Name != "doc.txt" {
|
||||||
|
t.Errorf("name = %q, want doc.txt", entry.Name)
|
||||||
|
}
|
||||||
|
if entry.OriginalPath != src {
|
||||||
|
t.Errorf("originalPath = %q, want %q", entry.OriginalPath, src)
|
||||||
|
}
|
||||||
|
if entry.TrashDir != trashDir {
|
||||||
|
t.Errorf("trashDir = %q, want %q", entry.TrashDir, trashDir)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(src); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("source still exists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := os.ReadFile(filepath.Join(trashDir, "info", "doc.txt.trashinfo"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read trashinfo: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(string(body), "[Trash Info]\n") {
|
||||||
|
t.Errorf("trashinfo missing header: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "Path="+src+"\n") {
|
||||||
|
t.Errorf("Path key missing or wrong: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "DeletionDate=") {
|
||||||
|
t.Errorf("DeletionDate missing: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutPercentEncodesPath(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
name := "spaces & %.txt"
|
||||||
|
src := filepath.Join(homeRoot, name)
|
||||||
|
writeFile(t, src, "x")
|
||||||
|
|
||||||
|
if _, err := Put(src); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := os.ReadFile(filepath.Join(trashDir, "info", name+".trashinfo"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
want := "Path=" + filepath.Dir(src) + "/spaces%20&%20%25.txt"
|
||||||
|
if !strings.Contains(string(body), want) {
|
||||||
|
t.Errorf("expected %q in %q", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutCollisionGetsUniqueName(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
src := filepath.Join(homeRoot, "dup.txt")
|
||||||
|
writeFile(t, src, "x")
|
||||||
|
if _, err := Put(src); err != nil {
|
||||||
|
t.Fatalf("Put #%d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"dup.txt", "dup.2.txt", "dup.3.txt"}
|
||||||
|
for _, n := range want {
|
||||||
|
if _, err := os.Stat(filepath.Join(trashDir, "files", n)); err != nil {
|
||||||
|
t.Errorf("expected %s in trash: %v", n, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(trashDir, "info", n+".trashinfo")); err != nil {
|
||||||
|
t.Errorf("expected %s.trashinfo: %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAndCount(t *testing.T) {
|
||||||
|
homeRoot, _ := setupHomeTrash(t)
|
||||||
|
|
||||||
|
if n, _ := Count(); n != 0 {
|
||||||
|
t.Errorf("initial count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
entries, _ := List()
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("initial list len = %d, want 0", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range []string{"a.txt", "b.txt", "c.log"} {
|
||||||
|
src := filepath.Join(homeRoot, n)
|
||||||
|
writeFile(t, src, n)
|
||||||
|
if _, err := Put(src); err != nil {
|
||||||
|
t.Fatalf("Put %s: %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := Count()
|
||||||
|
if got != 3 {
|
||||||
|
t.Errorf("count = %d, want 3", got)
|
||||||
|
}
|
||||||
|
entries, _ = List()
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Errorf("list len = %d, want 3", len(entries))
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.OriginalPath == "" {
|
||||||
|
t.Errorf("entry %s: empty OriginalPath", e.Name)
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("2006-01-02T15:04:05", e.DeletionDate); err != nil {
|
||||||
|
t.Errorf("entry %s: bad DeletionDate %q: %v", e.Name, e.DeletionDate, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyClearsAll(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
for _, n := range []string{"x", "y", "z"} {
|
||||||
|
src := filepath.Join(homeRoot, n)
|
||||||
|
writeFile(t, src, n)
|
||||||
|
if _, err := Put(src); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n, _ := Count(); n != 3 {
|
||||||
|
t.Fatalf("pre-empty count = %d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Empty(); err != nil {
|
||||||
|
t.Fatalf("Empty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, _ := Count(); n != 0 {
|
||||||
|
t.Errorf("post-empty count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
for _, sub := range []string{"files", "info"} {
|
||||||
|
ents, err := os.ReadDir(filepath.Join(trashDir, sub))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readdir %s: %v", sub, err)
|
||||||
|
}
|
||||||
|
if len(ents) != 0 {
|
||||||
|
t.Errorf("%s/ has %d entries, want 0", sub, len(ents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreToOriginalPath(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
src := filepath.Join(homeRoot, "sub", "dir", "thing.txt")
|
||||||
|
writeFile(t, src, "payload")
|
||||||
|
|
||||||
|
entry, err := Put(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.RemoveAll(filepath.Join(homeRoot, "sub"))
|
||||||
|
|
||||||
|
if err := Restore(entry.Name, trashDir); err != nil {
|
||||||
|
t.Fatalf("Restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read restored: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "payload" {
|
||||||
|
t.Errorf("restored content = %q, want %q", body, "payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(entry.InfoPath); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("info file still present: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(entry.FilesPath); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("files entry still present: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreRefusesToOverwrite(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
src := filepath.Join(homeRoot, "keep.txt")
|
||||||
|
writeFile(t, src, "v1")
|
||||||
|
|
||||||
|
entry, err := Put(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile(t, src, "v2-blocking")
|
||||||
|
|
||||||
|
err = Restore(entry.Name, trashDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error on conflicting restore, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "exists") {
|
||||||
|
t.Errorf("error %q does not mention conflict", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := os.ReadFile(src)
|
||||||
|
if string(body) != "v2-blocking" {
|
||||||
|
t.Errorf("blocking file altered: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutDirectory(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
dir := filepath.Join(homeRoot, "myfolder")
|
||||||
|
writeFile(t, filepath.Join(dir, "child.txt"), "inside")
|
||||||
|
|
||||||
|
entry, err := Put(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put dir: %v", err)
|
||||||
|
}
|
||||||
|
if !entry.IsDir {
|
||||||
|
t.Errorf("IsDir = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
moved := filepath.Join(trashDir, "files", "myfolder", "child.txt")
|
||||||
|
body, err := os.ReadFile(moved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read moved child: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "inside" {
|
||||||
|
t.Errorf("child content = %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidSharedTrashRejectsSymlink(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
target := filepath.Join(tmp, "real")
|
||||||
|
if err := os.MkdirAll(target, os.ModeSticky|0o777); err != nil {
|
||||||
|
t.Fatalf("mkdir target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
link := filepath.Join(tmp, ".Trash")
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Fatalf("symlink: %v", err)
|
||||||
|
}
|
||||||
|
if isValidSharedTrash(link) {
|
||||||
|
t.Errorf("symlinked .Trash accepted; spec requires rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidSharedTrashRequiresStickyBit(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
dir := filepath.Join(tmp, ".Trash")
|
||||||
|
if err := os.MkdirAll(dir, 0o777); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if isValidSharedTrash(dir) {
|
||||||
|
t.Errorf(".Trash without sticky bit accepted; spec requires rejection")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(dir, os.ModeSticky|0o777); err != nil {
|
||||||
|
t.Fatalf("chmod: %v", err)
|
||||||
|
}
|
||||||
|
if !isValidSharedTrash(dir) {
|
||||||
|
t.Errorf(".Trash with sticky bit rejected; spec accepts it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathEncodeRoundTrip(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"/home/u/file.txt",
|
||||||
|
"/path with spaces/and-symbols & %.txt",
|
||||||
|
"relative/path/é unicode.md",
|
||||||
|
}
|
||||||
|
for _, in := range cases {
|
||||||
|
got := pathDecode(pathEncode(in))
|
||||||
|
if got != in {
|
||||||
|
t.Errorf("round-trip %q -> %q", in, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,36 @@ func XDGConfigHome() string {
|
|||||||
return filepath.Join(home, ".config")
|
return filepath.Join(home, ".config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func XDGPicturesDir() string {
|
||||||
|
if dir := os.Getenv("XDG_PICTURES_DIR"); dir != "" {
|
||||||
|
if expanded, err := ExpandPath(dir); err == nil {
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(XDGConfigHome(), "user-dirs.dirs"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = "XDG_PICTURES_DIR="
|
||||||
|
for line := range strings.SplitSeq(string(data), "\n") {
|
||||||
|
if len(line) == 0 || line[0] == '#' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(line, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := strings.Trim(line[len(prefix):], "\"")
|
||||||
|
expanded, err := ExpandPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func EmacsConfigDir() string {
|
func EmacsConfigDir() string {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ package version
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mocks_version "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/version"
|
mocks_version "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/version"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompareVersions(t *testing.T) {
|
func TestCompareVersions(t *testing.T) {
|
||||||
@@ -150,76 +148,6 @@ func TestGetCurrentDMSVersion_NotInstalled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCurrentDMSVersion_GitTag(t *testing.T) {
|
|
||||||
if !utils.CommandExists("git") {
|
|
||||||
t.Skip("git not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
|
|
||||||
os.MkdirAll(dmsPath, 0o755)
|
|
||||||
|
|
||||||
originalHome := os.Getenv("HOME")
|
|
||||||
defer os.Setenv("HOME", originalHome)
|
|
||||||
os.Setenv("HOME", tempDir)
|
|
||||||
|
|
||||||
exec.Command("git", "init", dmsPath).Run()
|
|
||||||
exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run()
|
|
||||||
exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run()
|
|
||||||
|
|
||||||
testFile := filepath.Join(dmsPath, "test.txt")
|
|
||||||
os.WriteFile(testFile, []byte("test"), 0o644)
|
|
||||||
exec.Command("git", "-C", dmsPath, "add", ".").Run()
|
|
||||||
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
|
|
||||||
exec.Command("git", "-C", dmsPath, "tag", "v0.1.0").Run()
|
|
||||||
|
|
||||||
version, err := GetCurrentDMSVersion()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetCurrentDMSVersion() failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if version != "v0.1.0" {
|
|
||||||
t.Errorf("Expected version v0.1.0, got %s", version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetCurrentDMSVersion_GitBranch(t *testing.T) {
|
|
||||||
if !utils.CommandExists("git") {
|
|
||||||
t.Skip("git not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
|
|
||||||
os.MkdirAll(dmsPath, 0o755)
|
|
||||||
|
|
||||||
originalHome := os.Getenv("HOME")
|
|
||||||
defer os.Setenv("HOME", originalHome)
|
|
||||||
os.Setenv("HOME", tempDir)
|
|
||||||
|
|
||||||
exec.Command("git", "init", dmsPath).Run()
|
|
||||||
exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run()
|
|
||||||
exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run()
|
|
||||||
exec.Command("git", "-C", dmsPath, "checkout", "-b", "master").Run()
|
|
||||||
|
|
||||||
testFile := filepath.Join(dmsPath, "test.txt")
|
|
||||||
os.WriteFile(testFile, []byte("test"), 0o644)
|
|
||||||
exec.Command("git", "-C", dmsPath, "add", ".").Run()
|
|
||||||
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
|
|
||||||
|
|
||||||
version, err := GetCurrentDMSVersion()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetCurrentDMSVersion() failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if version == "" {
|
|
||||||
t.Error("Expected non-empty version")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(version) < 7 {
|
|
||||||
t.Errorf("Expected version with branch@commit format, got %s", version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVersionInfo_IsGit(t *testing.T) {
|
func TestVersionInfo_IsGit(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
current string
|
current string
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ Section: x11
|
|||||||
Priority: optional
|
Priority: optional
|
||||||
Maintainer: Avenge Media <AvengeMedia.US@gmail.com>
|
Maintainer: Avenge Media <AvengeMedia.US@gmail.com>
|
||||||
Build-Depends: debhelper-compat (= 13),
|
Build-Depends: debhelper-compat (= 13),
|
||||||
golang-go | golang (>= 2:1.22~) | golang-any
|
|
||||||
Standards-Version: 4.6.2
|
Standards-Version: 4.6.2
|
||||||
Homepage: https://github.com/AvengeMedia/DankMaterialShell
|
Homepage: https://github.com/AvengeMedia/DankMaterialShell
|
||||||
Vcs-Browser: https://github.com/AvengeMedia/DankMaterialShell
|
Vcs-Browser: https://github.com/AvengeMedia/DankMaterialShell
|
||||||
|
|||||||
@@ -27,20 +27,30 @@ override_dh_auto_build:
|
|||||||
# Verify core directory exists (native package format has source at root)
|
# Verify core directory exists (native package format has source at root)
|
||||||
test -d core || (echo "ERROR: core directory not found!" && exit 1)
|
test -d core || (echo "ERROR: core directory not found!" && exit 1)
|
||||||
|
|
||||||
# Pin go.mod and vendor/modules.txt to the installed Go toolchain version
|
# Bundled Go at repo root (go$$VER.linux-{amd64,arm64}.tar.gz); packaged via obs-upload.sh
|
||||||
GO_INSTALLED=$$(go version | grep -oP 'go\K[0-9]+\.[0-9]+'); \
|
GO_TOOLCHAIN_VERSION=$$(grep -m1 '^go ' core/go.mod | awk '{print $$2}'); \
|
||||||
sed -i "s/^go [0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$$/go $${GO_INSTALLED}/" core/go.mod; \
|
case "$(DEB_HOST_ARCH)" in \
|
||||||
sed -i "s/^\(## explicit; go \)[0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$$/\1$${GO_INSTALLED}/" core/vendor/modules.txt
|
amd64) GO_LINUX_ARCH=amd64 ;; \
|
||||||
|
arm64) GO_LINUX_ARCH=arm64 ;; \
|
||||||
# Build dms-cli (single shell to preserve variables; arch: Debian amd64/arm64 -> Makefile amd64/arm64)
|
*) echo "ERROR: Unsupported architecture: $(DEB_HOST_ARCH)" && exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
GO_TARBALL="$(CURDIR)/go$$GO_TOOLCHAIN_VERSION.linux-$$GO_LINUX_ARCH.tar.gz"; \
|
||||||
|
test -f "$$GO_TARBALL" || (echo "ERROR: Missing bundled Go toolchain $$GO_TARBALL" && exit 1); \
|
||||||
|
rm -rf "$(CURDIR)/go-bootstrap" "$(CURDIR)/.go-toolchain"; \
|
||||||
|
mkdir -p "$(CURDIR)/go-bootstrap"; \
|
||||||
|
tar -xzf "$$GO_TARBALL" -C "$(CURDIR)/go-bootstrap"; \
|
||||||
|
mv "$(CURDIR)/go-bootstrap/go" "$(CURDIR)/.go-toolchain"; \
|
||||||
|
export PATH="$(CURDIR)/.go-toolchain/bin:$$PATH"; \
|
||||||
|
export GOROOT="$(CURDIR)/.go-toolchain"; \
|
||||||
|
go version; \
|
||||||
|
sed -i "s/^go [0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$$/go $$GO_TOOLCHAIN_VERSION/" core/go.mod; \
|
||||||
|
sed -i "s/^\(## explicit; go \)[0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$$/\1$$GO_TOOLCHAIN_VERSION/" core/vendor/modules.txt; \
|
||||||
VERSION="$(UPSTREAM_VERSION)"; \
|
VERSION="$(UPSTREAM_VERSION)"; \
|
||||||
COMMIT=$$(echo "$(UPSTREAM_VERSION)" | grep -oP '(?<=git)[0-9]+\.[a-f0-9]+' | cut -d. -f2 | head -c8 || echo "unknown"); \
|
COMMIT=$$(echo "$(UPSTREAM_VERSION)" | grep -oP '(?<=git)[0-9]+\.[a-f0-9]+' | cut -d. -f2 | head -c8 || echo "unknown"); \
|
||||||
if [ "$(DEB_HOST_ARCH)" = "amd64" ]; then \
|
if [ "$(DEB_HOST_ARCH)" = "amd64" ]; then \
|
||||||
MAKE_ARCH=amd64; \
|
MAKE_ARCH=amd64; \
|
||||||
BINARY_NAME=dms-linux-amd64; \
|
|
||||||
elif [ "$(DEB_HOST_ARCH)" = "arm64" ]; then \
|
elif [ "$(DEB_HOST_ARCH)" = "arm64" ]; then \
|
||||||
MAKE_ARCH=arm64; \
|
MAKE_ARCH=arm64; \
|
||||||
BINARY_NAME=dms-linux-arm64; \
|
|
||||||
else \
|
else \
|
||||||
echo "ERROR: Unsupported architecture: $(DEB_HOST_ARCH)" && exit 1; \
|
echo "ERROR: Unsupported architecture: $(DEB_HOST_ARCH)" && exit 1; \
|
||||||
fi; \
|
fi; \
|
||||||
@@ -80,4 +90,5 @@ override_dh_auto_clean:
|
|||||||
rm -f dms
|
rm -f dms
|
||||||
rm -rf core/bin
|
rm -rf core/bin
|
||||||
rm -rf debian/tmp-home
|
rm -rf debian/tmp-home
|
||||||
|
rm -rf go-bootstrap .go-toolchain
|
||||||
dh_auto_clean
|
dh_auto_clean
|
||||||
|
|||||||
52
distro/nix/tests/default.nix
Normal file
52
distro/nix/tests/default.nix
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
self,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
rec {
|
||||||
|
all = pkgs.symlinkJoin {
|
||||||
|
name = "dms-nixos-tests";
|
||||||
|
paths = [
|
||||||
|
nixos-module
|
||||||
|
nixos-service-start-module
|
||||||
|
greeter-niri-module
|
||||||
|
niri-home-module
|
||||||
|
home-manager-module
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
nixos-module = import ./nixos-module.nix {
|
||||||
|
inherit
|
||||||
|
self
|
||||||
|
pkgs
|
||||||
|
;
|
||||||
|
};
|
||||||
|
|
||||||
|
nixos-service-start-module = import ./nixos-service-start-module.nix {
|
||||||
|
inherit
|
||||||
|
self
|
||||||
|
pkgs
|
||||||
|
;
|
||||||
|
};
|
||||||
|
|
||||||
|
greeter-niri-module = import ./greeter-niri-module.nix {
|
||||||
|
inherit
|
||||||
|
self
|
||||||
|
pkgs
|
||||||
|
;
|
||||||
|
};
|
||||||
|
|
||||||
|
niri-home-module = import ./niri-home-module.nix {
|
||||||
|
inherit
|
||||||
|
self
|
||||||
|
pkgs
|
||||||
|
;
|
||||||
|
};
|
||||||
|
|
||||||
|
home-manager-module = import ./home-manager-module.nix {
|
||||||
|
inherit
|
||||||
|
self
|
||||||
|
pkgs
|
||||||
|
;
|
||||||
|
};
|
||||||
|
}
|
||||||
60
distro/nix/tests/greeter-niri-module.nix
Normal file
60
distro/nix/tests/greeter-niri-module.nix
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
self,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
pkgs.testers.runNixOSTest {
|
||||||
|
name = "dms-greeter-niri-module";
|
||||||
|
|
||||||
|
nodes.machine = {
|
||||||
|
imports = [
|
||||||
|
self.nixosModules.greeter
|
||||||
|
];
|
||||||
|
|
||||||
|
users.groups.greeter = { };
|
||||||
|
users.users.greeter = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "greeter";
|
||||||
|
};
|
||||||
|
|
||||||
|
services.greetd.settings.default_session.user = "greeter";
|
||||||
|
|
||||||
|
programs.niri.enable = true;
|
||||||
|
|
||||||
|
programs.dank-material-shell.greeter = {
|
||||||
|
enable = true;
|
||||||
|
compositor.name = "niri";
|
||||||
|
};
|
||||||
|
|
||||||
|
system.stateVersion = "25.11";
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
import re
|
||||||
|
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
machine.wait_for_unit("greetd.service")
|
||||||
|
|
||||||
|
machine.succeed("systemctl is-enabled greetd.service")
|
||||||
|
machine.succeed("systemctl is-active greetd.service")
|
||||||
|
|
||||||
|
greetd_unit = machine.succeed("cat /etc/systemd/system/greetd.service")
|
||||||
|
config_match = re.search(r'--config (/nix/store[^ ]+-greetd.toml)', greetd_unit)
|
||||||
|
if config_match is None:
|
||||||
|
raise AssertionError(greetd_unit)
|
||||||
|
|
||||||
|
greetd_config_path = config_match.group(1)
|
||||||
|
greetd_config = machine.succeed(f"cat {greetd_config_path}")
|
||||||
|
t.assertIn("dms-greeter", greetd_config)
|
||||||
|
|
||||||
|
script_match = re.search(r'command\s*=\s*"([^"]+/bin/dms-greeter)"', greetd_config)
|
||||||
|
if script_match is None:
|
||||||
|
raise AssertionError(greetd_config)
|
||||||
|
|
||||||
|
script_path = script_match.group(1)
|
||||||
|
script = machine.succeed(f"cat {script_path}")
|
||||||
|
t.assertIn("--command", script)
|
||||||
|
t.assertIn("niri", script)
|
||||||
|
t.assertIn("/share/quickshell/dms", script)
|
||||||
|
'';
|
||||||
|
}
|
||||||
107
distro/nix/tests/home-manager-module.nix
Normal file
107
distro/nix/tests/home-manager-module.nix
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
self,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
homeManagerNixosModule =
|
||||||
|
(fetchTarball {
|
||||||
|
url = "https://github.com/nix-community/home-manager/archive/e82d4a4ecd18363aa2054cbaa3e32e4134c3dbf4.tar.gz";
|
||||||
|
sha256 = "sha256-ZTYDofOM3/PJhRF1EuBh6uibm+DmkhU7Wor6mMN7YTc=";
|
||||||
|
})
|
||||||
|
+ "/nixos";
|
||||||
|
in
|
||||||
|
pkgs.testers.runNixOSTest {
|
||||||
|
name = "dms-home-manager-module";
|
||||||
|
|
||||||
|
nodes.machine = {
|
||||||
|
...
|
||||||
|
}: {
|
||||||
|
imports = [
|
||||||
|
homeManagerNixosModule
|
||||||
|
];
|
||||||
|
|
||||||
|
users.users.danklinux = {
|
||||||
|
isNormalUser = true;
|
||||||
|
createHome = true;
|
||||||
|
home = "/home/danklinux";
|
||||||
|
extraGroups = [ "wheel" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
home-manager.useGlobalPkgs = true;
|
||||||
|
home-manager.useUserPackages = true;
|
||||||
|
|
||||||
|
home-manager.users.danklinux = {
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}: {
|
||||||
|
imports = [
|
||||||
|
self.homeModules.dank-material-shell
|
||||||
|
];
|
||||||
|
|
||||||
|
home.username = "danklinux";
|
||||||
|
home.homeDirectory = "/home/danklinux";
|
||||||
|
home.stateVersion = "25.11";
|
||||||
|
|
||||||
|
programs.dank-material-shell = {
|
||||||
|
enable = true;
|
||||||
|
systemd = {
|
||||||
|
enable = true;
|
||||||
|
target = "default.target";
|
||||||
|
};
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
theme = "integration-test";
|
||||||
|
};
|
||||||
|
|
||||||
|
clipboardSettings = {
|
||||||
|
maxItems = 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
session = {
|
||||||
|
startedFrom = "nixos-test";
|
||||||
|
};
|
||||||
|
|
||||||
|
plugins.TestPlugin = {
|
||||||
|
enable = true;
|
||||||
|
src = pkgs.runCommand "dms-test-plugin" { } ''
|
||||||
|
mkdir -p "$out"
|
||||||
|
echo plugin > "$out/plugin.txt"
|
||||||
|
'';
|
||||||
|
settings = {
|
||||||
|
enabled = true;
|
||||||
|
source = "test";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
system.stateVersion = "25.11";
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
import json
|
||||||
|
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
|
||||||
|
machine.succeed("su -- danklinux -c 'command -v dms'")
|
||||||
|
machine.succeed("su -- danklinux -c 'test -f ~/.config/DankMaterialShell/settings.json'")
|
||||||
|
machine.succeed("su -- danklinux -c 'test -f ~/.config/DankMaterialShell/clsettings.json'")
|
||||||
|
machine.succeed("su -- danklinux -c 'test -f ~/.config/DankMaterialShell/plugin_settings.json'")
|
||||||
|
machine.succeed("su -- danklinux -c 'test -e ~/.config/DankMaterialShell/plugins/TestPlugin'")
|
||||||
|
machine.succeed("su -- danklinux -c 'test -f ~/.local/state/DankMaterialShell/session.json'")
|
||||||
|
|
||||||
|
settings = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.config/DankMaterialShell/settings.json'"))
|
||||||
|
clipboard = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.config/DankMaterialShell/clsettings.json'"))
|
||||||
|
session = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.local/state/DankMaterialShell/session.json'"))
|
||||||
|
plugins = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.config/DankMaterialShell/plugin_settings.json'"))
|
||||||
|
doctor = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
|
||||||
|
|
||||||
|
t.assertEqual(settings["theme"], "integration-test")
|
||||||
|
t.assertEqual(clipboard["maxItems"], 10)
|
||||||
|
t.assertEqual(session["startedFrom"], "nixos-test")
|
||||||
|
t.assertTrue(plugins["TestPlugin"]["enabled"])
|
||||||
|
t.assertEqual(plugins["TestPlugin"]["source"], "test")
|
||||||
|
t.assertIsInstance(doctor.get("results"), list)
|
||||||
|
'';
|
||||||
|
}
|
||||||
84
distro/nix/tests/niri-home-module.nix
Normal file
84
distro/nix/tests/niri-home-module.nix
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
self,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
homeManagerNixosModule =
|
||||||
|
(fetchTarball {
|
||||||
|
url = "https://github.com/nix-community/home-manager/archive/e82d4a4ecd18363aa2054cbaa3e32e4134c3dbf4.tar.gz";
|
||||||
|
sha256 = "sha256-ZTYDofOM3/PJhRF1EuBh6uibm+DmkhU7Wor6mMN7YTc=";
|
||||||
|
})
|
||||||
|
+ "/nixos";
|
||||||
|
|
||||||
|
niriFlake = builtins.getFlake "github:sodiboo/niri-flake/2bb22af2985e5f3cfd051b3d977ebfbf81126280?narHash=sha256-ooPmu%2B8tqOGh4kozPW4rJC7Y7WM/FHtEY3OK1PoNW7g%3D";
|
||||||
|
|
||||||
|
fakeNiri = (pkgs.writeScriptBin "niri" "") // {
|
||||||
|
cargoBuildNoDefaultFeatures = false;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
pkgs.testers.runNixOSTest {
|
||||||
|
name = "dms-niri-home-module";
|
||||||
|
|
||||||
|
nodes.machine = {
|
||||||
|
...
|
||||||
|
}: {
|
||||||
|
imports = [
|
||||||
|
homeManagerNixosModule
|
||||||
|
];
|
||||||
|
|
||||||
|
users.users.danklinux = {
|
||||||
|
isNormalUser = true;
|
||||||
|
createHome = true;
|
||||||
|
home = "/home/danklinux";
|
||||||
|
extraGroups = [ "wheel" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
home-manager.useGlobalPkgs = true;
|
||||||
|
home-manager.useUserPackages = true;
|
||||||
|
|
||||||
|
environment.pathsToLink = [
|
||||||
|
"/share/applications"
|
||||||
|
"/share/xdg-desktop-portal"
|
||||||
|
];
|
||||||
|
|
||||||
|
home-manager.users.danklinux = {
|
||||||
|
...
|
||||||
|
}: {
|
||||||
|
imports = [
|
||||||
|
self.homeModules.dank-material-shell
|
||||||
|
niriFlake.homeModules.niri
|
||||||
|
self.homeModules.niri
|
||||||
|
];
|
||||||
|
|
||||||
|
home.username = "danklinux";
|
||||||
|
home.homeDirectory = "/home/danklinux";
|
||||||
|
home.stateVersion = "25.11";
|
||||||
|
|
||||||
|
programs.niri = {
|
||||||
|
enable = true;
|
||||||
|
package = fakeNiri; # avoids niri from being compiled in the CI
|
||||||
|
};
|
||||||
|
|
||||||
|
programs.dank-material-shell = {
|
||||||
|
enable = true;
|
||||||
|
niri = {
|
||||||
|
enableKeybinds = false;
|
||||||
|
enableSpawn = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
system.stateVersion = "25.11";
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
|
||||||
|
machine.succeed("su -- danklinux -c 'test -f ~/.config/niri/config.kdl'")
|
||||||
|
machine.succeed("su -- danklinux -c 'grep -F \"include \\\"dms/binds.kdl\\\"\" ~/.config/niri/config.kdl'")
|
||||||
|
machine.succeed("su -- danklinux -c 'grep -F \"include \\\"hm.kdl\\\"\" ~/.config/niri/config.kdl'")
|
||||||
|
machine.succeed("su -- danklinux -c 'grep -F \"spawn-at-startup\" ~/.config/niri/hm.kdl'")
|
||||||
|
machine.succeed("su -- danklinux -c 'grep -F \"\\\"dms\\\" \\\"run\\\"\" ~/.config/niri/hm.kdl'")
|
||||||
|
'';
|
||||||
|
}
|
||||||
47
distro/nix/tests/nixos-module.nix
Normal file
47
distro/nix/tests/nixos-module.nix
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
self,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
pkgs.testers.runNixOSTest {
|
||||||
|
name = "dms-nixos-module";
|
||||||
|
|
||||||
|
nodes.machine = {
|
||||||
|
imports = [
|
||||||
|
self.nixosModules.dank-material-shell
|
||||||
|
];
|
||||||
|
|
||||||
|
users.users.danklinux = {
|
||||||
|
isNormalUser = true;
|
||||||
|
extraGroups = [ "wheel" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
programs.dank-material-shell = {
|
||||||
|
enable = true;
|
||||||
|
systemd.enable = true;
|
||||||
|
plugins = {
|
||||||
|
TestPlugin = {
|
||||||
|
src = pkgs.emptyDirectory;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
system.stateVersion = "25.11";
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
import json
|
||||||
|
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
|
||||||
|
machine.succeed("command -v dms")
|
||||||
|
machine.succeed("command -v quickshell")
|
||||||
|
machine.succeed("su -- danklinux -c 'dms --help >/dev/null'")
|
||||||
|
machine.succeed("test -d /etc/xdg/quickshell/dms-plugins")
|
||||||
|
machine.succeed("test -f /run/current-system/sw/lib/systemd/user/dms.service")
|
||||||
|
|
||||||
|
payload = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
|
||||||
|
t.assertIn("summary", payload)
|
||||||
|
t.assertIsInstance(payload.get("results"), list)
|
||||||
|
'';
|
||||||
|
}
|
||||||
48
distro/nix/tests/nixos-service-start-module.nix
Normal file
48
distro/nix/tests/nixos-service-start-module.nix
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
self,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
fakeDms = pkgs.writeShellScriptBin "dms" ''
|
||||||
|
printf '%s\n' "$@" > /tmp/dms-service-args
|
||||||
|
exec ${pkgs.coreutils}/bin/sleep 300
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
pkgs.testers.runNixOSTest {
|
||||||
|
name = "dms-nixos-service-start-module";
|
||||||
|
|
||||||
|
nodes.machine = {
|
||||||
|
imports = [
|
||||||
|
self.nixosModules.dank-material-shell
|
||||||
|
];
|
||||||
|
|
||||||
|
users.users.danklinux = {
|
||||||
|
isNormalUser = true;
|
||||||
|
linger = true;
|
||||||
|
extraGroups = [ "wheel" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
programs.dank-material-shell = {
|
||||||
|
enable = true;
|
||||||
|
package = fakeDms;
|
||||||
|
systemd = {
|
||||||
|
enable = true;
|
||||||
|
target = "default.target";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
system.stateVersion = "25.11";
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
machine.wait_for_unit("user@1000.service")
|
||||||
|
|
||||||
|
machine.succeed("systemctl --machine=danklinux@ --user start dms.service")
|
||||||
|
machine.wait_until_succeeds("systemctl --machine=danklinux@ --user is-active dms.service")
|
||||||
|
machine.wait_until_succeeds("test -f /tmp/dms-service-args")
|
||||||
|
machine.succeed("grep -Fx run /tmp/dms-service-args")
|
||||||
|
machine.succeed("grep -Fx -- --session /tmp/dms-service-args")
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
%global debug_package %{nil}
|
%global debug_package %{nil}
|
||||||
|
%global go_toolchain_version 1.26.1
|
||||||
|
|
||||||
Name: dms-git
|
Name: dms-git
|
||||||
Version: 1.0.2+git2528.d336866f
|
Version: 1.4.0+git2528.d336866f
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Epoch: 2
|
Epoch: 2
|
||||||
Summary: DankMaterialShell - Material 3 inspired shell (git nightly)
|
Summary: DankMaterialShell - Material 3 inspired shell (git nightly)
|
||||||
@@ -9,9 +10,9 @@ Summary: DankMaterialShell - Material 3 inspired shell (git nightly)
|
|||||||
License: MIT
|
License: MIT
|
||||||
URL: https://github.com/AvengeMedia/DankMaterialShell
|
URL: https://github.com/AvengeMedia/DankMaterialShell
|
||||||
Source0: dms-git-source.tar.gz
|
Source0: dms-git-source.tar.gz
|
||||||
|
Source1: go%{go_toolchain_version}.linux-amd64.tar.gz
|
||||||
|
Source2: go%{go_toolchain_version}.linux-arm64.tar.gz
|
||||||
|
|
||||||
BuildRequires: golang >= 1.22
|
|
||||||
BuildRequires: golang-packaging
|
|
||||||
BuildRequires: git-core
|
BuildRequires: git-core
|
||||||
BuildRequires: systemd-rpm-macros
|
BuildRequires: systemd-rpm-macros
|
||||||
|
|
||||||
@@ -47,6 +48,28 @@ and fixes. Includes pre-built dms CLI binary and QML shell files.
|
|||||||
test -d core/vendor || (echo "ERROR: Go vendor directory missing!" && exit 1)
|
test -d core/vendor || (echo "ERROR: Go vendor directory missing!" && exit 1)
|
||||||
|
|
||||||
%build
|
%build
|
||||||
|
# Bundled Go toolchain
|
||||||
|
case "%{_arch}" in
|
||||||
|
x86_64)
|
||||||
|
GO_TARBALL="%{_sourcedir}/go%{go_toolchain_version}.linux-amd64.tar.gz"
|
||||||
|
;;
|
||||||
|
aarch64)
|
||||||
|
GO_TARBALL="%{_sourcedir}/go%{go_toolchain_version}.linux-arm64.tar.gz"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported architecture for bundled Go: %{_arch}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
rm -rf "%{_builddir}/go-bootstrap" "%{_builddir}/.go-toolchain"
|
||||||
|
mkdir -p "%{_builddir}/go-bootstrap"
|
||||||
|
tar -xzf "$GO_TARBALL" -C "%{_builddir}/go-bootstrap"
|
||||||
|
mv "%{_builddir}/go-bootstrap/go" "%{_builddir}/.go-toolchain"
|
||||||
|
|
||||||
|
export GOROOT="%{_builddir}/.go-toolchain"
|
||||||
|
export PATH="$GOROOT/bin:$PATH"
|
||||||
|
|
||||||
# Create Go cache directories (OBS build env may have restricted HOME)
|
# Create Go cache directories (OBS build env may have restricted HOME)
|
||||||
export HOME=%{_builddir}/go-home
|
export HOME=%{_builddir}/go-home
|
||||||
export GOCACHE=%{_builddir}/go-cache
|
export GOCACHE=%{_builddir}/go-cache
|
||||||
@@ -56,10 +79,11 @@ mkdir -p $HOME $GOCACHE $GOMODCACHE
|
|||||||
# OBS has no network access, so use local toolchain only
|
# OBS has no network access, so use local toolchain only
|
||||||
export GOTOOLCHAIN=local
|
export GOTOOLCHAIN=local
|
||||||
|
|
||||||
# Pin go.mod and vendor/modules.txt to the installed Go toolchain version
|
go version
|
||||||
GO_INSTALLED=$(go version | grep -oP 'go\K[0-9]+\.[0-9]+')
|
|
||||||
sed -i "s/^go [0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$/go ${GO_INSTALLED}/" core/go.mod
|
# Pin go.mod and vendor/modules.txt to the bundled Go toolchain version
|
||||||
sed -i "s/^\(## explicit; go \)[0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$/\1${GO_INSTALLED}/" core/vendor/modules.txt
|
sed -i "s/^go [0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$/go %{go_toolchain_version}/" core/go.mod
|
||||||
|
sed -i "s/^\(## explicit; go \)[0-9]\+\.[0-9]\+\(\.[0-9]*\)\?$/\1%{go_toolchain_version}/" core/vendor/modules.txt
|
||||||
|
|
||||||
# Extract version info for embedding in binary
|
# Extract version info for embedding in binary
|
||||||
VERSION="%{version}"
|
VERSION="%{version}"
|
||||||
|
|||||||
@@ -115,6 +115,40 @@ osc_retry() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Bundled Go for dms-git OBS builds (offline VM); filenames must match distro/opensuse/dms-git.spec Source1/2.
|
||||||
|
GO_TOOLCHAIN_CACHE="${GO_TOOLCHAIN_CACHE:-$HOME/.cache/dms-obs-go-toolchain}"
|
||||||
|
|
||||||
|
dms_git_go_toolchain_version() {
|
||||||
|
grep -m1 '^go ' "$REPO_ROOT/core/go.mod" 2>/dev/null | awk '{print $2}'
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_dms_git_go_tarballs() {
|
||||||
|
local dest="$1"
|
||||||
|
local ver arch url cached
|
||||||
|
ver="$(dms_git_go_toolchain_version)"
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
echo "ERROR: Could not read Go version from core/go.mod"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$GO_TOOLCHAIN_CACHE/$ver"
|
||||||
|
for arch in amd64 arm64; do
|
||||||
|
url="https://go.dev/dl/go${ver}.linux-${arch}.tar.gz"
|
||||||
|
cached="$GO_TOOLCHAIN_CACHE/$ver/go${ver}.linux-${arch}.tar.gz"
|
||||||
|
if [[ ! -f "$cached" ]]; then
|
||||||
|
echo " Downloading Go ${ver} (${arch})…"
|
||||||
|
if wget -q -O "${cached}.tmp" "$url" 2>/dev/null || curl -L -f -s -o "${cached}.tmp" "$url"; then
|
||||||
|
mv "${cached}.tmp" "$cached"
|
||||||
|
else
|
||||||
|
rm -f "${cached}.tmp"
|
||||||
|
echo "ERROR: Failed to download $url"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
cp -f "$cached" "$dest/go${ver}.linux-${arch}.tar.gz"
|
||||||
|
echo " ✓ Go toolchain ready: $dest/go${ver}.linux-${arch}.tar.gz"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# Parameters:
|
# Parameters:
|
||||||
# $1 = PROJECT
|
# $1 = PROJECT
|
||||||
# $2 = PACKAGE
|
# $2 = PACKAGE
|
||||||
@@ -205,9 +239,15 @@ update_debian_dms_greeter_service() {
|
|||||||
|
|
||||||
update_opensuse_git_spec() {
|
update_opensuse_git_spec() {
|
||||||
local spec_path="$1"
|
local spec_path="$1"
|
||||||
|
local go_ver
|
||||||
if [[ -z "$spec_path" || ! -f "$spec_path" ]]; then
|
if [[ -z "$spec_path" || ! -f "$spec_path" ]]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
go_ver="$(dms_git_go_toolchain_version)"
|
||||||
|
if [[ -n "$go_ver" ]] && grep -q '^%global go_toolchain_version' "$spec_path"; then
|
||||||
|
sed -i "s/^%global go_toolchain_version .*/%global go_toolchain_version ${go_ver}/" "$spec_path"
|
||||||
|
echo " Synced %global go_toolchain_version to ${go_ver} (core/go.mod)"
|
||||||
|
fi
|
||||||
if [[ -n "$CHANGELOG_VERSION" ]]; then
|
if [[ -n "$CHANGELOG_VERSION" ]]; then
|
||||||
echo " Updating OpenSUSE spec to version $CHANGELOG_VERSION"
|
echo " Updating OpenSUSE spec to version $CHANGELOG_VERSION"
|
||||||
sed -i "s/^Version:.*/Version: $CHANGELOG_VERSION/" "$spec_path"
|
sed -i "s/^Version:.*/Version: $CHANGELOG_VERSION/" "$spec_path"
|
||||||
@@ -438,7 +478,7 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ -f "distro/opensuse/$PACKAGE.spec" ]];
|
|||||||
echo " - Copying $PACKAGE.spec for OpenSUSE"
|
echo " - Copying $PACKAGE.spec for OpenSUSE"
|
||||||
cp "distro/opensuse/$PACKAGE.spec" "$WORK_DIR/"
|
cp "distro/opensuse/$PACKAGE.spec" "$WORK_DIR/"
|
||||||
|
|
||||||
if [[ "$PACKAGE" == *"-git" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
|
if [[ "$PACKAGE" == *"-git" ]]; then
|
||||||
update_opensuse_git_spec "$WORK_DIR/$PACKAGE.spec"
|
update_opensuse_git_spec "$WORK_DIR/$PACKAGE.spec"
|
||||||
elif [[ "$PACKAGE" == "dms-greeter" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
|
elif [[ "$PACKAGE" == "dms-greeter" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
|
||||||
DMS_GREETER_BASE_VERSION=$(echo "$CHANGELOG_VERSION" | sed -E 's/^([0-9]+(\.[0-9]+)*).*/\1/')
|
DMS_GREETER_BASE_VERSION=$(echo "$CHANGELOG_VERSION" | sed -E 's/^([0-9]+(\.[0-9]+)*).*/\1/')
|
||||||
@@ -570,6 +610,11 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
|
|||||||
|
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
rm -rf "$OBS_TARBALL_DIR"
|
rm -rf "$OBS_TARBALL_DIR"
|
||||||
|
|
||||||
|
if [[ "$PACKAGE" == "dms-git" ]]; then
|
||||||
|
echo " - Staging bundled Go toolchains for RPM (Source1/Source2)"
|
||||||
|
ensure_dms_git_go_tarballs "$WORK_DIR"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo " - Warning: Could not obtain source for OpenSUSE tarball"
|
echo " - Warning: Could not obtain source for OpenSUSE tarball"
|
||||||
@@ -830,12 +875,18 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
|||||||
esac
|
esac
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
rm -rf "$OBS_TARBALL_DIR"
|
rm -rf "$OBS_TARBALL_DIR"
|
||||||
|
|
||||||
|
if [[ "$PACKAGE" == "dms-git" ]]; then
|
||||||
|
echo " - Staging bundled Go toolchains for RPM (Source1/Source2)"
|
||||||
|
ensure_dms_git_go_tarballs "$WORK_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
echo " - OpenSUSE source tarballs created"
|
echo " - OpenSUSE source tarballs created"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copy and update OpenSUSE spec file with the correct version
|
# Copy and update OpenSUSE spec file with the correct version
|
||||||
cp "distro/opensuse/$PACKAGE.spec" "$WORK_DIR/"
|
cp "distro/opensuse/$PACKAGE.spec" "$WORK_DIR/"
|
||||||
if [[ "$PACKAGE" == *"-git" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
|
if [[ "$PACKAGE" == *"-git" ]]; then
|
||||||
update_opensuse_git_spec "$WORK_DIR/$PACKAGE.spec"
|
update_opensuse_git_spec "$WORK_DIR/$PACKAGE.spec"
|
||||||
elif [[ "$PACKAGE" == "dms-greeter" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
|
elif [[ "$PACKAGE" == "dms-greeter" ]] && [[ -n "$CHANGELOG_VERSION" ]]; then
|
||||||
DMS_GREETER_BASE_VERSION=$(echo "$CHANGELOG_VERSION" | sed -E 's/^([0-9]+(\.[0-9]+)*).*/\1/')
|
DMS_GREETER_BASE_VERSION=$(echo "$CHANGELOG_VERSION" | sed -E 's/^([0-9]+(\.[0-9]+)*).*/\1/')
|
||||||
@@ -891,6 +942,11 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$PACKAGE" == "dms-git" ]]; then
|
||||||
|
echo " Bundling Go toolchains into Debian source tree (offline build)"
|
||||||
|
ensure_dms_git_go_tarballs "$SOURCE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
rm -f "$WORK_DIR/$COMBINED_TARBALL"
|
rm -f "$WORK_DIR/$COMBINED_TARBALL"
|
||||||
|
|
||||||
echo " Creating combined tarball: $COMBINED_TARBALL"
|
echo " Creating combined tarball: $COMBINED_TARBALL"
|
||||||
@@ -1055,6 +1111,12 @@ if [[ -n "$OBS_FILES" ]]; then
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Keep pinned Go toolchain archives (bundled for dms-git offline builds)
|
||||||
|
if [[ "$old_file" =~ ^go[0-9].+\.linux-(amd64|arm64)\.tar\.gz$ ]]; then
|
||||||
|
echo " - Keeping Go toolchain tarball: $old_file"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
# Keep current orig tarball for dms-greeter (Debian 3.0 quilt needs it)
|
# Keep current orig tarball for dms-greeter (Debian 3.0 quilt needs it)
|
||||||
UPSTREAM_VER_CLEAN=$(echo "$CHANGELOG_VERSION" | sed 's/-[^-]*$//' 2>/dev/null)
|
UPSTREAM_VER_CLEAN=$(echo "$CHANGELOG_VERSION" | sed 's/-[^-]*$//' 2>/dev/null)
|
||||||
if [[ "$PACKAGE" == "dms-greeter" ]] && [[ "$old_file" == "${PACKAGE}_${UPSTREAM_VER_CLEAN}.orig.tar.gz" ]]; then
|
if [[ "$PACKAGE" == "dms-greeter" ]] && [[ "$old_file" == "${PACKAGE}_${UPSTREAM_VER_CLEAN}.orig.tar.gz" ]]; then
|
||||||
@@ -1130,11 +1192,11 @@ ls -la 2>&1 | head -20
|
|||||||
echo "==> Staging changes"
|
echo "==> Staging changes"
|
||||||
echo "Files to upload:"
|
echo "Files to upload:"
|
||||||
if [[ "$UPLOAD_DEBIAN" == true ]] && [[ "$UPLOAD_OPENSUSE" == true ]]; then
|
if [[ "$UPLOAD_DEBIAN" == true ]] && [[ "$UPLOAD_OPENSUSE" == true ]]; then
|
||||||
ls -lh ./*.tar.gz ./*.tar.xz ./*.tar ./*.spec ./*.dsc _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
ls -lh ./*.tar.gz ./*.tar.xz ./*.tar ./*.spec ./*.dsc _service ./go*.linux-*.tar.gz 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
||||||
elif [[ "$UPLOAD_DEBIAN" == true ]]; then
|
elif [[ "$UPLOAD_DEBIAN" == true ]]; then
|
||||||
ls -lh ./*.tar.gz ./*.dsc _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
ls -lh ./*.tar.gz ./*.dsc _service ./go*.linux-*.tar.gz 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
||||||
elif [[ "$UPLOAD_OPENSUSE" == true ]]; then
|
elif [[ "$UPLOAD_OPENSUSE" == true ]]; then
|
||||||
ls -lh ./*.tar.gz ./*.tar.xz ./*.tar ./*.spec _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
ls -lh ./*.tar.gz ./*.tar.xz ./*.tar ./*.spec _service ./go*.linux-*.tar.gz 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|||||||
160
distro/scripts/ppa-sync-plan.sh
Executable file
160
distro/scripts/ppa-sync-plan.sh
Executable file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a DMS per-series upload plan by comparing Git/GitHub with Launchpad.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PPA_OWNER="avengemedia"
|
||||||
|
LAUNCHPAD_API="https://api.launchpad.net/1.0"
|
||||||
|
SERIES_LIST=(questing resolute)
|
||||||
|
PACKAGE_FILTER="dms-git"
|
||||||
|
REBUILD_RELEASE=""
|
||||||
|
JSON=false
|
||||||
|
|
||||||
|
PACKAGES=(
|
||||||
|
"dms:dms:release"
|
||||||
|
"dms-git:dms-git:git"
|
||||||
|
"dms-greeter:danklinux:release"
|
||||||
|
)
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--package)
|
||||||
|
PACKAGE_FILTER="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--rebuild)
|
||||||
|
REBUILD_RELEASE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--json)
|
||||||
|
JSON=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
latest_tag() {
|
||||||
|
git ls-remote --tags --refs --sort='-v:refname' https://github.com/AvengeMedia/DankMaterialShell.git |
|
||||||
|
sed -n '1s|.*/v\{0,1\}||p'
|
||||||
|
}
|
||||||
|
|
||||||
|
published_version() {
|
||||||
|
local package="$1"
|
||||||
|
local ppa="$2"
|
||||||
|
local series="$3"
|
||||||
|
local series_url="https%3A%2F%2Fapi.launchpad.net%2F1.0%2Fubuntu%2F${series}"
|
||||||
|
local url="${LAUNCHPAD_API}/~${PPA_OWNER}/+archive/ubuntu/${ppa}?ws.op=getPublishedSources&source_name=${package}&status=Published&distro_series=${series_url}"
|
||||||
|
|
||||||
|
curl -fsSL "$url" 2>/dev/null | jq -r '.entries[0].source_package_version // empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
release_base() {
|
||||||
|
echo "$1" | sed -E 's/ppa[0-9]+$//' | sed -E 's/-[0-9]+$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
ppa_suffix() {
|
||||||
|
local version="$1"
|
||||||
|
if [[ "$version" =~ ppa([0-9]+)$ ]]; then
|
||||||
|
echo "${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
echo "0"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
embedded_commit() {
|
||||||
|
echo "$1" | sed -nE 's/.*[+~]git[0-9]+\.([a-f0-9]{7,12}).*/\1/p'
|
||||||
|
}
|
||||||
|
|
||||||
|
target_ppa() {
|
||||||
|
local series="$1"
|
||||||
|
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||||
|
if [[ "$series" == "resolute" ]]; then
|
||||||
|
echo $((REBUILD_RELEASE + 1))
|
||||||
|
else
|
||||||
|
echo "$REBUILD_RELEASE"
|
||||||
|
fi
|
||||||
|
elif [[ "$series" == "resolute" ]]; then
|
||||||
|
echo "2"
|
||||||
|
else
|
||||||
|
echo "1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild_release_is_newer() {
|
||||||
|
local series="$1"
|
||||||
|
local published="$2"
|
||||||
|
local requested current
|
||||||
|
|
||||||
|
[[ -n "$REBUILD_RELEASE" ]] || return 1
|
||||||
|
|
||||||
|
requested="$(target_ppa "$series")"
|
||||||
|
current="$(ppa_suffix "$published")"
|
||||||
|
[[ "$requested" -gt "$current" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
include_package() {
|
||||||
|
local package="$1"
|
||||||
|
[[ "$PACKAGE_FILTER" == "all" || "$PACKAGE_FILTER" == "$package" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
CURRENT_COMMIT="$(git rev-parse --short=8 HEAD)"
|
||||||
|
LATEST_TAG=""
|
||||||
|
TARGETS=()
|
||||||
|
|
||||||
|
for pkg_info in "${PACKAGES[@]}"; do
|
||||||
|
IFS=':' read -r package ppa type <<< "$pkg_info"
|
||||||
|
include_package "$package" || continue
|
||||||
|
|
||||||
|
for series in "${SERIES_LIST[@]}"; do
|
||||||
|
ppa_version="$(published_version "$package" "$ppa" "$series")"
|
||||||
|
needs_update=false
|
||||||
|
reason=""
|
||||||
|
|
||||||
|
if [[ -z "$ppa_version" ]]; then
|
||||||
|
needs_update=true
|
||||||
|
reason="missing from ${series}"
|
||||||
|
elif [[ "$type" == "git" ]]; then
|
||||||
|
ppa_commit="$(embedded_commit "$ppa_version")"
|
||||||
|
if [[ "$ppa_commit" != "$CURRENT_COMMIT" ]]; then
|
||||||
|
needs_update=true
|
||||||
|
reason="commit ${ppa_commit:-none} -> ${CURRENT_COMMIT}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ -z "$LATEST_TAG" ]]; then
|
||||||
|
LATEST_TAG="$(latest_tag)"
|
||||||
|
fi
|
||||||
|
ppa_base="$(release_base "$ppa_version")"
|
||||||
|
if [[ "$ppa_base" != "$LATEST_TAG" ]]; then
|
||||||
|
needs_update=true
|
||||||
|
reason="version ${ppa_base:-none} -> ${LATEST_TAG}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$needs_update" != "true" ]] && rebuild_release_is_newer "$series" "$ppa_version"; then
|
||||||
|
needs_update=true
|
||||||
|
reason="rebuild ppa$(ppa_suffix "$ppa_version") -> ppa$(target_ppa "$series")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$needs_update" == "true" ]]; then
|
||||||
|
target="${package}:${series}:$(target_ppa "$series")"
|
||||||
|
TARGETS+=("$target")
|
||||||
|
echo "${package}/${series}: ${reason} (published: ${ppa_version:-none})" >&2
|
||||||
|
else
|
||||||
|
echo "${package}/${series}: current (${ppa_version})" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$JSON" == "true" ]]; then
|
||||||
|
if [[ ${#TARGETS[@]} -eq 0 ]]; then
|
||||||
|
echo "[]"
|
||||||
|
else
|
||||||
|
printf '%s\n' "${TARGETS[@]}" | jq -R -s -c 'split("\n")[:-1]'
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "${TARGETS[*]}"
|
||||||
|
fi
|
||||||
@@ -217,6 +217,42 @@ fi
|
|||||||
PACKAGE_DIR=$(cd "$PACKAGE_DIR" && pwd)
|
PACKAGE_DIR=$(cd "$PACKAGE_DIR" && pwd)
|
||||||
PARENT_DIR=$(dirname "$PACKAGE_DIR")
|
PARENT_DIR=$(dirname "$PACKAGE_DIR")
|
||||||
|
|
||||||
|
setup_launchpad_sftp() {
|
||||||
|
if [[ -z "${LAUNCHPAD_SSH_PRIVATE_KEY:-}" ]]; then
|
||||||
|
error "LAUNCHPAD_SSH_PRIVATE_KEY is required for CI SFTP uploads."
|
||||||
|
error "Add a GitHub Actions secret containing a private SSH key whose public key is registered in Launchpad."
|
||||||
|
error "Optional: set LAUNCHPAD_SSH_LOGIN if the Launchpad login is not 'avengemedia'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local ssh_dir="$HOME/.ssh"
|
||||||
|
local key_file="$ssh_dir/launchpad_ppa"
|
||||||
|
local login="${LAUNCHPAD_SSH_LOGIN:-avengemedia}"
|
||||||
|
local strict_host_key_checking="yes"
|
||||||
|
|
||||||
|
mkdir -p "$ssh_dir"
|
||||||
|
chmod 700 "$ssh_dir"
|
||||||
|
printf '%s\n' "$LAUNCHPAD_SSH_PRIVATE_KEY" > "$key_file"
|
||||||
|
chmod 600 "$key_file"
|
||||||
|
|
||||||
|
if ssh-keyscan -H ppa.launchpad.net >> "$ssh_dir/known_hosts" 2>/dev/null; then
|
||||||
|
chmod 600 "$ssh_dir/known_hosts"
|
||||||
|
else
|
||||||
|
warn "Could not prefetch ppa.launchpad.net SSH host key; allowing OpenSSH to trust it on first SFTP connection"
|
||||||
|
strict_host_key_checking="accept-new"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$ssh_dir/config" <<EOF
|
||||||
|
Host ppa.launchpad.net
|
||||||
|
HostName ppa.launchpad.net
|
||||||
|
User ${login}
|
||||||
|
IdentityFile ${key_file}
|
||||||
|
IdentitiesOnly yes
|
||||||
|
StrictHostKeyChecking ${strict_host_key_checking}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$ssh_dir/config"
|
||||||
|
}
|
||||||
|
|
||||||
if [[ ${#SERIES_LIST[@]} -gt 1 ]]; then
|
if [[ ${#SERIES_LIST[@]} -gt 1 ]]; then
|
||||||
SOURCE_FORMAT_LINE=$(head -1 "$PACKAGE_DIR/debian/source/format" 2>/dev/null || echo "")
|
SOURCE_FORMAT_LINE=$(head -1 "$PACKAGE_DIR/debian/source/format" 2>/dev/null || echo "")
|
||||||
IS_NATIVE_DUAL=false
|
IS_NATIVE_DUAL=false
|
||||||
@@ -330,8 +366,30 @@ if [ "$PPA_NAME" = "danklinux" ] || [ "$PPA_NAME" = "dms" ] || [ "$PPA_NAME" = "
|
|||||||
info " - $BUILDINFO"
|
info " - $BUILDINFO"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
LFTP_SCRIPT=$(mktemp)
|
if [[ -n "${GITHUB_ACTIONS:-}" || -n "${CI:-}" ]] && command -v dput >/dev/null 2>&1; then
|
||||||
cat >"$LFTP_SCRIPT" <<EOF
|
setup_launchpad_sftp
|
||||||
|
DPUT_CONFIG=$(mktemp)
|
||||||
|
cat >"$DPUT_CONFIG" <<EOF
|
||||||
|
[avengemedia-${PPA_NAME}]
|
||||||
|
fqdn = ppa.launchpad.net
|
||||||
|
method = sftp
|
||||||
|
incoming = ~avengemedia/ubuntu/${PPA_NAME}/
|
||||||
|
login = ${LAUNCHPAD_SSH_LOGIN:-avengemedia}
|
||||||
|
allow_unsigned_uploads = 0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
info "Using dput for CI upload (SFTP)"
|
||||||
|
if dput -c "$DPUT_CONFIG" "avengemedia-${PPA_NAME}" "$CHANGES_FILE"; then
|
||||||
|
success "Upload successful!"
|
||||||
|
rm -f "$DPUT_CONFIG"
|
||||||
|
else
|
||||||
|
rm -f "$DPUT_CONFIG"
|
||||||
|
error "dput upload failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
LFTP_SCRIPT=$(mktemp)
|
||||||
|
cat >"$LFTP_SCRIPT" <<EOF
|
||||||
cd ~avengemedia/ubuntu/$PPA_NAME/
|
cd ~avengemedia/ubuntu/$PPA_NAME/
|
||||||
lcd $BUILD_DIR
|
lcd $BUILD_DIR
|
||||||
mput $CHANGES_BASENAME
|
mput $CHANGES_BASENAME
|
||||||
@@ -341,13 +399,14 @@ mput $BUILDINFO
|
|||||||
bye
|
bye
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if lftp -d ftp://anonymous:@ppa.launchpad.net <"$LFTP_SCRIPT"; then
|
if lftp -d ftp://anonymous:@ppa.launchpad.net <"$LFTP_SCRIPT"; then
|
||||||
success "Upload successful!"
|
success "Upload successful!"
|
||||||
rm -f "$LFTP_SCRIPT"
|
rm -f "$LFTP_SCRIPT"
|
||||||
else
|
else
|
||||||
error "Upload failed!"
|
error "Upload failed!"
|
||||||
rm -f "$LFTP_SCRIPT"
|
rm -f "$LFTP_SCRIPT"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# This branch should not be reached for DMS packages
|
# This branch should not be reached for DMS packages
|
||||||
|
|||||||
21
docs/IPC.md
21
docs/IPC.md
@@ -396,6 +396,10 @@ Top bar visibility control.
|
|||||||
- Toggle top bar visibility
|
- Toggle top bar visibility
|
||||||
- Returns: Success confirmation with current state
|
- Returns: Success confirmation with current state
|
||||||
|
|
||||||
|
**`toggleReveal`**
|
||||||
|
- Toggle the runtime reveal/tuck state for an autohidden bar
|
||||||
|
- Returns: Success confirmation with current reveal state
|
||||||
|
|
||||||
**`status`**
|
**`status`**
|
||||||
- Get current top bar visibility status
|
- Get current top bar visibility status
|
||||||
- Returns: "visible" or "hidden"
|
- Returns: "visible" or "hidden"
|
||||||
@@ -403,22 +407,35 @@ Top bar visibility control.
|
|||||||
### Examples
|
### Examples
|
||||||
```bash
|
```bash
|
||||||
dms ipc call bar toggle
|
dms ipc call bar toggle
|
||||||
|
dms ipc call bar toggleReveal index 0
|
||||||
dms ipc call bar hide
|
dms ipc call bar hide
|
||||||
dms ipc call bar status
|
dms ipc call bar status
|
||||||
```
|
```
|
||||||
|
|
||||||
## Target: `systemupdater`
|
## Target: `systemupdater`
|
||||||
|
|
||||||
System updater external check request.
|
System updater widget control and background update checks.
|
||||||
|
|
||||||
### Functions
|
### Functions
|
||||||
|
|
||||||
|
**`toggle`**
|
||||||
|
- Toggle the system updater popout open/closed
|
||||||
|
|
||||||
|
**`open`**
|
||||||
|
- Open the system updater popout
|
||||||
|
|
||||||
|
**`close`**
|
||||||
|
- Close the system updater popout
|
||||||
|
|
||||||
**`updatestatus`**
|
**`updatestatus`**
|
||||||
- Trigger a system update check
|
- Trigger a background update check
|
||||||
- Returns: Success confirmation
|
- Returns: Success confirmation
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
```bash
|
```bash
|
||||||
|
dms ipc call systemupdater toggle
|
||||||
|
dms ipc call systemupdater open
|
||||||
|
dms ipc call systemupdater close
|
||||||
dms ipc call systemupdater updatestatus
|
dms ipc call systemupdater updatestatus
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
10
flake.lock
generated
10
flake.lock
generated
@@ -39,16 +39,16 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1766725085,
|
"lastModified": 1776854048,
|
||||||
"narHash": "sha256-O2aMFdDUYJazFrlwL7aSIHbUSEm3ADVZjmf41uBJfHs=",
|
"narHash": "sha256-lLbV66V3RMNp1l8/UelmR4YzoJ5ONtgvEtiUMJATH/o=",
|
||||||
"ref": "refs/heads/master",
|
"ref": "refs/heads/master",
|
||||||
"rev": "41828c4180fb921df7992a5405f5ff05d2ac2fff",
|
"rev": "783c953987dc56ff0601abe6845ed96f1d00495a",
|
||||||
"revCount": 715,
|
"revCount": 806,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.outfoxxed.me/quickshell/quickshell"
|
"url": "https://git.outfoxxed.me/quickshell/quickshell"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"rev": "41828c4180fb921df7992a5405f5ff05d2ac2fff",
|
"rev": "783c953987dc56ff0601abe6845ed96f1d00495a",
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.outfoxxed.me/quickshell/quickshell"
|
"url": "https://git.outfoxxed.me/quickshell/quickshell"
|
||||||
}
|
}
|
||||||
|
|||||||
172
flake.nix
172
flake.nix
@@ -4,7 +4,7 @@
|
|||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
quickshell = {
|
quickshell = {
|
||||||
url = "git+https://git.outfoxxed.me/quickshell/quickshell?rev=41828c4180fb921df7992a5405f5ff05d2ac2fff";
|
url = "git+https://git.outfoxxed.me/quickshell/quickshell?rev=783c953987dc56ff0601abe6845ed96f1d00495a";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
flake-compat = {
|
flake-compat = {
|
||||||
@@ -45,10 +45,12 @@
|
|||||||
nixpkgs.lib.genAttrs [ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ] (
|
nixpkgs.lib.genAttrs [ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ] (
|
||||||
system: fn system nixpkgs.legacyPackages.${system}
|
system: fn system nixpkgs.legacyPackages.${system}
|
||||||
);
|
);
|
||||||
buildDmsPkgs = pkgs: {
|
forEachLinuxSystem =
|
||||||
dms-shell = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
fn:
|
||||||
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
nixpkgs.lib.genAttrs [ "aarch64-linux" "x86_64-linux" ] (
|
||||||
};
|
system: fn system nixpkgs.legacyPackages.${system}
|
||||||
|
);
|
||||||
|
|
||||||
mkModuleWithDmsPkgs =
|
mkModuleWithDmsPkgs =
|
||||||
modulePath:
|
modulePath:
|
||||||
args@{ pkgs, ... }:
|
args@{ pkgs, ... }:
|
||||||
@@ -57,6 +59,7 @@
|
|||||||
(import modulePath (args // { dmsPkgs = buildDmsPkgs pkgs; }))
|
(import modulePath (args // { dmsPkgs = buildDmsPkgs pkgs; }))
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
mkQmlImportPath =
|
mkQmlImportPath =
|
||||||
pkgs: qmlPkgs:
|
pkgs: qmlPkgs:
|
||||||
pkgs.lib.concatStringsSep ":" (map (o: "${o}/${pkgs.qt6.qtbase.qtQmlPrefix}") qmlPkgs);
|
pkgs.lib.concatStringsSep ":" (map (o: "${o}/${pkgs.qt6.qtbase.qtQmlPrefix}") qmlPkgs);
|
||||||
@@ -73,10 +76,11 @@
|
|||||||
qtimageformats
|
qtimageformats
|
||||||
kimageformats
|
kimageformats
|
||||||
];
|
];
|
||||||
in
|
|
||||||
{
|
# Allows downstream modules to provide their own 'pkgs' (with overlays)
|
||||||
packages = forEachSystem (
|
# instead of being forced to use the flake's locked nixpkgs.
|
||||||
system: pkgs:
|
mkDmsShell =
|
||||||
|
pkgs:
|
||||||
let
|
let
|
||||||
mkDate =
|
mkDate =
|
||||||
longDate:
|
longDate:
|
||||||
@@ -94,89 +98,96 @@
|
|||||||
in
|
in
|
||||||
"${cleanVersion}${dateSuffix}${revSuffix}";
|
"${cleanVersion}${dateSuffix}${revSuffix}";
|
||||||
in
|
in
|
||||||
{
|
pkgs.lib.makeOverridable (
|
||||||
dms-shell = pkgs.lib.makeOverridable (
|
{
|
||||||
|
extraQtPackages ? [ ],
|
||||||
|
}:
|
||||||
|
(pkgs.buildGoModule.override { go = goForPkgs pkgs; }) (
|
||||||
|
let
|
||||||
|
rootSrc = ./.;
|
||||||
|
qtPackages = (qmlPkgs pkgs) ++ extraQtPackages;
|
||||||
|
in
|
||||||
{
|
{
|
||||||
extraQtPackages ? [ ],
|
inherit version;
|
||||||
}:
|
pname = "dms-shell";
|
||||||
(pkgs.buildGoModule.override { go = goForPkgs pkgs; }) (
|
src = ./core;
|
||||||
let
|
vendorHash = "sha256-nvxFHQhOfBGl3h51fgYDb39K0NCj+H8mAEyKr1qOwJQ=";
|
||||||
rootSrc = ./.;
|
|
||||||
qtPackages = (qmlPkgs pkgs) ++ extraQtPackages;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
inherit version;
|
|
||||||
pname = "dms-shell";
|
|
||||||
src = ./core;
|
|
||||||
vendorHash = "sha256-dEk7IOd6aQwaxZruxQclN7TGMyb8EJOl6NBWRsoZ9HQ=";
|
|
||||||
|
|
||||||
subPackages = [ "cmd/dms" ];
|
subPackages = [ "cmd/dms" ];
|
||||||
|
|
||||||
ldflags = [
|
ldflags = [
|
||||||
"-s"
|
"-s"
|
||||||
"-w"
|
"-w"
|
||||||
"-X 'main.Version=${version}'"
|
"-X 'main.Version=${version}'"
|
||||||
];
|
];
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
installShellFiles
|
installShellFiles
|
||||||
makeWrapper
|
makeWrapper
|
||||||
];
|
];
|
||||||
|
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
mkdir -p $out/share/quickshell/dms
|
mkdir -p $out/share/quickshell/dms
|
||||||
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
|
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
|
||||||
|
|
||||||
chmod u+w $out/share/quickshell/dms/VERSION
|
chmod u+w $out/share/quickshell/dms/VERSION
|
||||||
echo "${version}" > $out/share/quickshell/dms/VERSION
|
echo "${version}" > $out/share/quickshell/dms/VERSION
|
||||||
|
|
||||||
# Install desktop file and icon
|
# Install desktop file and icon
|
||||||
install -D ${rootSrc}/assets/dms-open.desktop \
|
install -D ${rootSrc}/assets/dms-open.desktop \
|
||||||
$out/share/applications/dms-open.desktop
|
$out/share/applications/dms-open.desktop
|
||||||
install -D ${rootSrc}/core/assets/danklogo.svg \
|
install -D ${rootSrc}/core/assets/danklogo.svg \
|
||||||
$out/share/hicolor/scalable/apps/danklogo.svg
|
$out/share/hicolor/scalable/apps/danklogo.svg
|
||||||
|
|
||||||
wrapProgram $out/bin/dms \
|
wrapProgram $out/bin/dms \
|
||||||
--add-flags "-c $out/share/quickshell/dms" \
|
--add-flags "-c $out/share/quickshell/dms" \
|
||||||
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
|
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
|
||||||
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
|
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
|
||||||
|
|
||||||
install -Dm644 ${rootSrc}/assets/systemd/dms.service \
|
install -Dm644 ${rootSrc}/assets/systemd/dms.service \
|
||||||
$out/lib/systemd/user/dms.service
|
$out/lib/systemd/user/dms.service
|
||||||
|
|
||||||
substituteInPlace $out/lib/systemd/user/dms.service \
|
substituteInPlace $out/lib/systemd/user/dms.service \
|
||||||
--replace-fail /usr/bin/dms $out/bin/dms \
|
--replace-fail /usr/bin/dms $out/bin/dms \
|
||||||
--replace-fail /usr/bin/pkill ${pkgs.procps}/bin/pkill
|
--replace-fail /usr/bin/pkill ${pkgs.procps}/bin/pkill
|
||||||
|
|
||||||
substituteInPlace $out/share/quickshell/dms/Modules/Greetd/assets/dms-greeter \
|
substituteInPlace $out/share/quickshell/dms/Modules/Greetd/assets/dms-greeter \
|
||||||
--replace-fail /bin/bash ${pkgs.bashInteractive}/bin/bash
|
--replace-fail /bin/bash ${pkgs.bashInteractive}/bin/bash
|
||||||
|
|
||||||
substituteInPlace $out/share/quickshell/dms/assets/pam/fprint \
|
substituteInPlace $out/share/quickshell/dms/assets/pam/fprint \
|
||||||
--replace-fail pam_fprintd.so ${pkgs.fprintd}/lib/security/pam_fprintd.so
|
--replace-fail pam_fprintd.so ${pkgs.fprintd}/lib/security/pam_fprintd.so
|
||||||
|
|
||||||
substituteInPlace $out/share/quickshell/dms/assets/pam/u2f \
|
substituteInPlace $out/share/quickshell/dms/assets/pam/u2f \
|
||||||
--replace-fail pam_u2f.so ${pkgs.pam_u2f}/lib/security/pam_u2f.so
|
--replace-fail pam_u2f.so ${pkgs.pam_u2f}/lib/security/pam_u2f.so
|
||||||
|
|
||||||
installShellCompletion --cmd dms \
|
installShellCompletion --cmd dms \
|
||||||
--bash <($out/bin/dms completion bash) \
|
--bash <($out/bin/dms completion bash) \
|
||||||
--fish <($out/bin/dms completion fish) \
|
--fish <($out/bin/dms completion fish) \
|
||||||
--zsh <($out/bin/dms completion zsh)
|
--zsh <($out/bin/dms completion zsh)
|
||||||
'';
|
'';
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
description = "Desktop shell for wayland compositors built with Quickshell & GO";
|
description = "Desktop shell for wayland compositors built with Quickshell & GO";
|
||||||
homepage = "https://danklinux.com";
|
homepage = "https://danklinux.com";
|
||||||
changelog = "https://github.com/AvengeMedia/DankMaterialShell/releases/tag/v${version}";
|
changelog = "https://github.com/AvengeMedia/DankMaterialShell/releases/tag/v${version}";
|
||||||
license = pkgs.lib.licenses.mit;
|
license = pkgs.lib.licenses.mit;
|
||||||
mainProgram = "dms";
|
mainProgram = "dms";
|
||||||
platforms = pkgs.lib.platforms.linux;
|
platforms = pkgs.lib.platforms.linux;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
) { };
|
) { };
|
||||||
|
|
||||||
|
buildDmsPkgs = pkgs: {
|
||||||
|
dms-shell = mkDmsShell pkgs;
|
||||||
|
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forEachSystem (
|
||||||
|
system: pkgs: {
|
||||||
|
dms-shell = mkDmsShell pkgs;
|
||||||
quickshell = quickshell.packages.${system}.default;
|
quickshell = quickshell.packages.${system}.default;
|
||||||
|
|
||||||
default = self.packages.${system}.dms-shell;
|
default = self.packages.${system}.dms-shell;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -240,5 +251,16 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
nixosTests = forEachLinuxSystem (
|
||||||
|
system: pkgs:
|
||||||
|
import ./distro/nix/tests {
|
||||||
|
inherit
|
||||||
|
self
|
||||||
|
pkgs
|
||||||
|
;
|
||||||
|
lib = pkgs.lib;
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
62
quickshell/Common/AnimVariants.qml
Normal file
62
quickshell/Common/AnimVariants.qml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
pragma Singleton
|
||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
// AnimVariants — central tuning for animation variants (Material/Fluent/Dynamic)
|
||||||
|
// and motion effects (Standard/Directional/Depth). Lookups are indexed by enum
|
||||||
|
// value: animationVariant 0=Material, 1=Fluent, 2=Dynamic; motionEffect
|
||||||
|
// 0=Standard, 1=Directional, 2=Depth.
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property int _variant: (typeof SettingsData === "undefined") ? 0 : SettingsData.animationVariant
|
||||||
|
readonly property int _effect: (typeof SettingsData === "undefined") ? 0 : SettingsData.motionEffect
|
||||||
|
|
||||||
|
readonly property var _enterCurves: [Anims.expressiveDefaultSpatial, Anims.standardDecel, Anims.expressiveFastSpatial]
|
||||||
|
readonly property var _exitCurves: [Anims.emphasized, Anims.standard, Anims.emphasized]
|
||||||
|
readonly property var _directionalExitCurves: [Anims.emphasized, Anims.emphasizedAccel, Anims.emphasizedAccel]
|
||||||
|
readonly property var _enterDurationFactors: [1.0, 0.9, 1.08]
|
||||||
|
readonly property var _exitDurationFactors: [1.0, 0.85, 0.92]
|
||||||
|
readonly property var _cleanupPaddings: [50, 8, 24]
|
||||||
|
readonly property var _effectScaleCollapsed: [0.96, 1.0, 0.88]
|
||||||
|
readonly property var _effectAnimOffsets: [16, 144, 56]
|
||||||
|
|
||||||
|
readonly property list<real> variantEnterCurve: _enterCurves[_variant] || _enterCurves[0]
|
||||||
|
readonly property list<real> variantExitCurve: _exitCurves[_variant] || _exitCurves[0]
|
||||||
|
|
||||||
|
readonly property list<real> variantModalEnterCurve: isDirectionalEffect && _variant !== 0 ? (_enterCurves[_variant] || _enterCurves[0]) : variantEnterCurve
|
||||||
|
readonly property list<real> variantModalExitCurve: isDirectionalEffect ? (_directionalExitCurves[_variant] || _exitCurves[0]) : variantExitCurve
|
||||||
|
|
||||||
|
readonly property list<real> variantPopoutEnterCurve: isDirectionalEffect ? (_variant === 0 ? Anims.standardDecel : (_enterCurves[_variant] || _enterCurves[0])) : variantEnterCurve
|
||||||
|
readonly property list<real> variantPopoutExitCurve: isDirectionalEffect ? (_directionalExitCurves[_variant] || _exitCurves[0]) : variantExitCurve
|
||||||
|
|
||||||
|
readonly property real variantEnterDurationFactor: _enterDurationFactors[_variant] !== undefined ? _enterDurationFactors[_variant] : 1.0
|
||||||
|
readonly property real variantExitDurationFactor: _exitDurationFactors[_variant] !== undefined ? _exitDurationFactors[_variant] : 1.0
|
||||||
|
|
||||||
|
// Fluent: opacity at ~55% of duration; Material/Dynamic: 1:1 with position
|
||||||
|
readonly property real variantOpacityDurationScale: _variant === 1 ? 0.55 : 1.0
|
||||||
|
|
||||||
|
function variantDuration(baseDuration, entering) {
|
||||||
|
const factor = entering ? variantEnterDurationFactor : variantExitDurationFactor;
|
||||||
|
return Math.max(0, Math.round(baseDuration * factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantExitCleanupPadding() {
|
||||||
|
return _cleanupPaddings[_effect] !== undefined ? _cleanupPaddings[_effect] : 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantCloseInterval(baseDuration) {
|
||||||
|
return variantDuration(baseDuration, false) + variantExitCleanupPadding();
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property bool isDirectionalEffect: isConnectedEffect || _effect === 1
|
||||||
|
readonly property bool isDepthEffect: _effect === 2
|
||||||
|
readonly property bool isConnectedEffect: (typeof SettingsData !== "undefined") && SettingsData.connectedFrameModeActive
|
||||||
|
|
||||||
|
readonly property real effectScaleCollapsed: _effectScaleCollapsed[_effect] !== undefined ? _effectScaleCollapsed[_effect] : 0.96
|
||||||
|
readonly property real effectAnimOffset: _effectAnimOffsets[_effect] !== undefined ? _effectAnimOffsets[_effect] : 16
|
||||||
|
}
|
||||||
@@ -22,4 +22,9 @@ Singleton {
|
|||||||
readonly property var standard: [0.20, 0.00, 0.00, 1.00, 1.00, 1.00]
|
readonly property var standard: [0.20, 0.00, 0.00, 1.00, 1.00, 1.00]
|
||||||
readonly property var standardDecel: [0.00, 0.00, 0.00, 1.00, 1.00, 1.00]
|
readonly property var standardDecel: [0.00, 0.00, 0.00, 1.00, 1.00, 1.00]
|
||||||
readonly property var standardAccel: [0.30, 0.00, 1.00, 1.00, 1.00, 1.00]
|
readonly property var standardAccel: [0.30, 0.00, 1.00, 1.00, 1.00, 1.00]
|
||||||
|
|
||||||
|
// Used by AnimVariants for variant/effect logic
|
||||||
|
readonly property var expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1]
|
||||||
|
readonly property var expressiveFastSpatial: [0.34, 1.5, 0.2, 1.0, 1.0, 1.0]
|
||||||
|
readonly property var expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import QtCore
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
readonly property var log: Log.scoped("CacheData")
|
||||||
|
|
||||||
readonly property int cacheConfigVersion: 1
|
readonly property int cacheConfigVersion: 1
|
||||||
|
|
||||||
@@ -131,7 +133,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("CacheData: Failed to parse cache:", e.message);
|
log.warn("Failed to parse cache:", e.message);
|
||||||
} finally {
|
} finally {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
@@ -149,7 +151,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function migrateFromUndefinedToV1(cache) {
|
function migrateFromUndefinedToV1(cache) {
|
||||||
console.info("CacheData: Migrating configuration from undefined to version 1");
|
log.info("Migrating configuration from undefined to version 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupUnusedKeys() {
|
function cleanupUnusedKeys() {
|
||||||
@@ -164,7 +166,7 @@ Singleton {
|
|||||||
|
|
||||||
for (const key in cache) {
|
for (const key in cache) {
|
||||||
if (!validKeys.includes(key)) {
|
if (!validKeys.includes(key)) {
|
||||||
console.log("CacheData: Removing unused key:", key);
|
log.debug("Removing unused key:", key);
|
||||||
delete cache[key];
|
delete cache[key];
|
||||||
needsSave = true;
|
needsSave = true;
|
||||||
}
|
}
|
||||||
@@ -174,7 +176,7 @@ Singleton {
|
|||||||
cacheFile.setText(JSON.stringify(cache, null, 2));
|
cacheFile.setText(JSON.stringify(cache, null, 2));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("CacheData: Failed to cleanup unused keys:", e.message);
|
log.warn("Failed to cleanup unused keys:", e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +186,7 @@ Singleton {
|
|||||||
if (content && content.trim())
|
if (content && content.trim())
|
||||||
return JSON.parse(content);
|
return JSON.parse(content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("CacheData: Failed to parse launcher cache:", e.message);
|
log.warn("Failed to parse launcher cache:", e.message);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -220,7 +222,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
onLoadFailed: error => {
|
onLoadFailed: error => {
|
||||||
if (!isGreeterMode) {
|
if (!isGreeterMode) {
|
||||||
console.info("CacheData: No cache file found, starting fresh");
|
log.info("No cache file found, starting fresh");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,29 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
pragma ComponentBehavior: Bound
|
pragma ComponentBehavior: Bound
|
||||||
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
// Clear all image cache
|
|
||||||
function clearImageCache() {
|
function clearImageCache() {
|
||||||
Quickshell.execDetached(["rm", "-rf", Paths.stringify(Paths.imagecache)]);
|
Quickshell.execDetached(["rm", "-rf", Paths.stringify(Paths.imagecache)]);
|
||||||
Paths.mkdir(Paths.imagecache);
|
Paths.mkdir(Paths.imagecache);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear cache older than specified minutes
|
|
||||||
function clearOldCache(ageInMinutes) {
|
function clearOldCache(ageInMinutes) {
|
||||||
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", "*.png", "-mmin", `+${ageInMinutes}`, "-delete"]);
|
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", "*.png", "-mmin", `+${ageInMinutes}`, "-delete"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear cache for specific size
|
|
||||||
function clearCacheForSize(size) {
|
function clearCacheForSize(size) {
|
||||||
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", `*@${size}x${size}.png`, "-delete"]);
|
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", `*@${size}x${size}.png`, "-delete"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get cache size in MB
|
|
||||||
function getCacheSize(callback) {
|
function getCacheSize(callback) {
|
||||||
var process = Qt.createQmlObject(`
|
Proc.runCommand("cache_size", ["du", "-sm", Paths.stringify(Paths.imagecache)], function (output, exitCode) {
|
||||||
import Quickshell.Io
|
const sizeMB = parseInt(output.split("\t")[0]) || 0;
|
||||||
Process {
|
callback(sizeMB);
|
||||||
command: ["du", "-sm", "${Paths.stringify(Paths.imagecache)}"]
|
});
|
||||||
running: true
|
|
||||||
stdout: StdioCollector {
|
|
||||||
onStreamFinished: {
|
|
||||||
var sizeMB = parseInt(text.split("\\t")[0]) || 0
|
|
||||||
callback(sizeMB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`, root);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
518
quickshell/Common/ConnectedModeState.qml
Normal file
518
quickshell/Common/ConnectedModeState.qml
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
pragma Singleton
|
||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property var emptyDockState: ({
|
||||||
|
"reveal": false,
|
||||||
|
"barSide": "bottom",
|
||||||
|
"bodyX": 0,
|
||||||
|
"bodyY": 0,
|
||||||
|
"bodyW": 0,
|
||||||
|
"bodyH": 0,
|
||||||
|
"slideX": 0,
|
||||||
|
"slideY": 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Popout state (updated by DankPopout when connectedFrameModeActive)
|
||||||
|
property string popoutOwnerId: ""
|
||||||
|
property bool popoutVisible: false
|
||||||
|
property string popoutBarSide: "top"
|
||||||
|
property real popoutBodyX: 0
|
||||||
|
property real popoutBodyY: 0
|
||||||
|
property real popoutBodyW: 0
|
||||||
|
property real popoutBodyH: 0
|
||||||
|
property real popoutAnimX: 0
|
||||||
|
property real popoutAnimY: 0
|
||||||
|
property string popoutScreen: ""
|
||||||
|
property bool popoutOmitStartConnector: false
|
||||||
|
property bool popoutOmitEndConnector: false
|
||||||
|
|
||||||
|
// Dock state (updated by Dock when connectedFrameModeActive), keyed by screen.name
|
||||||
|
property var dockStates: ({})
|
||||||
|
|
||||||
|
// Dock slide offsets — hot-path updates separated from full geometry state
|
||||||
|
property var dockSlides: ({})
|
||||||
|
|
||||||
|
function _cloneDict(src) {
|
||||||
|
const next = {};
|
||||||
|
for (const k in src)
|
||||||
|
next[k] = src[k];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPopoutOwner(claimId) {
|
||||||
|
return !!claimId && popoutOwnerId === claimId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimPopout(claimId, state) {
|
||||||
|
if (!claimId)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
popoutOwnerId = claimId;
|
||||||
|
return updatePopout(claimId, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePopout(claimId, state) {
|
||||||
|
if (!hasPopoutOwner(claimId) || !state)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (state.visible !== undefined)
|
||||||
|
popoutVisible = !!state.visible;
|
||||||
|
if (state.barSide !== undefined)
|
||||||
|
popoutBarSide = state.barSide || "top";
|
||||||
|
if (state.bodyX !== undefined)
|
||||||
|
popoutBodyX = Number(state.bodyX);
|
||||||
|
if (state.bodyY !== undefined)
|
||||||
|
popoutBodyY = Number(state.bodyY);
|
||||||
|
if (state.bodyW !== undefined)
|
||||||
|
popoutBodyW = Number(state.bodyW);
|
||||||
|
if (state.bodyH !== undefined)
|
||||||
|
popoutBodyH = Number(state.bodyH);
|
||||||
|
if (state.animX !== undefined)
|
||||||
|
popoutAnimX = Number(state.animX);
|
||||||
|
if (state.animY !== undefined)
|
||||||
|
popoutAnimY = Number(state.animY);
|
||||||
|
if (state.screen !== undefined)
|
||||||
|
popoutScreen = state.screen || "";
|
||||||
|
if (state.omitStartConnector !== undefined)
|
||||||
|
popoutOmitStartConnector = !!state.omitStartConnector;
|
||||||
|
if (state.omitEndConnector !== undefined)
|
||||||
|
popoutOmitEndConnector = !!state.omitEndConnector;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePopout(claimId) {
|
||||||
|
if (!hasPopoutOwner(claimId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
popoutOwnerId = "";
|
||||||
|
popoutVisible = false;
|
||||||
|
popoutBarSide = "top";
|
||||||
|
popoutBodyX = 0;
|
||||||
|
popoutBodyY = 0;
|
||||||
|
popoutBodyW = 0;
|
||||||
|
popoutBodyH = 0;
|
||||||
|
popoutAnimX = 0;
|
||||||
|
popoutAnimY = 0;
|
||||||
|
popoutScreen = "";
|
||||||
|
popoutOmitStartConnector = false;
|
||||||
|
popoutOmitEndConnector = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPopoutAnim(claimId, animX, animY) {
|
||||||
|
if (!hasPopoutOwner(claimId))
|
||||||
|
return false;
|
||||||
|
if (animX !== undefined) {
|
||||||
|
const nextX = Number(animX);
|
||||||
|
if (!isNaN(nextX) && popoutAnimX !== nextX)
|
||||||
|
popoutAnimX = nextX;
|
||||||
|
}
|
||||||
|
if (animY !== undefined) {
|
||||||
|
const nextY = Number(animY);
|
||||||
|
if (!isNaN(nextY) && popoutAnimY !== nextY)
|
||||||
|
popoutAnimY = nextY;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPopoutBody(claimId, bodyX, bodyY, bodyW, bodyH) {
|
||||||
|
if (!hasPopoutOwner(claimId))
|
||||||
|
return false;
|
||||||
|
if (bodyX !== undefined) {
|
||||||
|
const nextX = Number(bodyX);
|
||||||
|
if (!isNaN(nextX) && popoutBodyX !== nextX)
|
||||||
|
popoutBodyX = nextX;
|
||||||
|
}
|
||||||
|
if (bodyY !== undefined) {
|
||||||
|
const nextY = Number(bodyY);
|
||||||
|
if (!isNaN(nextY) && popoutBodyY !== nextY)
|
||||||
|
popoutBodyY = nextY;
|
||||||
|
}
|
||||||
|
if (bodyW !== undefined) {
|
||||||
|
const nextW = Number(bodyW);
|
||||||
|
if (!isNaN(nextW) && popoutBodyW !== nextW)
|
||||||
|
popoutBodyW = nextW;
|
||||||
|
}
|
||||||
|
if (bodyH !== undefined) {
|
||||||
|
const nextH = Number(bodyH);
|
||||||
|
if (!isNaN(nextH) && popoutBodyH !== nextH)
|
||||||
|
popoutBodyH = nextH;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeDockState(state) {
|
||||||
|
return {
|
||||||
|
"reveal": !!(state && state.reveal),
|
||||||
|
"barSide": state && state.barSide ? state.barSide : "bottom",
|
||||||
|
"bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0),
|
||||||
|
"bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0),
|
||||||
|
"bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0),
|
||||||
|
"bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0),
|
||||||
|
"slideX": Number(state && state.slideX !== undefined ? state.slideX : 0),
|
||||||
|
"slideY": Number(state && state.slideY !== undefined ? state.slideY : 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sameDockState(a, b) {
|
||||||
|
if (!a || !b)
|
||||||
|
return false;
|
||||||
|
return a.reveal === b.reveal && a.barSide === b.barSide && Math.abs(a.bodyX - b.bodyX) < 0.5 && Math.abs(a.bodyY - b.bodyY) < 0.5 && Math.abs(a.bodyW - b.bodyW) < 0.5 && Math.abs(a.bodyH - b.bodyH) < 0.5 && Math.abs(a.slideX - b.slideX) < 0.5 && Math.abs(a.slideY - b.slideY) < 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDockState(screenName, state) {
|
||||||
|
if (!screenName || !state)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const normalized = _normalizeDockState(state);
|
||||||
|
if (_sameDockState(dockStates[screenName], normalized))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const next = _cloneDict(dockStates);
|
||||||
|
next[screenName] = normalized;
|
||||||
|
dockStates = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDockState(screenName) {
|
||||||
|
if (!screenName || !dockStates[screenName])
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const next = _cloneDict(dockStates);
|
||||||
|
delete next[screenName];
|
||||||
|
dockStates = next;
|
||||||
|
|
||||||
|
// Also clear corresponding slide
|
||||||
|
if (dockSlides[screenName]) {
|
||||||
|
const nextSlides = _cloneDict(dockSlides);
|
||||||
|
delete nextSlides[screenName];
|
||||||
|
dockSlides = nextSlides;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDockSlide(screenName, x, y) {
|
||||||
|
if (!screenName)
|
||||||
|
return false;
|
||||||
|
const numX = Number(x);
|
||||||
|
const numY = Number(y);
|
||||||
|
const cur = dockSlides[screenName];
|
||||||
|
if (cur && Math.abs(cur.x - numX) < 0.5 && Math.abs(cur.y - numY) < 0.5)
|
||||||
|
return true;
|
||||||
|
const next = _cloneDict(dockSlides);
|
||||||
|
next[screenName] = {
|
||||||
|
"x": numX,
|
||||||
|
"y": numY
|
||||||
|
};
|
||||||
|
dockSlides = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var emptyNotificationState: ({
|
||||||
|
"visible": false,
|
||||||
|
"barSide": "top",
|
||||||
|
"bodyX": 0,
|
||||||
|
"bodyY": 0,
|
||||||
|
"bodyW": 0,
|
||||||
|
"bodyH": 0,
|
||||||
|
"omitStartConnector": false,
|
||||||
|
"omitEndConnector": false
|
||||||
|
})
|
||||||
|
|
||||||
|
property var notificationStates: ({})
|
||||||
|
|
||||||
|
function _normalizeNotificationState(state) {
|
||||||
|
return {
|
||||||
|
"visible": !!(state && state.visible),
|
||||||
|
"barSide": state && state.barSide ? state.barSide : "top",
|
||||||
|
"bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0),
|
||||||
|
"bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0),
|
||||||
|
"bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0),
|
||||||
|
"bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0),
|
||||||
|
"omitStartConnector": !!(state && state.omitStartConnector),
|
||||||
|
"omitEndConnector": !!(state && state.omitEndConnector)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sameNotificationGeometry(a, b) {
|
||||||
|
if (!a || !b)
|
||||||
|
return false;
|
||||||
|
return Math.abs(Number(a.bodyX) - Number(b.bodyX)) < 0.5 && Math.abs(Number(a.bodyY) - Number(b.bodyY)) < 0.5 && Math.abs(Number(a.bodyW) - Number(b.bodyW)) < 0.5 && Math.abs(Number(a.bodyH) - Number(b.bodyH)) < 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sameNotificationState(a, b) {
|
||||||
|
if (!a || !b)
|
||||||
|
return false;
|
||||||
|
return a.visible === b.visible && a.barSide === b.barSide && a.omitStartConnector === b.omitStartConnector && a.omitEndConnector === b.omitEndConnector && _sameNotificationGeometry(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNotificationState(screenName, state) {
|
||||||
|
if (!screenName || !state)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const normalized = _normalizeNotificationState(state);
|
||||||
|
if (_sameNotificationState(notificationStates[screenName], normalized))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const next = _cloneDict(notificationStates);
|
||||||
|
next[screenName] = normalized;
|
||||||
|
notificationStates = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearNotificationState(screenName) {
|
||||||
|
if (!screenName || !notificationStates[screenName])
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const next = _cloneDict(notificationStates);
|
||||||
|
delete next[screenName];
|
||||||
|
notificationStates = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DankModal / DankLauncherV2Modal State
|
||||||
|
readonly property var emptyModalState: ({
|
||||||
|
"visible": false,
|
||||||
|
"barSide": "bottom",
|
||||||
|
"bodyX": 0,
|
||||||
|
"bodyY": 0,
|
||||||
|
"bodyW": 0,
|
||||||
|
"bodyH": 0,
|
||||||
|
"animX": 0,
|
||||||
|
"animY": 0,
|
||||||
|
"omitStartConnector": false,
|
||||||
|
"omitEndConnector": false
|
||||||
|
})
|
||||||
|
|
||||||
|
property var modalStates: ({})
|
||||||
|
property var modalOwners: ({})
|
||||||
|
|
||||||
|
function _normalizeModalState(state) {
|
||||||
|
return {
|
||||||
|
"visible": !!(state && state.visible),
|
||||||
|
"barSide": state && state.barSide ? state.barSide : "bottom",
|
||||||
|
"bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0),
|
||||||
|
"bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0),
|
||||||
|
"bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0),
|
||||||
|
"bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0),
|
||||||
|
"animX": Number(state && state.animX !== undefined ? state.animX : 0),
|
||||||
|
"animY": Number(state && state.animY !== undefined ? state.animY : 0),
|
||||||
|
"omitStartConnector": !!(state && state.omitStartConnector),
|
||||||
|
"omitEndConnector": !!(state && state.omitEndConnector)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sameModalGeometry(a, b) {
|
||||||
|
if (!a || !b)
|
||||||
|
return false;
|
||||||
|
return Math.abs(Number(a.bodyX) - Number(b.bodyX)) < 0.5 && Math.abs(Number(a.bodyY) - Number(b.bodyY)) < 0.5 && Math.abs(Number(a.bodyW) - Number(b.bodyW)) < 0.5 && Math.abs(Number(a.bodyH) - Number(b.bodyH)) < 0.5 && Math.abs(Number(a.animX) - Number(b.animX)) < 0.5 && Math.abs(Number(a.animY) - Number(b.animY)) < 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sameModalState(a, b) {
|
||||||
|
if (!a || !b)
|
||||||
|
return false;
|
||||||
|
return a.visible === b.visible && a.barSide === b.barSide && a.omitStartConnector === b.omitStartConnector && a.omitEndConnector === b.omitEndConnector && _sameModalGeometry(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimModalState(screenName, state, ownerId) {
|
||||||
|
if (!screenName || !state)
|
||||||
|
return false;
|
||||||
|
if (ownerId) {
|
||||||
|
const nextOwners = _cloneDict(modalOwners);
|
||||||
|
nextOwners[screenName] = ownerId;
|
||||||
|
modalOwners = nextOwners;
|
||||||
|
}
|
||||||
|
const normalized = _normalizeModalState(state);
|
||||||
|
if (_sameModalState(modalStates[screenName], normalized))
|
||||||
|
return true;
|
||||||
|
const next = _cloneDict(modalStates);
|
||||||
|
next[screenName] = normalized;
|
||||||
|
modalStates = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModalState(screenName, state, ownerId) {
|
||||||
|
if (!screenName || !state)
|
||||||
|
return false;
|
||||||
|
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
||||||
|
return false;
|
||||||
|
const normalized = _normalizeModalState(state);
|
||||||
|
if (_sameModalState(modalStates[screenName], normalized))
|
||||||
|
return true;
|
||||||
|
const next = _cloneDict(modalStates);
|
||||||
|
next[screenName] = normalized;
|
||||||
|
modalStates = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setModalState(screenName, state) {
|
||||||
|
return updateModalState(screenName, state, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearModalState(screenName, ownerId) {
|
||||||
|
if (!screenName || !modalStates[screenName])
|
||||||
|
return false;
|
||||||
|
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const next = _cloneDict(modalStates);
|
||||||
|
delete next[screenName];
|
||||||
|
modalStates = next;
|
||||||
|
|
||||||
|
if (modalOwners[screenName]) {
|
||||||
|
const nextOwners = _cloneDict(modalOwners);
|
||||||
|
delete nextOwners[screenName];
|
||||||
|
modalOwners = nextOwners;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setModalAnim(screenName, animX, animY, ownerId) {
|
||||||
|
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
||||||
|
return false;
|
||||||
|
const cur = screenName ? modalStates[screenName] : null;
|
||||||
|
if (!cur)
|
||||||
|
return false;
|
||||||
|
const nax = animX !== undefined ? Number(animX) : cur.animX;
|
||||||
|
const nay = animY !== undefined ? Number(animY) : cur.animY;
|
||||||
|
if (Math.abs(nax - cur.animX) < 0.5 && Math.abs(nay - cur.animY) < 0.5)
|
||||||
|
return false;
|
||||||
|
const next = _cloneDict(modalStates);
|
||||||
|
next[screenName] = Object.assign({}, cur, {
|
||||||
|
"animX": nax,
|
||||||
|
"animY": nay
|
||||||
|
});
|
||||||
|
modalStates = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setModalBody(screenName, bodyX, bodyY, bodyW, bodyH, ownerId) {
|
||||||
|
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
||||||
|
return false;
|
||||||
|
const cur = screenName ? modalStates[screenName] : null;
|
||||||
|
if (!cur)
|
||||||
|
return false;
|
||||||
|
const nx = bodyX !== undefined ? Number(bodyX) : cur.bodyX;
|
||||||
|
const ny = bodyY !== undefined ? Number(bodyY) : cur.bodyY;
|
||||||
|
const nw = bodyW !== undefined ? Number(bodyW) : cur.bodyW;
|
||||||
|
const nh = bodyH !== undefined ? Number(bodyH) : cur.bodyH;
|
||||||
|
if (Math.abs(nx - cur.bodyX) < 0.5 && Math.abs(ny - cur.bodyY) < 0.5 && Math.abs(nw - cur.bodyW) < 0.5 && Math.abs(nh - cur.bodyH) < 0.5)
|
||||||
|
return false;
|
||||||
|
const next = _cloneDict(modalStates);
|
||||||
|
next[screenName] = Object.assign({}, cur, {
|
||||||
|
"bodyX": nx,
|
||||||
|
"bodyY": ny,
|
||||||
|
"bodyW": nw,
|
||||||
|
"bodyH": nh
|
||||||
|
});
|
||||||
|
modalStates = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
property var dockRetractRequests: ({})
|
||||||
|
|
||||||
|
function requestDockRetract(requesterId, screenName, side) {
|
||||||
|
if (!requesterId || !screenName || !side)
|
||||||
|
return false;
|
||||||
|
const existing = dockRetractRequests[requesterId];
|
||||||
|
if (existing && existing.screenName === screenName && existing.side === side)
|
||||||
|
return true;
|
||||||
|
const next = _cloneDict(dockRetractRequests);
|
||||||
|
next[requesterId] = {
|
||||||
|
"screenName": screenName,
|
||||||
|
"side": side
|
||||||
|
};
|
||||||
|
dockRetractRequests = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseDockRetract(requesterId) {
|
||||||
|
if (!requesterId || !dockRetractRequests[requesterId])
|
||||||
|
return false;
|
||||||
|
const next = _cloneDict(dockRetractRequests);
|
||||||
|
delete next[requesterId];
|
||||||
|
dockRetractRequests = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dockRetractActiveForSide(screenName, side) {
|
||||||
|
if (!screenName || !side)
|
||||||
|
return false;
|
||||||
|
for (const k in dockRetractRequests) {
|
||||||
|
const r = dockRetractRequests[k];
|
||||||
|
if (r && r.screenName === screenName && r.side === side)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune state for screens that are no longer connected. Stale entries
|
||||||
|
// accumulate across hotplug cycles otherwise — Frame's per-screen
|
||||||
|
// FrameInstance doesn't notice when its peer dicts go orphan.
|
||||||
|
function _pruneToLiveScreens() {
|
||||||
|
const live = {};
|
||||||
|
const screens = Quickshell.screens || [];
|
||||||
|
for (let i = 0; i < screens.length; i++) {
|
||||||
|
const s = screens[i];
|
||||||
|
if (s && s.name)
|
||||||
|
live[s.name] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneKeyed(dict) {
|
||||||
|
let changed = false;
|
||||||
|
const next = {};
|
||||||
|
for (const k in dict) {
|
||||||
|
if (live[k])
|
||||||
|
next[k] = dict[k];
|
||||||
|
else
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed ? next : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDock = pruneKeyed(dockStates);
|
||||||
|
if (nextDock !== null)
|
||||||
|
dockStates = nextDock;
|
||||||
|
const nextSlides = pruneKeyed(dockSlides);
|
||||||
|
if (nextSlides !== null)
|
||||||
|
dockSlides = nextSlides;
|
||||||
|
const nextNotif = pruneKeyed(notificationStates);
|
||||||
|
if (nextNotif !== null)
|
||||||
|
notificationStates = nextNotif;
|
||||||
|
const nextModal = pruneKeyed(modalStates);
|
||||||
|
if (nextModal !== null)
|
||||||
|
modalStates = nextModal;
|
||||||
|
const nextModalOwners = pruneKeyed(modalOwners);
|
||||||
|
if (nextModalOwners !== null)
|
||||||
|
modalOwners = nextModalOwners;
|
||||||
|
|
||||||
|
let retractChanged = false;
|
||||||
|
const nextRetract = {};
|
||||||
|
for (const k in dockRetractRequests) {
|
||||||
|
const r = dockRetractRequests[k];
|
||||||
|
if (r && live[r.screenName])
|
||||||
|
nextRetract[k] = r;
|
||||||
|
else
|
||||||
|
retractChanged = true;
|
||||||
|
}
|
||||||
|
if (retractChanged)
|
||||||
|
dockRetractRequests = nextRetract;
|
||||||
|
|
||||||
|
if (popoutOwnerId && popoutScreen && !live[popoutScreen])
|
||||||
|
releasePopout(popoutOwnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: Quickshell
|
||||||
|
function onScreensChanged() {
|
||||||
|
root._pruneToLiveScreens();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
quickshell/Common/ConnectorGeometry.js
Normal file
68
quickshell/Common/ConnectorGeometry.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
.pragma library
|
||||||
|
|
||||||
|
// Geometry for connected-frame arc connectors.
|
||||||
|
// `barSide` is one of "top" | "bottom" | "left" | "right" — the edge where the
|
||||||
|
// host bar/dock sits. `placement` is "left" (start) or "right" (end) of the
|
||||||
|
// body's far edge. `radius` is the connector's arc radius. `spacing` is the
|
||||||
|
// gap between the host edge and the body.
|
||||||
|
|
||||||
|
function isVertical(barSide) {
|
||||||
|
return barSide === "left" || barSide === "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHorizontal(barSide) {
|
||||||
|
return barSide === "top" || barSide === "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectorWidth(barSide, spacing, radius) {
|
||||||
|
return isVertical(barSide) ? (spacing + radius) : radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectorHeight(barSide, spacing, radius) {
|
||||||
|
return isVertical(barSide) ? radius : (spacing + radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seamX(barSide, baseX, bodyWidth, placement) {
|
||||||
|
if (!isVertical(barSide))
|
||||||
|
return placement === "left" ? baseX : baseX + bodyWidth;
|
||||||
|
return barSide === "left" ? baseX : baseX + bodyWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seamY(barSide, baseY, bodyHeight, placement) {
|
||||||
|
if (barSide === "top")
|
||||||
|
return baseY;
|
||||||
|
if (barSide === "bottom")
|
||||||
|
return baseY + bodyHeight;
|
||||||
|
return placement === "left" ? baseY : baseY + bodyHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectorX(barSide, baseX, bodyWidth, placement, spacing, radius) {
|
||||||
|
var s = seamX(barSide, baseX, bodyWidth, placement);
|
||||||
|
var w = connectorWidth(barSide, spacing, radius);
|
||||||
|
if (!isVertical(barSide))
|
||||||
|
return placement === "left" ? s - w : s;
|
||||||
|
return barSide === "left" ? s : s - w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectorY(barSide, baseY, bodyHeight, placement, spacing, radius) {
|
||||||
|
var s = seamY(barSide, baseY, bodyHeight, placement);
|
||||||
|
var h = connectorHeight(barSide, spacing, radius);
|
||||||
|
if (barSide === "top")
|
||||||
|
return s;
|
||||||
|
if (barSide === "bottom")
|
||||||
|
return s - h;
|
||||||
|
return placement === "left" ? s - h : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which corner of the connector's bounding rect hosts the concave arc that
|
||||||
|
// carves into the body. Used for arc-sweep orientation.
|
||||||
|
function arcCorner(barSide, placement) {
|
||||||
|
var left = placement === "left";
|
||||||
|
if (barSide === "top")
|
||||||
|
return left ? "bottomLeft" : "bottomRight";
|
||||||
|
if (barSide === "bottom")
|
||||||
|
return left ? "topLeft" : "topRight";
|
||||||
|
if (barSide === "left")
|
||||||
|
return left ? "topRight" : "bottomRight";
|
||||||
|
return left ? "topLeft" : "bottomLeft";
|
||||||
|
}
|
||||||
55
quickshell/Common/DeferredAction.qml
Normal file
55
quickshell/Common/DeferredAction.qml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
visible: false
|
||||||
|
width: 0
|
||||||
|
height: 0
|
||||||
|
|
||||||
|
property int interval: 0
|
||||||
|
property bool pending: false
|
||||||
|
|
||||||
|
signal triggered
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (!root.enabled || root.pending)
|
||||||
|
return;
|
||||||
|
root.pending = true;
|
||||||
|
deferTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
if (!root.enabled)
|
||||||
|
return;
|
||||||
|
root.pending = true;
|
||||||
|
deferTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (!root.pending)
|
||||||
|
return;
|
||||||
|
deferTimer.stop();
|
||||||
|
root.pending = false;
|
||||||
|
root.triggered();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
deferTimer.stop();
|
||||||
|
root.pending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onEnabledChanged: {
|
||||||
|
if (!enabled)
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: deferTimer
|
||||||
|
interval: root.interval
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onDestruction: cancel()
|
||||||
|
}
|
||||||
@@ -13,8 +13,13 @@ Item {
|
|||||||
|
|
||||||
property color targetColor: "white"
|
property color targetColor: "white"
|
||||||
property real targetRadius: Theme.cornerRadius
|
property real targetRadius: Theme.cornerRadius
|
||||||
|
property real topLeftRadius: targetRadius
|
||||||
|
property real topRightRadius: targetRadius
|
||||||
|
property real bottomLeftRadius: targetRadius
|
||||||
|
property real bottomRightRadius: targetRadius
|
||||||
property color borderColor: "transparent"
|
property color borderColor: "transparent"
|
||||||
property real borderWidth: 0
|
property real borderWidth: 0
|
||||||
|
property bool useCustomSource: false
|
||||||
|
|
||||||
property bool shadowEnabled: Theme.elevationEnabled
|
property bool shadowEnabled: Theme.elevationEnabled
|
||||||
property real shadowBlurPx: level && level.blurPx !== undefined ? level.blurPx : 0
|
property real shadowBlurPx: level && level.blurPx !== undefined ? level.blurPx : 0
|
||||||
@@ -46,7 +51,11 @@ Item {
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
id: sourceRect
|
id: sourceRect
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
radius: root.targetRadius
|
visible: !root.useCustomSource
|
||||||
|
topLeftRadius: root.topLeftRadius
|
||||||
|
topRightRadius: root.topRightRadius
|
||||||
|
bottomLeftRadius: root.bottomLeftRadius
|
||||||
|
bottomRightRadius: root.bottomRightRadius
|
||||||
color: root.targetColor
|
color: root.targetColor
|
||||||
border.color: root.borderColor
|
border.color: root.borderColor
|
||||||
border.width: root.borderWidth
|
border.width: root.borderWidth
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import QtQuick
|
|||||||
import Qt.labs.folderlistmodel
|
import Qt.labs.folderlistmodel
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
readonly property var log: Log.scoped("I18n")
|
||||||
|
|
||||||
property string _resolvedLocale: "en"
|
property string _resolvedLocale: "en"
|
||||||
|
|
||||||
@@ -54,15 +56,15 @@ Singleton {
|
|||||||
try {
|
try {
|
||||||
root.translations = JSON.parse(text());
|
root.translations = JSON.parse(text());
|
||||||
root.translationsLoaded = true;
|
root.translationsLoaded = true;
|
||||||
console.info(`I18n: Loaded translations for '${root._resolvedLocale}' (${Object.keys(root.translations).length} contexts)`);
|
log.info(`I18n: Loaded translations for '${root._resolvedLocale}' (${Object.keys(root.translations).length} contexts)`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`I18n: Error parsing '${root._resolvedLocale}':`, e, "- falling back to English");
|
log.warn(`I18n: Error parsing '${root._resolvedLocale}':`, e, "- falling back to English");
|
||||||
root._fallbackToEnglish();
|
root._fallbackToEnglish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoadFailed: error => {
|
onLoadFailed: error => {
|
||||||
console.warn(`I18n: Failed to load '${root._resolvedLocale}' (${error}), ` + "falling back to English");
|
log.warn(`I18n: Failed to load '${root._resolvedLocale}' (${error}), ` + "falling back to English");
|
||||||
root._fallbackToEnglish();
|
root._fallbackToEnglish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,14 +107,14 @@ Singleton {
|
|||||||
_selectedPath = fileUrl;
|
_selectedPath = fileUrl;
|
||||||
translationsLoaded = false;
|
translationsLoaded = false;
|
||||||
translations = ({});
|
translations = ({});
|
||||||
console.info(`I18n: Using locale '${localeTag}' from ${fileUrl}`);
|
log.info(`I18n: Using locale '${localeTag}' from ${fileUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _fallbackToEnglish() {
|
function _fallbackToEnglish() {
|
||||||
_selectedPath = "";
|
_selectedPath = "";
|
||||||
translationsLoaded = false;
|
translationsLoaded = false;
|
||||||
translations = ({});
|
translations = ({});
|
||||||
console.warn("I18n: Falling back to built-in English strings");
|
log.warn("Falling back to built-in English strings");
|
||||||
}
|
}
|
||||||
|
|
||||||
function tr(term, context) {
|
function tr(term, context) {
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ const DMS_ACTIONS = [
|
|||||||
{ id: "spawn dms ipc call bar toggle index 0", label: "Bar: Toggle (Primary)" },
|
{ id: "spawn dms ipc call bar toggle index 0", label: "Bar: Toggle (Primary)" },
|
||||||
{ id: "spawn dms ipc call bar reveal index 0", label: "Bar: Reveal (Primary)" },
|
{ id: "spawn dms ipc call bar reveal index 0", label: "Bar: Reveal (Primary)" },
|
||||||
{ id: "spawn dms ipc call bar hide index 0", label: "Bar: Hide (Primary)" },
|
{ id: "spawn dms ipc call bar hide index 0", label: "Bar: Hide (Primary)" },
|
||||||
|
{ id: "spawn dms ipc call bar toggleReveal index 0", label: "Bar: Toggle Autohide Reveal (Primary)" },
|
||||||
{ id: "spawn dms ipc call bar toggleAutoHide index 0", label: "Bar: Toggle Auto-Hide (Primary)" },
|
{ id: "spawn dms ipc call bar toggleAutoHide index 0", label: "Bar: Toggle Auto-Hide (Primary)" },
|
||||||
{ id: "spawn dms ipc call bar autoHide index 0", label: "Bar: Enable Auto-Hide (Primary)" },
|
{ id: "spawn dms ipc call bar autoHide index 0", label: "Bar: Enable Auto-Hide (Primary)" },
|
||||||
{ id: "spawn dms ipc call bar manualHide index 0", label: "Bar: Disable Auto-Hide (Primary)" },
|
{ id: "spawn dms ipc call bar manualHide index 0", label: "Bar: Disable Auto-Hide (Primary)" },
|
||||||
@@ -161,10 +162,16 @@ const NIRI_ACTIONS = {
|
|||||||
{ id: "focus-monitor-right", label: "Focus Monitor Right" },
|
{ id: "focus-monitor-right", label: "Focus Monitor Right" },
|
||||||
{ id: "focus-monitor-down", label: "Focus Monitor Down" },
|
{ id: "focus-monitor-down", label: "Focus Monitor Down" },
|
||||||
{ id: "focus-monitor-up", label: "Focus Monitor Up" },
|
{ id: "focus-monitor-up", label: "Focus Monitor Up" },
|
||||||
{ id: "move-column-to-monitor-left", label: "Move to Monitor Left" },
|
{ id: "move-column-to-monitor-left", label: "Move Column to Monitor Left" },
|
||||||
{ id: "move-column-to-monitor-right", label: "Move to Monitor Right" },
|
{ id: "move-column-to-monitor-right", label: "Move Column to Monitor Right" },
|
||||||
{ id: "move-column-to-monitor-down", label: "Move to Monitor Down" },
|
{ id: "move-column-to-monitor-down", label: "Move Column to Monitor Down" },
|
||||||
{ id: "move-column-to-monitor-up", label: "Move to Monitor Up" }
|
{ id: "move-column-to-monitor-up", label: "Move Column to Monitor Up" },
|
||||||
|
{ id: "move-workspace-to-monitor-left", label: "Move Workspace to Monitor Left" },
|
||||||
|
{ id: "move-workspace-to-monitor-right", label: "Move Workspace to Monitor Right" },
|
||||||
|
{ id: "move-workspace-to-monitor-down", label: "Move Workspace to Monitor Down" },
|
||||||
|
{ id: "move-workspace-to-monitor-up", label: "Move Workspace to Monitor Up" },
|
||||||
|
{ id: "move-workspace-to-monitor-next", label: "Move Workspace to Next Monitor" },
|
||||||
|
{ id: "move-workspace-to-monitor-previous", label: "Move Workspace to Previous Monitor" }
|
||||||
],
|
],
|
||||||
"Screenshot": [
|
"Screenshot": [
|
||||||
{ id: "screenshot", label: "Screenshot (Interactive)" },
|
{ id: "screenshot", label: "Screenshot (Interactive)" },
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function expandTilde(path: string): string {
|
function expandTilde(path: string): string {
|
||||||
return strip(path.replace("~", stringify(root.home)));
|
if (!path.startsWith("~"))
|
||||||
|
return path;
|
||||||
|
return strip(root.home) + path.substring(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortenHome(path: string): string {
|
function shortenHome(path: string): string {
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ pragma ComponentBehavior: Bound
|
|||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
readonly property var log: Log.scoped("Proc")
|
||||||
|
|
||||||
readonly property int noTimeout: -1
|
readonly property int noTimeout: -1
|
||||||
property int defaultDebounceMs: 50
|
property int defaultDebounceMs: 50
|
||||||
@@ -19,7 +22,7 @@ Singleton {
|
|||||||
const isRandomId = !id;
|
const isRandomId = !id;
|
||||||
|
|
||||||
if (!_procDebouncers[procId]) {
|
if (!_procDebouncers[procId]) {
|
||||||
const t = Qt.createQmlObject('import QtQuick; Timer { repeat: false }', root);
|
const t = debounceTimerComp.createObject(root);
|
||||||
t.triggered.connect(function () {
|
t.triggered.connect(function () {
|
||||||
_launchProc(procId, isRandomId);
|
_launchProc(procId, isRandomId);
|
||||||
});
|
});
|
||||||
@@ -47,14 +50,10 @@ Singleton {
|
|||||||
const entry = _procDebouncers[id];
|
const entry = _procDebouncers[id];
|
||||||
if (!entry)
|
if (!entry)
|
||||||
return;
|
return;
|
||||||
const proc = Qt.createQmlObject('import Quickshell.Io; Process { running: false }', root);
|
const proc = procComp.createObject(root, {
|
||||||
const out = Qt.createQmlObject('import Quickshell.Io; StdioCollector {}', proc);
|
command: entry.command
|
||||||
const err = Qt.createQmlObject('import Quickshell.Io; StdioCollector {}', proc);
|
});
|
||||||
const timeoutTimer = Qt.createQmlObject('import QtQuick; Timer { repeat: false }', root);
|
const timeoutTimer = debounceTimerComp.createObject(root);
|
||||||
|
|
||||||
proc.stdout = out;
|
|
||||||
proc.stderr = err;
|
|
||||||
proc.command = entry.command;
|
|
||||||
|
|
||||||
let capturedOut = "";
|
let capturedOut = "";
|
||||||
let capturedErr = "";
|
let capturedErr = "";
|
||||||
@@ -75,9 +74,9 @@ Singleton {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
out.streamFinished.connect(function () {
|
proc.stdout.streamFinished.connect(function () {
|
||||||
try {
|
try {
|
||||||
capturedOut = out.text || "";
|
capturedOut = proc.stdout.text || "";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
capturedOut = "";
|
capturedOut = "";
|
||||||
}
|
}
|
||||||
@@ -85,9 +84,9 @@ Singleton {
|
|||||||
maybeComplete();
|
maybeComplete();
|
||||||
});
|
});
|
||||||
|
|
||||||
err.streamFinished.connect(function () {
|
proc.stderr.streamFinished.connect(function () {
|
||||||
try {
|
try {
|
||||||
capturedErr = err.text || "";
|
capturedErr = proc.stderr.text || "";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
capturedErr = "";
|
capturedErr = "";
|
||||||
}
|
}
|
||||||
@@ -112,7 +111,7 @@ Singleton {
|
|||||||
const safeExitCode = exitCodeValue !== null && exitCodeValue !== undefined ? exitCodeValue : -1;
|
const safeExitCode = exitCodeValue !== null && exitCodeValue !== undefined ? exitCodeValue : -1;
|
||||||
entry.callback(safeOutput, safeExitCode);
|
entry.callback(safeOutput, safeExitCode);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("runCommand callback error for command:", entry.command, "Error:", e);
|
log.warn("runCommand callback error for command:", entry.command, "Error:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -138,4 +137,20 @@ Singleton {
|
|||||||
if (entry.timeoutMs !== noTimeout)
|
if (entry.timeoutMs !== noTimeout)
|
||||||
timeoutTimer.start();
|
timeoutTimer.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: debounceTimerComp
|
||||||
|
Timer {
|
||||||
|
repeat: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: procComp
|
||||||
|
Process {
|
||||||
|
running: false
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import "settings/SessionStore.js" as Store
|
|||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
readonly property var log: Log.scoped("SessionData")
|
||||||
|
|
||||||
readonly property int sessionConfigVersion: 3
|
readonly property int sessionConfigVersion: 3
|
||||||
|
|
||||||
@@ -30,9 +31,36 @@ Singleton {
|
|||||||
property bool isLightMode: false
|
property bool isLightMode: false
|
||||||
property bool doNotDisturb: false
|
property bool doNotDisturb: false
|
||||||
property real doNotDisturbUntil: 0
|
property real doNotDisturbUntil: 0
|
||||||
|
property string terminalOverride: ""
|
||||||
property bool isSwitchingMode: false
|
property bool isSwitchingMode: false
|
||||||
property bool suppressOSD: true
|
property bool suppressOSD: true
|
||||||
|
|
||||||
|
readonly property var terminalOptions: ["ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"]
|
||||||
|
property var installedTerminals: []
|
||||||
|
|
||||||
|
function resolveTerminal() {
|
||||||
|
if (terminalOverride && terminalOverride.length > 0) {
|
||||||
|
return terminalOverride;
|
||||||
|
}
|
||||||
|
const env = Quickshell.env("TERMINAL");
|
||||||
|
if (env && env.length > 0) {
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: terminalProbe
|
||||||
|
running: true
|
||||||
|
command: ["sh", "-c", "for t in ghostty kitty foot alacritty wezterm konsole gnome-terminal xterm; do command -v \"$t\" >/dev/null 2>&1 && echo \"$t\"; done"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
const found = text.trim().split("\n").filter(line => line.length > 0);
|
||||||
|
root.installedTerminals = found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: dndExpireTimer
|
id: dndExpireTimer
|
||||||
repeat: false
|
repeat: false
|
||||||
@@ -230,7 +258,7 @@ Singleton {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_parseError = true;
|
_parseError = true;
|
||||||
const msg = e.message;
|
const msg = e.message;
|
||||||
console.error("SessionData: Failed to parse session.json - file will not be overwritten.");
|
log.error("Failed to parse session.json - file will not be overwritten.");
|
||||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
|
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,7 +338,7 @@ Singleton {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_parseError = true;
|
_parseError = true;
|
||||||
const msg = e.message;
|
const msg = e.message;
|
||||||
console.error("SessionData: Failed to parse session.json - file will not be overwritten.");
|
log.error("Failed to parse session.json - file will not be overwritten.");
|
||||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
|
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,7 +553,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
console.warn("SessionData: Screen not found");
|
log.warn("Screen not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,7 +650,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
console.warn("SessionData: Screen not found");
|
log.warn("Screen not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,7 +681,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
console.warn("SessionData: Screen not found");
|
log.warn("Screen not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,7 +712,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
console.warn("SessionData: Screen not found");
|
log.warn("Screen not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,7 +743,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
console.warn("SessionData: Screen not found");
|
log.warn("Screen not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import "settings/SettingsStore.js" as Store
|
|||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
readonly property var log: Log.scoped("SettingsData")
|
||||||
|
|
||||||
readonly property int settingsConfigVersion: 5
|
readonly property int settingsConfigVersion: 11
|
||||||
|
|
||||||
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
||||||
|
|
||||||
@@ -37,6 +38,18 @@ Singleton {
|
|||||||
Custom
|
Custom
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AnimationVariant {
|
||||||
|
Material,
|
||||||
|
Fluent,
|
||||||
|
Dynamic
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnimationEffect {
|
||||||
|
Standard, // 0 — M3: scale-in, rises from below
|
||||||
|
Directional, // 1 — pure large slide, no scale
|
||||||
|
Depth // 2 — medium slide with deep depth scale pop
|
||||||
|
}
|
||||||
|
|
||||||
enum SuspendBehavior {
|
enum SuspendBehavior {
|
||||||
Suspend,
|
Suspend,
|
||||||
Hibernate,
|
Hibernate,
|
||||||
@@ -48,6 +61,20 @@ Singleton {
|
|||||||
Colorful
|
Colorful
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TextRenderType {
|
||||||
|
Qt,
|
||||||
|
Native,
|
||||||
|
Curve
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TextRenderQuality {
|
||||||
|
Default,
|
||||||
|
Low,
|
||||||
|
Normal,
|
||||||
|
High,
|
||||||
|
VeryHigh
|
||||||
|
}
|
||||||
|
|
||||||
readonly property string _homeUrl: StandardPaths.writableLocation(StandardPaths.HomeLocation)
|
readonly property string _homeUrl: StandardPaths.writableLocation(StandardPaths.HomeLocation)
|
||||||
readonly property string _configUrl: StandardPaths.writableLocation(StandardPaths.ConfigLocation)
|
readonly property string _configUrl: StandardPaths.writableLocation(StandardPaths.ConfigLocation)
|
||||||
readonly property string _configDir: Paths.strip(_configUrl)
|
readonly property string _configDir: Paths.strip(_configUrl)
|
||||||
@@ -168,6 +195,10 @@ Singleton {
|
|||||||
property int modalCustomAnimationDuration: 150
|
property int modalCustomAnimationDuration: 150
|
||||||
property bool enableRippleEffects: true
|
property bool enableRippleEffects: true
|
||||||
onEnableRippleEffectsChanged: saveSettings()
|
onEnableRippleEffectsChanged: saveSettings()
|
||||||
|
property int animationVariant: SettingsData.AnimationVariant.Material
|
||||||
|
onAnimationVariantChanged: saveSettings()
|
||||||
|
property int motionEffect: SettingsData.AnimationEffect.Standard
|
||||||
|
onMotionEffectChanged: saveSettings()
|
||||||
property bool m3ElevationEnabled: true
|
property bool m3ElevationEnabled: true
|
||||||
onM3ElevationEnabledChanged: saveSettings()
|
onM3ElevationEnabledChanged: saveSettings()
|
||||||
property int m3ElevationIntensity: 12
|
property int m3ElevationIntensity: 12
|
||||||
@@ -186,18 +217,70 @@ Singleton {
|
|||||||
onPopoutElevationEnabledChanged: saveSettings()
|
onPopoutElevationEnabledChanged: saveSettings()
|
||||||
property bool barElevationEnabled: true
|
property bool barElevationEnabled: true
|
||||||
onBarElevationEnabledChanged: saveSettings()
|
onBarElevationEnabledChanged: saveSettings()
|
||||||
|
|
||||||
property bool blurEnabled: false
|
property bool blurEnabled: false
|
||||||
onBlurEnabledChanged: saveSettings()
|
onBlurEnabledChanged: saveSettings()
|
||||||
|
property bool blurForegroundLayers: true
|
||||||
|
onBlurForegroundLayersChanged: saveSettings()
|
||||||
|
property real blurLayerOutlineOpacity: 0.12
|
||||||
|
onBlurLayerOutlineOpacityChanged: saveSettings()
|
||||||
property string blurBorderColor: "outline"
|
property string blurBorderColor: "outline"
|
||||||
onBlurBorderColorChanged: saveSettings()
|
onBlurBorderColorChanged: saveSettings()
|
||||||
property string blurBorderCustomColor: "#ffffff"
|
property string blurBorderCustomColor: "#ffffff"
|
||||||
onBlurBorderCustomColorChanged: saveSettings()
|
onBlurBorderCustomColorChanged: saveSettings()
|
||||||
property real blurBorderOpacity: 1.0
|
property real blurBorderOpacity: 0.35
|
||||||
onBlurBorderOpacityChanged: saveSettings()
|
onBlurBorderOpacityChanged: saveSettings()
|
||||||
property string wallpaperFillMode: "Fill"
|
property string wallpaperFillMode: "Fill"
|
||||||
property bool blurredWallpaperLayer: false
|
property bool blurredWallpaperLayer: false
|
||||||
property bool blurWallpaperOnOverview: false
|
property bool blurWallpaperOnOverview: false
|
||||||
|
|
||||||
|
property bool frameEnabled: false
|
||||||
|
onFrameEnabledChanged: saveSettings()
|
||||||
|
property real frameThickness: 16
|
||||||
|
onFrameThicknessChanged: saveSettings()
|
||||||
|
property real frameRounding: 23
|
||||||
|
onFrameRoundingChanged: saveSettings()
|
||||||
|
property string frameColor: ""
|
||||||
|
onFrameColorChanged: saveSettings()
|
||||||
|
property real frameOpacity: 1.0
|
||||||
|
onFrameOpacityChanged: saveSettings()
|
||||||
|
property var frameScreenPreferences: ["all"]
|
||||||
|
onFrameScreenPreferencesChanged: saveSettings()
|
||||||
|
property real frameBarSize: 40
|
||||||
|
onFrameBarSizeChanged: saveSettings()
|
||||||
|
property bool frameShowOnOverview: false
|
||||||
|
onFrameShowOnOverviewChanged: saveSettings()
|
||||||
|
property bool frameBlurEnabled: true
|
||||||
|
onFrameBlurEnabledChanged: saveSettings()
|
||||||
|
property bool frameCloseGaps: true
|
||||||
|
onFrameCloseGapsChanged: saveSettings()
|
||||||
|
property string frameLauncherEmergeSide: "bottom"
|
||||||
|
onFrameLauncherEmergeSideChanged: saveSettings()
|
||||||
|
property bool frameLauncherArcExtender: false
|
||||||
|
onFrameLauncherArcExtenderChanged: saveSettings()
|
||||||
|
readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top"
|
||||||
|
property string frameMode: "separate"
|
||||||
|
onFrameModeChanged: saveSettings()
|
||||||
|
property var connectedFrameBarStyleBackups: ({})
|
||||||
|
onConnectedFrameBarStyleBackupsChanged: saveSettings()
|
||||||
|
readonly property bool connectedFrameModeActive: frameEnabled && frameMode === "connected"
|
||||||
|
onConnectedFrameModeActiveChanged: {
|
||||||
|
if (_loading)
|
||||||
|
return;
|
||||||
|
_reconcileConnectedFrameBarStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property color effectiveFrameColor: {
|
||||||
|
const fc = frameColor;
|
||||||
|
if (!fc || fc === "default")
|
||||||
|
return Theme.surfaceContainer;
|
||||||
|
if (fc === "primary")
|
||||||
|
return Theme.primary;
|
||||||
|
if (fc === "surface")
|
||||||
|
return Theme.surface;
|
||||||
|
return fc;
|
||||||
|
}
|
||||||
|
|
||||||
property bool showLauncherButton: true
|
property bool showLauncherButton: true
|
||||||
property bool showWorkspaceSwitcher: true
|
property bool showWorkspaceSwitcher: true
|
||||||
property bool showFocusedWindow: true
|
property bool showFocusedWindow: true
|
||||||
@@ -211,6 +294,9 @@ Singleton {
|
|||||||
property int selectedGpuIndex: 0
|
property int selectedGpuIndex: 0
|
||||||
property var enabledGpuPciIds: []
|
property var enabledGpuPciIds: []
|
||||||
property bool showSystemTray: true
|
property bool showSystemTray: true
|
||||||
|
property string systemTrayIconTintMode: "none"
|
||||||
|
property int systemTrayIconTintSaturation: 50
|
||||||
|
property int systemTrayIconTintStrength: 135
|
||||||
property bool showClock: true
|
property bool showClock: true
|
||||||
property bool showNotificationButton: true
|
property bool showNotificationButton: true
|
||||||
property bool showBattery: true
|
property bool showBattery: true
|
||||||
@@ -411,6 +497,8 @@ Singleton {
|
|||||||
property int fontWeight: Font.Normal
|
property int fontWeight: Font.Normal
|
||||||
property real fontScale: 1.0
|
property real fontScale: 1.0
|
||||||
property real dankBarFontScale: 1.0
|
property real dankBarFontScale: 1.0
|
||||||
|
property int textRenderType: SettingsData.TextRenderType.Native
|
||||||
|
property int textRenderQuality: SettingsData.TextRenderQuality.Default
|
||||||
|
|
||||||
property bool notepadUseMonospace: true
|
property bool notepadUseMonospace: true
|
||||||
property string notepadFontFamily: ""
|
property string notepadFontFamily: ""
|
||||||
@@ -486,6 +574,7 @@ Singleton {
|
|||||||
property bool matugenTemplatePywalfox: true
|
property bool matugenTemplatePywalfox: true
|
||||||
property bool matugenTemplateZenBrowser: true
|
property bool matugenTemplateZenBrowser: true
|
||||||
property bool matugenTemplateVesktop: true
|
property bool matugenTemplateVesktop: true
|
||||||
|
property bool matugenTemplateVencord: true
|
||||||
property bool matugenTemplateEquibop: true
|
property bool matugenTemplateEquibop: true
|
||||||
property bool matugenTemplateGhostty: true
|
property bool matugenTemplateGhostty: true
|
||||||
property bool matugenTemplateKitty: true
|
property bool matugenTemplateKitty: true
|
||||||
@@ -514,6 +603,7 @@ Singleton {
|
|||||||
property bool showDock: false
|
property bool showDock: false
|
||||||
property bool dockAutoHide: false
|
property bool dockAutoHide: false
|
||||||
property bool dockSmartAutoHide: false
|
property bool dockSmartAutoHide: false
|
||||||
|
property bool dockHideOnFullscreen: true
|
||||||
property bool dockGroupByApp: false
|
property bool dockGroupByApp: false
|
||||||
property bool dockRestoreSpecialWorkspaceOnClick: false
|
property bool dockRestoreSpecialWorkspaceOnClick: false
|
||||||
property bool dockOpenOnOverview: false
|
property bool dockOpenOnOverview: false
|
||||||
@@ -538,6 +628,9 @@ Singleton {
|
|||||||
property int dockMaxVisibleApps: 0
|
property int dockMaxVisibleApps: 0
|
||||||
property int dockMaxVisibleRunningApps: 0
|
property int dockMaxVisibleRunningApps: 0
|
||||||
property bool dockShowOverflowBadge: true
|
property bool dockShowOverflowBadge: true
|
||||||
|
property bool dockShowTrash: false
|
||||||
|
property string dockTrashFileManager: "default"
|
||||||
|
property string dockTrashCustomCommand: ""
|
||||||
|
|
||||||
property bool notificationOverlayEnabled: false
|
property bool notificationOverlayEnabled: false
|
||||||
property bool notificationPopupShadowEnabled: true
|
property bool notificationPopupShadowEnabled: true
|
||||||
@@ -630,6 +723,9 @@ Singleton {
|
|||||||
property bool updaterUseCustomCommand: false
|
property bool updaterUseCustomCommand: false
|
||||||
property string updaterCustomCommand: ""
|
property string updaterCustomCommand: ""
|
||||||
property string updaterTerminalAdditionalParams: ""
|
property string updaterTerminalAdditionalParams: ""
|
||||||
|
property int updaterIntervalSeconds: 1800
|
||||||
|
property bool updaterIncludeFlatpak: true
|
||||||
|
property bool updaterAllowAUR: true
|
||||||
|
|
||||||
property string displayNameMode: "system"
|
property string displayNameMode: "system"
|
||||||
property var screenPreferences: ({})
|
property var screenPreferences: ({})
|
||||||
@@ -641,6 +737,7 @@ Singleton {
|
|||||||
property bool displayProfileAutoSelect: false
|
property bool displayProfileAutoSelect: false
|
||||||
property bool displayShowDisconnected: false
|
property bool displayShowDisconnected: false
|
||||||
property bool displaySnapToEdge: true
|
property bool displaySnapToEdge: true
|
||||||
|
property var barIpcRevealStates: ({})
|
||||||
|
|
||||||
property var barConfigs: [
|
property var barConfigs: [
|
||||||
{
|
{
|
||||||
@@ -678,6 +775,7 @@ Singleton {
|
|||||||
"fontScale": 1.0,
|
"fontScale": 1.0,
|
||||||
"iconScale": 1.0,
|
"iconScale": 1.0,
|
||||||
"autoHide": false,
|
"autoHide": false,
|
||||||
|
"autoHideStrict": false,
|
||||||
"autoHideDelay": 250,
|
"autoHideDelay": 250,
|
||||||
"showOnWindowsOpen": false,
|
"showOnWindowsOpen": false,
|
||||||
"openOnOverview": false,
|
"openOnOverview": false,
|
||||||
@@ -1261,6 +1359,9 @@ Singleton {
|
|||||||
|
|
||||||
Store.parse(root, obj);
|
Store.parse(root, obj);
|
||||||
|
|
||||||
|
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
|
||||||
|
frameMode = "connected";
|
||||||
|
|
||||||
if (obj?.weatherLocation !== undefined)
|
if (obj?.weatherLocation !== undefined)
|
||||||
_legacyWeatherLocation = obj.weatherLocation;
|
_legacyWeatherLocation = obj.weatherLocation;
|
||||||
if (obj?.weatherCoordinates !== undefined)
|
if (obj?.weatherCoordinates !== undefined)
|
||||||
@@ -1281,13 +1382,14 @@ Singleton {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_parseError = true;
|
_parseError = true;
|
||||||
const msg = e.message;
|
const msg = e.message;
|
||||||
console.error("SettingsData: Failed to parse settings.json - file will not be overwritten. Error:", msg);
|
log.error("Failed to parse settings.json - file will not be overwritten. Error:", msg);
|
||||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
|
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
|
||||||
applyStoredTheme();
|
applyStoredTheme();
|
||||||
} finally {
|
} finally {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
loadPluginSettings();
|
loadPluginSettings();
|
||||||
|
Qt.callLater(() => _reconcileConnectedFrameBarStyles());
|
||||||
}
|
}
|
||||||
|
|
||||||
property var _pendingMigration: null
|
property var _pendingMigration: null
|
||||||
@@ -1302,12 +1404,12 @@ Singleton {
|
|||||||
if (_isReadOnly) {
|
if (_isReadOnly) {
|
||||||
_hasUnsavedChanges = _checkForUnsavedChanges();
|
_hasUnsavedChanges = _checkForUnsavedChanges();
|
||||||
if (!wasReadOnly)
|
if (!wasReadOnly)
|
||||||
console.info("SettingsData: settings.json is now read-only");
|
log.info("settings.json is now read-only");
|
||||||
} else {
|
} else {
|
||||||
_loadedSettingsSnapshot = JSON.stringify(Store.toJson(root));
|
_loadedSettingsSnapshot = JSON.stringify(Store.toJson(root));
|
||||||
_hasUnsavedChanges = false;
|
_hasUnsavedChanges = false;
|
||||||
if (wasReadOnly)
|
if (wasReadOnly)
|
||||||
console.info("SettingsData: settings.json is now writable");
|
log.info("settings.json is now writable");
|
||||||
if (_pendingMigration)
|
if (_pendingMigration)
|
||||||
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
|
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
|
||||||
}
|
}
|
||||||
@@ -1361,7 +1463,7 @@ Singleton {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e.message || String(e);
|
const msg = e.message || String(e);
|
||||||
if (!_isMissingPluginSettingsError(e))
|
if (!_isMissingPluginSettingsError(e))
|
||||||
console.warn("SettingsData: Failed to load plugin_settings.json. Error:", msg);
|
log.warn("Failed to load plugin_settings.json. Error:", msg);
|
||||||
_resetPluginSettings();
|
_resetPluginSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1378,7 +1480,7 @@ Singleton {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_pluginParseError = true;
|
_pluginParseError = true;
|
||||||
const msg = e.message;
|
const msg = e.message;
|
||||||
console.error("SettingsData: Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg);
|
log.error("Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg);
|
||||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse plugin_settings.json"), msg));
|
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse plugin_settings.json"), msg));
|
||||||
pluginSettings = {};
|
pluginSettings = {};
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1401,6 +1503,140 @@ Singleton {
|
|||||||
pluginSettingsFile.setText(JSON.stringify(pluginSettings, null, 2));
|
pluginSettingsFile.setText(JSON.stringify(pluginSettings, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _connectedFrameBarStyleSnapshot(config) {
|
||||||
|
return {
|
||||||
|
"shadowIntensity": config?.shadowIntensity ?? 0,
|
||||||
|
"squareCorners": config?.squareCorners ?? false,
|
||||||
|
"gothCornersEnabled": config?.gothCornersEnabled ?? false,
|
||||||
|
"borderEnabled": config?.borderEnabled ?? false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hasConnectedFrameBarStyleBackups() {
|
||||||
|
return connectedFrameBarStyleBackups && Object.keys(connectedFrameBarStyleBackups).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _captureConnectedFrameBarStyleBackups(configs, overwriteExisting) {
|
||||||
|
if (!Array.isArray(configs))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
|
||||||
|
const validIds = {};
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < configs.length; i++) {
|
||||||
|
const config = configs[i];
|
||||||
|
if (!config?.id)
|
||||||
|
continue;
|
||||||
|
validIds[config.id] = true;
|
||||||
|
|
||||||
|
if (!overwriteExisting && nextBackups[config.id] !== undefined)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const snapshot = _connectedFrameBarStyleSnapshot(config);
|
||||||
|
if (JSON.stringify(nextBackups[config.id]) !== JSON.stringify(snapshot)) {
|
||||||
|
nextBackups[config.id] = snapshot;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overwriteExisting) {
|
||||||
|
for (const barId in nextBackups) {
|
||||||
|
if (validIds[barId])
|
||||||
|
continue;
|
||||||
|
delete nextBackups[barId];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
connectedFrameBarStyleBackups = nextBackups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _restoreConnectedFrameBarStyleBackups() {
|
||||||
|
if (!_hasConnectedFrameBarStyleBackups())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const backups = connectedFrameBarStyleBackups || {};
|
||||||
|
const configs = JSON.parse(JSON.stringify(barConfigs));
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < configs.length; i++) {
|
||||||
|
const backup = backups[configs[i].id];
|
||||||
|
if (!backup)
|
||||||
|
continue;
|
||||||
|
for (const key in backup) {
|
||||||
|
if (configs[i][key] === backup[key])
|
||||||
|
continue;
|
||||||
|
configs[i][key] = backup[key];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
barConfigs = configs;
|
||||||
|
connectedFrameBarStyleBackups = ({});
|
||||||
|
if (changed)
|
||||||
|
updateBarConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeroes out connected-mode-hostile fields (shadow, square/goth corners, border).
|
||||||
|
// Returns { configs, changed } — `configs` is the same ref when no change.
|
||||||
|
function _sanitizeBarConfigsForConnectedFrame(configs) {
|
||||||
|
if (!connectedFrameModeActive || !Array.isArray(configs))
|
||||||
|
return {
|
||||||
|
"configs": configs,
|
||||||
|
"changed": false
|
||||||
|
};
|
||||||
|
|
||||||
|
let anyChanged = false;
|
||||||
|
const out = configs.map(cfg => {
|
||||||
|
if (!cfg)
|
||||||
|
return cfg;
|
||||||
|
let dirty = false;
|
||||||
|
const s = Object.assign({}, cfg);
|
||||||
|
if ((s.shadowIntensity ?? 0) !== 0) {
|
||||||
|
s.shadowIntensity = 0;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
if (s.squareCorners ?? false) {
|
||||||
|
s.squareCorners = false;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
if (s.gothCornersEnabled ?? false) {
|
||||||
|
s.gothCornersEnabled = false;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
if (s.borderEnabled ?? false) {
|
||||||
|
s.borderEnabled = false;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
if (dirty)
|
||||||
|
anyChanged = true;
|
||||||
|
return dirty ? s : cfg;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
"configs": anyChanged ? out : configs,
|
||||||
|
"changed": anyChanged
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single entry point for connected-mode settings state.
|
||||||
|
// !active → restore backups
|
||||||
|
function _reconcileConnectedFrameBarStyles() {
|
||||||
|
if (!connectedFrameModeActive) {
|
||||||
|
_restoreConnectedFrameBarStyleBackups();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_hasConnectedFrameBarStyleBackups())
|
||||||
|
_captureConnectedFrameBarStyleBackups(barConfigs, true);
|
||||||
|
const result = _sanitizeBarConfigsForConnectedFrame(barConfigs);
|
||||||
|
if (result.changed) {
|
||||||
|
barConfigs = result.configs;
|
||||||
|
updateBarConfigs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function detectAvailableIconThemes() {
|
function detectAvailableIconThemes() {
|
||||||
const xdgDataDirs = Quickshell.env("XDG_DATA_DIRS") || "";
|
const xdgDataDirs = Quickshell.env("XDG_DATA_DIRS") || "";
|
||||||
const localData = Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation));
|
const localData = Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation));
|
||||||
@@ -1548,35 +1784,37 @@ Singleton {
|
|||||||
const spacing = barSpacing !== undefined ? barSpacing : (defaultBar?.spacing ?? 4);
|
const spacing = barSpacing !== undefined ? barSpacing : (defaultBar?.spacing ?? 4);
|
||||||
const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top);
|
const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top);
|
||||||
const rawBottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0);
|
const rawBottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0);
|
||||||
const bottomGap = Math.max(0, rawBottomGap);
|
const isConnected = connectedFrameModeActive;
|
||||||
|
const bottomGap = isConnected ? 0 : Math.max(0, rawBottomGap);
|
||||||
|
|
||||||
const useAutoGaps = (barConfig && barConfig.popupGapsAuto !== undefined) ? barConfig.popupGapsAuto : (defaultBar?.popupGapsAuto ?? true);
|
const useAutoGaps = (barConfig && barConfig.popupGapsAuto !== undefined) ? barConfig.popupGapsAuto : (defaultBar?.popupGapsAuto ?? true);
|
||||||
const manualGapValue = (barConfig && barConfig.popupGapsManual !== undefined) ? barConfig.popupGapsManual : (defaultBar?.popupGapsManual ?? 4);
|
const manualGapValue = (barConfig && barConfig.popupGapsManual !== undefined) ? barConfig.popupGapsManual : (defaultBar?.popupGapsManual ?? 4);
|
||||||
const popupGap = useAutoGaps ? Math.max(4, spacing) : manualGapValue;
|
const popupGap = isConnected ? 0 : (useAutoGaps ? Math.max(4, spacing) : manualGapValue);
|
||||||
|
const edgeSpacing = isConnected ? 0 : spacing;
|
||||||
|
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case SettingsData.Position.Left:
|
case SettingsData.Position.Left:
|
||||||
return {
|
return {
|
||||||
"x": barThickness + spacing + popupGap,
|
"x": barThickness + edgeSpacing + popupGap,
|
||||||
"y": relativeY,
|
"y": relativeY,
|
||||||
"width": widgetWidth
|
"width": widgetWidth
|
||||||
};
|
};
|
||||||
case SettingsData.Position.Right:
|
case SettingsData.Position.Right:
|
||||||
return {
|
return {
|
||||||
"x": (screen?.width || 0) - (barThickness + spacing + popupGap),
|
"x": (screen?.width || 0) - (barThickness + edgeSpacing + popupGap),
|
||||||
"y": relativeY,
|
"y": relativeY,
|
||||||
"width": widgetWidth
|
"width": widgetWidth
|
||||||
};
|
};
|
||||||
case SettingsData.Position.Bottom:
|
case SettingsData.Position.Bottom:
|
||||||
return {
|
return {
|
||||||
"x": relativeX,
|
"x": relativeX,
|
||||||
"y": (screen?.height || 0) - (barThickness + spacing + bottomGap + popupGap),
|
"y": (screen?.height || 0) - (barThickness + edgeSpacing + bottomGap + popupGap),
|
||||||
"width": widgetWidth
|
"width": widgetWidth
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
"x": relativeX,
|
"x": relativeX,
|
||||||
"y": barThickness + spacing + bottomGap + popupGap,
|
"y": barThickness + edgeSpacing + bottomGap + popupGap,
|
||||||
"width": widgetWidth
|
"width": widgetWidth
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1670,7 +1908,9 @@ Singleton {
|
|||||||
const screenWidth = screen.width;
|
const screenWidth = screen.width;
|
||||||
const screenHeight = screen.height;
|
const screenHeight = screen.height;
|
||||||
const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top);
|
const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top);
|
||||||
const bottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0);
|
const isConnected = connectedFrameModeActive;
|
||||||
|
const rawBottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0);
|
||||||
|
const bottomGap = isConnected ? 0 : rawBottomGap;
|
||||||
|
|
||||||
let topOffset = 0;
|
let topOffset = 0;
|
||||||
let bottomOffset = 0;
|
let bottomOffset = 0;
|
||||||
@@ -1692,7 +1932,7 @@ Singleton {
|
|||||||
const otherSpacing = other.spacing !== undefined ? other.spacing : (defaultBar?.spacing ?? 4);
|
const otherSpacing = other.spacing !== undefined ? other.spacing : (defaultBar?.spacing ?? 4);
|
||||||
const otherPadding = other.innerPadding !== undefined ? other.innerPadding : (defaultBar?.innerPadding ?? 4);
|
const otherPadding = other.innerPadding !== undefined ? other.innerPadding : (defaultBar?.innerPadding ?? 4);
|
||||||
const otherThickness = Math.max(26 + otherPadding * 0.6, Theme.barHeight - 4 - (8 - otherPadding)) + otherSpacing + wingSize;
|
const otherThickness = Math.max(26 + otherPadding * 0.6, Theme.barHeight - 4 - (8 - otherPadding)) + otherSpacing + wingSize;
|
||||||
const otherBottomGap = other.bottomGap !== undefined ? other.bottomGap : (defaultBar?.bottomGap ?? 0);
|
const otherBottomGap = isConnected ? 0 : (other.bottomGap !== undefined ? other.bottomGap : (defaultBar?.bottomGap ?? 0));
|
||||||
|
|
||||||
switch (other.position) {
|
switch (other.position) {
|
||||||
case SettingsData.Position.Top:
|
case SettingsData.Position.Top:
|
||||||
@@ -1780,10 +2020,39 @@ Singleton {
|
|||||||
return barConfigs.find(cfg => cfg.id === barId) || null;
|
return barConfigs.find(cfg => cfg.id === barId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBarIpcRevealed(barId) {
|
||||||
|
if (!barId)
|
||||||
|
return false;
|
||||||
|
return !!barIpcRevealStates[barId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBarIpcReveal(barId, revealed) {
|
||||||
|
if (!barId)
|
||||||
|
return;
|
||||||
|
const nextRevealed = !!revealed;
|
||||||
|
if (!!barIpcRevealStates[barId] === nextRevealed)
|
||||||
|
return;
|
||||||
|
const states = Object.assign({}, barIpcRevealStates);
|
||||||
|
if (nextRevealed) {
|
||||||
|
states[barId] = true;
|
||||||
|
} else {
|
||||||
|
delete states[barId];
|
||||||
|
}
|
||||||
|
barIpcRevealStates = states;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBarIpcReveal(barId) {
|
||||||
|
const revealed = !isBarIpcRevealed(barId);
|
||||||
|
setBarIpcReveal(barId, revealed);
|
||||||
|
return revealed;
|
||||||
|
}
|
||||||
|
|
||||||
function addBarConfig(config) {
|
function addBarConfig(config) {
|
||||||
const configs = JSON.parse(JSON.stringify(barConfigs));
|
const configs = JSON.parse(JSON.stringify(barConfigs));
|
||||||
configs.push(config);
|
configs.push(config);
|
||||||
barConfigs = configs;
|
if (connectedFrameModeActive)
|
||||||
|
_captureConnectedFrameBarStyleBackups(configs, false);
|
||||||
|
barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
|
||||||
updateBarConfigs();
|
updateBarConfigs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1793,9 +2062,11 @@ Singleton {
|
|||||||
if (index === -1)
|
if (index === -1)
|
||||||
return;
|
return;
|
||||||
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
|
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
|
||||||
|
if (updates.autoHide === false || updates.visible === false)
|
||||||
|
setBarIpcReveal(barId, false);
|
||||||
|
|
||||||
Object.assign(configs[index], updates);
|
Object.assign(configs[index], updates);
|
||||||
barConfigs = configs;
|
barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
|
||||||
updateBarConfigs();
|
updateBarConfigs();
|
||||||
|
|
||||||
if (positionChanged) {
|
if (positionChanged) {
|
||||||
@@ -1849,6 +2120,12 @@ Singleton {
|
|||||||
return;
|
return;
|
||||||
const configs = barConfigs.filter(cfg => cfg.id !== barId);
|
const configs = barConfigs.filter(cfg => cfg.id !== barId);
|
||||||
barConfigs = configs;
|
barConfigs = configs;
|
||||||
|
if (connectedFrameBarStyleBackups?.[barId] !== undefined) {
|
||||||
|
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
|
||||||
|
delete nextBackups[barId];
|
||||||
|
connectedFrameBarStyleBackups = nextBackups;
|
||||||
|
}
|
||||||
|
setBarIpcReveal(barId, false);
|
||||||
updateBarConfigs();
|
updateBarConfigs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1943,6 +2220,95 @@ Singleton {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFrameFilteredScreens() {
|
||||||
|
var prefs = frameScreenPreferences || ["all"];
|
||||||
|
if (!prefs || prefs.length === 0 || prefs.includes("all")) {
|
||||||
|
return Quickshell.screens;
|
||||||
|
}
|
||||||
|
return Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveBarEdgeForScreen(screen) {
|
||||||
|
if (!screen)
|
||||||
|
return "";
|
||||||
|
for (var i = 0; i < barConfigs.length; i++) {
|
||||||
|
var bc = barConfigs[i];
|
||||||
|
if (!bc.enabled)
|
||||||
|
continue;
|
||||||
|
var prefs = bc.screenPreferences || ["all"];
|
||||||
|
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||||
|
continue;
|
||||||
|
switch (bc.position ?? 0) {
|
||||||
|
case SettingsData.Position.Top:
|
||||||
|
return "top";
|
||||||
|
case SettingsData.Position.Bottom:
|
||||||
|
return "bottom";
|
||||||
|
case SettingsData.Position.Left:
|
||||||
|
return "left";
|
||||||
|
case SettingsData.Position.Right:
|
||||||
|
return "right";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveBarEdgesForScreen(screen) {
|
||||||
|
if (!screen)
|
||||||
|
return [];
|
||||||
|
var edges = [];
|
||||||
|
for (var i = 0; i < barConfigs.length; i++) {
|
||||||
|
var bc = barConfigs[i];
|
||||||
|
if (!bc.enabled)
|
||||||
|
continue;
|
||||||
|
var prefs = bc.screenPreferences || ["all"];
|
||||||
|
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||||
|
continue;
|
||||||
|
switch (bc.position ?? 0) {
|
||||||
|
case SettingsData.Position.Top:
|
||||||
|
edges.push("top");
|
||||||
|
break;
|
||||||
|
case SettingsData.Position.Bottom:
|
||||||
|
edges.push("bottom");
|
||||||
|
break;
|
||||||
|
case SettingsData.Position.Left:
|
||||||
|
edges.push("left");
|
||||||
|
break;
|
||||||
|
case SettingsData.Position.Right:
|
||||||
|
edges.push("right");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameEdgeInsetForSide(screen, side) {
|
||||||
|
if (!frameEnabled || !screen)
|
||||||
|
return 0;
|
||||||
|
const edges = getActiveBarEdgesForScreen(screen);
|
||||||
|
return edges.includes(side) ? frameBarSize : frameThickness;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveBarThicknessForScreen(screen) {
|
||||||
|
if (frameEnabled)
|
||||||
|
return frameBarSize;
|
||||||
|
if (!screen)
|
||||||
|
return frameThickness;
|
||||||
|
for (var i = 0; i < barConfigs.length; i++) {
|
||||||
|
var bc = barConfigs[i];
|
||||||
|
if (!bc.enabled)
|
||||||
|
continue;
|
||||||
|
var prefs = bc.screenPreferences || ["all"];
|
||||||
|
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||||
|
continue;
|
||||||
|
const innerPadding = bc.innerPadding ?? 4;
|
||||||
|
const barT = Math.max(26 + innerPadding * 0.6, Theme.barHeight - 4 - (8 - innerPadding));
|
||||||
|
const spacing = bc.spacing ?? 4;
|
||||||
|
const bottomGap = bc.bottomGap ?? 0;
|
||||||
|
return barT + spacing + bottomGap;
|
||||||
|
}
|
||||||
|
return frameThickness;
|
||||||
|
}
|
||||||
|
|
||||||
function sendTestNotifications() {
|
function sendTestNotifications() {
|
||||||
NotificationService.dismissAllPopups();
|
NotificationService.dismissAllPopups();
|
||||||
sendTestNotification(0);
|
sendTestNotification(0);
|
||||||
@@ -2781,7 +3147,7 @@ Singleton {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_parseError = true;
|
_parseError = true;
|
||||||
const msg = e.message;
|
const msg = e.message;
|
||||||
console.error("SettingsData: Failed to reload settings.json - file will not be overwritten. Error:", msg);
|
log.error("Failed to reload settings.json - file will not be overwritten. Error:", msg);
|
||||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
|
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
|
||||||
} finally {
|
} finally {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
@@ -2816,7 +3182,7 @@ Singleton {
|
|||||||
if (!isGreeterMode) {
|
if (!isGreeterMode) {
|
||||||
const msg = String(error || "");
|
const msg = String(error || "");
|
||||||
if (!_isMissingPluginSettingsError(error))
|
if (!_isMissingPluginSettingsError(error))
|
||||||
console.warn("SettingsData: Failed to load plugin_settings.json. Error:", msg);
|
log.warn("Failed to load plugin_settings.json. Error:", msg);
|
||||||
_resetPluginSettings();
|
_resetPluginSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import "StockThemes.js" as StockThemes
|
|||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
readonly property var log: Log.scoped("Theme")
|
||||||
|
|
||||||
readonly property string stateDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericCacheLocation).toString()) + "/DankMaterialShell"
|
readonly property string stateDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericCacheLocation).toString()) + "/DankMaterialShell"
|
||||||
readonly property bool envDisableMatugen: Quickshell.env("DMS_DISABLE_MATUGEN") === "1" || Quickshell.env("DMS_DISABLE_MATUGEN") === "true"
|
readonly property bool envDisableMatugen: Quickshell.env("DMS_DISABLE_MATUGEN") === "1" || Quickshell.env("DMS_DISABLE_MATUGEN") === "true"
|
||||||
@@ -148,7 +149,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (colorsFileLoadFailed && currentTheme === dynamic && rawWallpaperPath) {
|
if (colorsFileLoadFailed && currentTheme === dynamic && rawWallpaperPath) {
|
||||||
console.info("Theme: Matugen now available, regenerating colors for dynamic theme");
|
log.info("Matugen now available, regenerating colors for dynamic theme");
|
||||||
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
|
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
|
||||||
const iconTheme = (typeof SettingsData !== "undefined" && SettingsData.iconTheme) ? SettingsData.iconTheme : "System Default";
|
const iconTheme = (typeof SettingsData !== "undefined" && SettingsData.iconTheme) ? SettingsData.iconTheme : "System Default";
|
||||||
const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot";
|
const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot";
|
||||||
@@ -341,19 +342,6 @@ Singleton {
|
|||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: DMSService
|
target: DMSService
|
||||||
enabled: typeof DMSService !== "undefined" && typeof SessionData !== "undefined"
|
|
||||||
|
|
||||||
function onLoginctlEvent(event) {
|
|
||||||
if (!SessionData.themeModeAutoEnabled)
|
|
||||||
return;
|
|
||||||
if (event.event === "unlock" || event.event === "resume") {
|
|
||||||
if (!themeAutoBackendAvailable()) {
|
|
||||||
root.evaluateThemeMode();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
DMSService.sendRequest("theme.auto.trigger", {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onThemeAutoStateUpdate(data) {
|
function onThemeAutoStateUpdate(data) {
|
||||||
if (!SessionData.themeModeAutoEnabled) {
|
if (!SessionData.themeModeAutoEnabled) {
|
||||||
@@ -389,7 +377,7 @@ Singleton {
|
|||||||
"use": true
|
"use": true
|
||||||
}, response => {
|
}, response => {
|
||||||
if (!response.error) {
|
if (!response.error) {
|
||||||
console.info("Theme automation: IP location enabled after connection");
|
log.info("Theme automation: IP location enabled after connection");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
|
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
|
||||||
@@ -402,18 +390,39 @@ Singleton {
|
|||||||
"longitude": SessionData.longitude
|
"longitude": SessionData.longitude
|
||||||
}, locationResponse => {
|
}, locationResponse => {
|
||||||
if (locationResponse?.error) {
|
if (locationResponse?.error) {
|
||||||
console.warn("Theme automation: Failed to set location", locationResponse.error);
|
log.warn("Theme automation: Failed to set location", locationResponse.error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn("Theme automation: No location configured");
|
log.warn("Theme automation: No location configured");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SessionService
|
||||||
|
enabled: SessionData.themeModeAutoEnabled
|
||||||
|
|
||||||
|
function onSessionUnlocked() {
|
||||||
|
root.triggerThemeAutomationRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSessionResumed() {
|
||||||
|
root.triggerThemeAutomationRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerThemeAutomationRefresh() {
|
||||||
|
if (!themeAutoBackendAvailable()) {
|
||||||
|
root.evaluateThemeMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DMSService.sendRequest("theme.auto.trigger", {});
|
||||||
|
}
|
||||||
|
|
||||||
function applyGreeterTheme(themeName) {
|
function applyGreeterTheme(themeName) {
|
||||||
switchTheme(themeName, false, false);
|
switchTheme(themeName, false, false);
|
||||||
if (themeName === dynamic && dynamicColorsFileView.path) {
|
if (themeName === dynamic && dynamicColorsFileView.path) {
|
||||||
@@ -441,6 +450,7 @@ Singleton {
|
|||||||
"primaryText": getMatugenColor("on_primary", "#ffffff"),
|
"primaryText": getMatugenColor("on_primary", "#ffffff"),
|
||||||
"primaryContainer": getMatugenColor("primary_container", "#1976d2"),
|
"primaryContainer": getMatugenColor("primary_container", "#1976d2"),
|
||||||
"secondary": getMatugenColor("secondary", "#8ab4f8"),
|
"secondary": getMatugenColor("secondary", "#8ab4f8"),
|
||||||
|
"tertiary": getMatugenColor("tertiary", "#efb8c8"),
|
||||||
"surface": getMatugenColor("surface", "#1a1c1e"),
|
"surface": getMatugenColor("surface", "#1a1c1e"),
|
||||||
"surfaceText": getMatugenColor("on_background", "#e3e8ef"),
|
"surfaceText": getMatugenColor("on_background", "#e3e8ef"),
|
||||||
"surfaceVariant": getMatugenColor("surface_variant", "#44464f"),
|
"surfaceVariant": getMatugenColor("surface_variant", "#44464f"),
|
||||||
@@ -513,6 +523,7 @@ Singleton {
|
|||||||
property color primaryText: currentThemeData.primaryText
|
property color primaryText: currentThemeData.primaryText
|
||||||
property color primaryContainer: currentThemeData.primaryContainer
|
property color primaryContainer: currentThemeData.primaryContainer
|
||||||
property color secondary: currentThemeData.secondary
|
property color secondary: currentThemeData.secondary
|
||||||
|
property color tertiary: currentThemeData.tertiary || currentThemeData.secondary
|
||||||
property color surface: currentThemeData.surface
|
property color surface: currentThemeData.surface
|
||||||
property color surfaceText: currentThemeData.surfaceText
|
property color surfaceText: currentThemeData.surfaceText
|
||||||
property color surfaceVariant: currentThemeData.surfaceVariant
|
property color surfaceVariant: currentThemeData.surfaceVariant
|
||||||
@@ -541,8 +552,8 @@ Singleton {
|
|||||||
property color success: currentThemeData.success || "#4CAF50"
|
property color success: currentThemeData.success || "#4CAF50"
|
||||||
|
|
||||||
property color primaryHover: Qt.rgba(primary.r, primary.g, primary.b, 0.12)
|
property color primaryHover: Qt.rgba(primary.r, primary.g, primary.b, 0.12)
|
||||||
property color primaryHoverLight: Qt.rgba(primary.r, primary.g, primary.b, 0.08)
|
property color primaryHoverLight: Qt.rgba(primary.r, primary.g, primary.b, transparentBlurLayers ? 0.12 : 0.08)
|
||||||
property color primaryPressed: Qt.rgba(primary.r, primary.g, primary.b, 0.16)
|
property color primaryPressed: Qt.rgba(primary.r, primary.g, primary.b, transparentBlurLayers ? 0.24 : 0.16)
|
||||||
property color primarySelected: Qt.rgba(primary.r, primary.g, primary.b, 0.3)
|
property color primarySelected: Qt.rgba(primary.r, primary.g, primary.b, 0.3)
|
||||||
property color primaryBackground: Qt.rgba(primary.r, primary.g, primary.b, 0.04)
|
property color primaryBackground: Qt.rgba(primary.r, primary.g, primary.b, 0.04)
|
||||||
|
|
||||||
@@ -551,17 +562,28 @@ Singleton {
|
|||||||
property color surfaceHover: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.08)
|
property color surfaceHover: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.08)
|
||||||
property color surfacePressed: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.12)
|
property color surfacePressed: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.12)
|
||||||
property color surfaceSelected: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.15)
|
property color surfaceSelected: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.15)
|
||||||
property color surfaceLight: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.1)
|
property color surfaceLight: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, transparentBlurLayers ? 0.3 : 0.1)
|
||||||
property color surfaceVariantAlpha: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.2)
|
property color surfaceVariantAlpha: Qt.rgba(surfaceVariant.r, surfaceVariant.g, surfaceVariant.b, 0.2)
|
||||||
|
|
||||||
|
readonly property bool blurForegroundLayers: BlurService.enabled && (typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true))
|
||||||
|
readonly property bool transparentBlurLayers: BlurService.enabled && !blurForegroundLayers
|
||||||
|
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
|
||||||
|
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
|
||||||
|
readonly property color floatingSurface: transparentBlurLayers ? "transparent" : readableSurface
|
||||||
|
readonly property color floatingSurfaceHigh: transparentBlurLayers ? "transparent" : readableSurfaceHigh
|
||||||
|
readonly property color nestedSurface: floatingSurfaceHigh
|
||||||
|
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
|
||||||
|
readonly property real layerOutlineOpacity: BlurService.enabled ? blurLayerOutlineOpacity : 0.08
|
||||||
|
readonly property int layerOutlineWidth: BlurService.enabled && layerOutlineOpacity > 0 ? 1 : 0
|
||||||
property color surfaceTextHover: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.08)
|
property color surfaceTextHover: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.08)
|
||||||
property color surfaceTextAlpha: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.3)
|
property color surfaceTextAlpha: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.3)
|
||||||
property color surfaceTextLight: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.06)
|
property color surfaceTextLight: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.06)
|
||||||
property color surfaceTextMedium: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.7)
|
property color surfaceTextMedium: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.7)
|
||||||
|
|
||||||
property color outlineButton: Qt.rgba(outline.r, outline.g, outline.b, 0.5)
|
property color outlineButton: Qt.rgba(outline.r, outline.g, outline.b, 0.5)
|
||||||
property color outlineLight: Qt.rgba(outline.r, outline.g, outline.b, 0.05)
|
property color outlineLight: Qt.rgba(outline.r, outline.g, outline.b, BlurService.enabled ? Math.min(1, layerOutlineOpacity * 0.625) : 0.05)
|
||||||
property color outlineMedium: Qt.rgba(outline.r, outline.g, outline.b, 0.08)
|
property color outlineMedium: Qt.rgba(outline.r, outline.g, outline.b, layerOutlineOpacity)
|
||||||
property color outlineStrong: Qt.rgba(outline.r, outline.g, outline.b, 0.12)
|
property color outlineStrong: Qt.rgba(outline.r, outline.g, outline.b, BlurService.enabled ? Math.min(1, layerOutlineOpacity * 1.5) : 0.12)
|
||||||
|
|
||||||
property color errorHover: Qt.rgba(error.r, error.g, error.b, 0.12)
|
property color errorHover: Qt.rgba(error.r, error.g, error.b, 0.12)
|
||||||
property color errorPressed: Qt.rgba(error.r, error.g, error.b, 0.16)
|
property color errorPressed: Qt.rgba(error.r, error.g, error.b, 0.16)
|
||||||
@@ -579,6 +601,12 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly property color ccTileInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.16) : (blurForegroundLayers ? withAlpha(surfaceContainerHigh, Math.min(popupTransparency, 0.24)) : withAlpha(surfaceContainer, popupTransparency))
|
||||||
|
readonly property color ccPillInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.08) : nestedSurface
|
||||||
|
readonly property color ccPillInactiveHoverBg: transparentBlurLayers ? withAlpha(primary, 0.10) : primaryPressed
|
||||||
|
readonly property color ccSliderTrackColor: transparentBlurLayers ? surfaceText : surfaceContainerHigh
|
||||||
|
readonly property real ccSliderTrackOpacity: transparentBlurLayers ? 0.18 : popupTransparency
|
||||||
|
|
||||||
readonly property color ccTileActiveText: {
|
readonly property color ccTileActiveText: {
|
||||||
switch (SettingsData.controlCenterTileColorMode) {
|
switch (SettingsData.controlCenterTileColorMode) {
|
||||||
case "primaryContainer":
|
case "primaryContainer":
|
||||||
@@ -960,6 +988,46 @@ Singleton {
|
|||||||
"expressiveEffects": [0.34, 0.8, 0.34, 1, 1, 1]
|
"expressiveEffects": [0.34, 0.8, 0.34, 1, 1, 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme is the canonical access point for animation variant state. The
|
||||||
|
// aliases below forward to AnimVariants.qml so consumers don't need two
|
||||||
|
// imports. ~200 call sites read through Theme.variantEnterCurve /
|
||||||
|
// Theme.isConnectedEffect / etc. — do NOT migrate to AnimVariants directly.
|
||||||
|
readonly property list<real> variantEnterCurve: AnimVariants.variantEnterCurve
|
||||||
|
readonly property list<real> variantExitCurve: AnimVariants.variantExitCurve
|
||||||
|
readonly property list<real> variantModalEnterCurve: AnimVariants.variantModalEnterCurve
|
||||||
|
readonly property list<real> variantModalExitCurve: AnimVariants.variantModalExitCurve
|
||||||
|
readonly property list<real> variantPopoutEnterCurve: AnimVariants.variantPopoutEnterCurve
|
||||||
|
readonly property list<real> variantPopoutExitCurve: AnimVariants.variantPopoutExitCurve
|
||||||
|
readonly property real variantEnterDurationFactor: AnimVariants.variantEnterDurationFactor
|
||||||
|
readonly property real variantExitDurationFactor: AnimVariants.variantExitDurationFactor
|
||||||
|
readonly property real variantOpacityDurationScale: AnimVariants.variantOpacityDurationScale
|
||||||
|
readonly property bool isDirectionalEffect: AnimVariants.isDirectionalEffect
|
||||||
|
readonly property bool isDepthEffect: AnimVariants.isDepthEffect
|
||||||
|
readonly property bool isConnectedEffect: AnimVariants.isConnectedEffect
|
||||||
|
readonly property real connectedCornerRadius: {
|
||||||
|
if (typeof SettingsData === "undefined")
|
||||||
|
return 12;
|
||||||
|
return SettingsData.connectedFrameModeActive ? SettingsData.frameRounding : cornerRadius;
|
||||||
|
}
|
||||||
|
readonly property color connectedSurfaceColor: {
|
||||||
|
if (typeof SettingsData === "undefined")
|
||||||
|
return withAlpha(surfaceContainer, popupTransparency);
|
||||||
|
return isConnectedEffect ? Qt.rgba(SettingsData.effectiveFrameColor.r, SettingsData.effectiveFrameColor.g, SettingsData.effectiveFrameColor.b, SettingsData.frameOpacity) : withAlpha(surfaceContainer, popupTransparency);
|
||||||
|
}
|
||||||
|
readonly property real connectedSurfaceRadius: isConnectedEffect ? connectedCornerRadius : cornerRadius
|
||||||
|
readonly property bool connectedSurfaceBlurEnabled: (typeof SettingsData === "undefined") ? true : (!isConnectedEffect || SettingsData.frameBlurEnabled)
|
||||||
|
readonly property real effectScaleCollapsed: AnimVariants.effectScaleCollapsed
|
||||||
|
readonly property real effectAnimOffset: AnimVariants.effectAnimOffset
|
||||||
|
function variantDuration(baseDuration, entering) {
|
||||||
|
return AnimVariants.variantDuration(baseDuration, entering);
|
||||||
|
}
|
||||||
|
function variantExitCleanupPadding() {
|
||||||
|
return AnimVariants.variantExitCleanupPadding();
|
||||||
|
}
|
||||||
|
function variantCloseInterval(baseDuration) {
|
||||||
|
return AnimVariants.variantCloseInterval(baseDuration);
|
||||||
|
}
|
||||||
|
|
||||||
readonly property var animationPresetDurations: {
|
readonly property var animationPresetDurations: {
|
||||||
"none": 0,
|
"none": 0,
|
||||||
"short": 250,
|
"short": 250,
|
||||||
@@ -1035,6 +1103,9 @@ Singleton {
|
|||||||
return base === 0 ? 0 : Math.round(base * 0.85);
|
return base === 0 ? 0 : Math.round(base * 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly property int notificationInlineExpandDuration: notificationAnimationBaseDuration === 0 ? 0 : 185
|
||||||
|
readonly property int notificationInlineCollapseDuration: notificationAnimationBaseDuration === 0 ? 0 : 150
|
||||||
|
|
||||||
readonly property real notificationIconSizeNormal: 56
|
readonly property real notificationIconSizeNormal: 56
|
||||||
readonly property real notificationIconSizeCompact: 48
|
readonly property real notificationIconSizeCompact: 48
|
||||||
readonly property real notificationExpandedIconSizeNormal: 48
|
readonly property real notificationExpandedIconSizeNormal: 48
|
||||||
@@ -1125,7 +1196,13 @@ Singleton {
|
|||||||
property real iconSizeLarge: 32
|
property real iconSizeLarge: 32
|
||||||
|
|
||||||
property real panelTransparency: 0.85
|
property real panelTransparency: 0.85
|
||||||
property real popupTransparency: typeof SettingsData !== "undefined" && SettingsData.popupTransparency !== undefined ? SettingsData.popupTransparency : 1.0
|
property real popupTransparency: {
|
||||||
|
if (typeof SettingsData === "undefined")
|
||||||
|
return 1.0;
|
||||||
|
if (isConnectedEffect)
|
||||||
|
return SettingsData.frameOpacity !== undefined ? SettingsData.frameOpacity : 1.0;
|
||||||
|
return SettingsData.popupTransparency !== undefined ? SettingsData.popupTransparency : 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
function screenTransition() {
|
function screenTransition() {
|
||||||
if (CompositorService.isNiri) {
|
if (CompositorService.isNiri) {
|
||||||
@@ -1321,7 +1398,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadCustomThemeFromFile(filePath) {
|
function loadCustomThemeFromFile(filePath) {
|
||||||
customThemeFileView.path = filePath;
|
customThemeFileView.path = Paths.expandTilde(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reloadCustomThemeVariant() {
|
function reloadCustomThemeVariant() {
|
||||||
@@ -1500,12 +1577,12 @@ Singleton {
|
|||||||
|
|
||||||
function setDesiredTheme(kind, value, isLight, iconTheme, matugenType, stockColors) {
|
function setDesiredTheme(kind, value, isLight, iconTheme, matugenType, stockColors) {
|
||||||
if (!matugenAvailable) {
|
if (!matugenAvailable) {
|
||||||
console.warn("Theme: matugen not available or disabled - cannot set system theme");
|
log.warn("matugen not available or disabled - cannot set system theme");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workerRunning) {
|
if (workerRunning) {
|
||||||
console.info("Theme: Worker already running, queueing request");
|
log.info("Worker already running, queueing request");
|
||||||
pendingThemeRequest = {
|
pendingThemeRequest = {
|
||||||
kind,
|
kind,
|
||||||
value,
|
value,
|
||||||
@@ -1517,7 +1594,7 @@ Singleton {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info("Theme: Setting desired theme -", kind, "mode:", isLight ? "light" : "dark", stockColors ? "(stock colors)" : "(dynamic)");
|
log.info("Setting desired theme -", kind, "mode:", isLight ? "light" : "dark", stockColors ? "(stock colors)" : "(dynamic)");
|
||||||
|
|
||||||
if (typeof NiriService !== "undefined" && CompositorService.isNiri) {
|
if (typeof NiriService !== "undefined" && CompositorService.isNiri) {
|
||||||
NiriService.suppressNextToast();
|
NiriService.suppressNextToast();
|
||||||
@@ -1532,7 +1609,7 @@ Singleton {
|
|||||||
"runUserTemplates": (typeof SettingsData !== "undefined") ? SettingsData.runUserMatugenTemplates : true
|
"runUserTemplates": (typeof SettingsData !== "undefined") ? SettingsData.runUserMatugenTemplates : true
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Theme: Starting matugen worker");
|
log.debug("Starting matugen worker");
|
||||||
workerRunning = true;
|
workerRunning = true;
|
||||||
|
|
||||||
const args = ["dms", "matugen", "queue", "--state-dir", stateDir, "--shell-dir", shellDir, "--config-dir", configDir, "--kind", desired.kind, "--value", desired.value, "--mode", desired.mode, "--icon-theme", desired.iconTheme, "--matugen-type", desired.matugenType,];
|
const args = ["dms", "matugen", "queue", "--state-dir", stateDir, "--shell-dir", shellDir, "--config-dir", configDir, "--kind", desired.kind, "--value", desired.value, "--mode", desired.mode, "--icon-theme", desired.iconTheme, "--matugen-type", desired.matugenType,];
|
||||||
@@ -1556,7 +1633,7 @@ Singleton {
|
|||||||
if (typeof SettingsData !== "undefined") {
|
if (typeof SettingsData !== "undefined") {
|
||||||
const skipTemplates = [];
|
const skipTemplates = [];
|
||||||
if (!SettingsData.runDmsMatugenTemplates) {
|
if (!SettingsData.runDmsMatugenTemplates) {
|
||||||
skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "firefox", "pywalfox", "zenbrowser", "vesktop", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
|
skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "firefox", "pywalfox", "zenbrowser", "vesktop", "vencord", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
|
||||||
} else {
|
} else {
|
||||||
if (!SettingsData.matugenTemplateGtk)
|
if (!SettingsData.matugenTemplateGtk)
|
||||||
skipTemplates.push("gtk");
|
skipTemplates.push("gtk");
|
||||||
@@ -1578,6 +1655,8 @@ Singleton {
|
|||||||
skipTemplates.push("zenbrowser");
|
skipTemplates.push("zenbrowser");
|
||||||
if (!SettingsData.matugenTemplateVesktop)
|
if (!SettingsData.matugenTemplateVesktop)
|
||||||
skipTemplates.push("vesktop");
|
skipTemplates.push("vesktop");
|
||||||
|
if (!SettingsData.matugenTemplateVencord)
|
||||||
|
skipTemplates.push("vencord");
|
||||||
if (!SettingsData.matugenTemplateEquibop)
|
if (!SettingsData.matugenTemplateEquibop)
|
||||||
skipTemplates.push("equibop");
|
skipTemplates.push("equibop");
|
||||||
if (!SettingsData.matugenTemplateGhostty)
|
if (!SettingsData.matugenTemplateGhostty)
|
||||||
@@ -1690,7 +1769,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!darkTheme || !darkTheme.primary) {
|
if (!darkTheme || !darkTheme.primary) {
|
||||||
console.warn("Theme data not available for:", currentTheme);
|
log.warn("Theme data not available for:", currentTheme);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1824,6 +1903,12 @@ Singleton {
|
|||||||
return Qt.rgba(c.r, c.g, c.b, a);
|
return Qt.rgba(c.r, c.g, c.b, a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function popupLayerColor(baseColor) {
|
||||||
|
if (isConnectedEffect)
|
||||||
|
return connectedSurfaceColor;
|
||||||
|
return withAlpha(baseColor, popupTransparency);
|
||||||
|
}
|
||||||
|
|
||||||
function blendAlpha(c, a) {
|
function blendAlpha(c, a) {
|
||||||
return Qt.rgba(c.r, c.g, c.b, c.a * a);
|
return Qt.rgba(c.r, c.g, c.b, c.a * a);
|
||||||
}
|
}
|
||||||
@@ -1928,10 +2013,10 @@ Singleton {
|
|||||||
id: systemThemeGenerator
|
id: systemThemeGenerator
|
||||||
running: false
|
running: false
|
||||||
stdout: SplitParser {
|
stdout: SplitParser {
|
||||||
onRead: data => console.info("Theme worker:", data)
|
onRead: data => log.info("Theme worker:", data)
|
||||||
}
|
}
|
||||||
stderr: SplitParser {
|
stderr: SplitParser {
|
||||||
onRead: data => console.warn("Theme worker:", data)
|
onRead: data => log.warn("Theme worker:", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
onExited: exitCode => {
|
onExited: exitCode => {
|
||||||
@@ -1940,18 +2025,18 @@ Singleton {
|
|||||||
|
|
||||||
switch (exitCode) {
|
switch (exitCode) {
|
||||||
case 0:
|
case 0:
|
||||||
console.info("Theme: Matugen worker completed successfully");
|
log.info("Matugen worker completed successfully");
|
||||||
root.matugenCompleted(currentMode, "success");
|
root.matugenCompleted(currentMode, "success");
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
console.log("Theme: Matugen worker completed with code 2 (no changes needed)");
|
log.debug("Matugen worker completed with code 2 (no changes needed)");
|
||||||
root.matugenCompleted(currentMode, "no-changes");
|
root.matugenCompleted(currentMode, "no-changes");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (typeof ToastService !== "undefined") {
|
if (typeof ToastService !== "undefined") {
|
||||||
ToastService.showError("Theme worker failed (" + exitCode + ")");
|
ToastService.showError("Theme worker failed (" + exitCode + ")");
|
||||||
}
|
}
|
||||||
console.warn("Theme: Matugen worker failed with exit code:", exitCode);
|
log.warn("Matugen worker failed with exit code:", exitCode);
|
||||||
root.matugenCompleted(currentMode, "error");
|
root.matugenCompleted(currentMode, "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1960,13 +2045,14 @@ Singleton {
|
|||||||
|
|
||||||
const req = pendingThemeRequest;
|
const req = pendingThemeRequest;
|
||||||
pendingThemeRequest = null;
|
pendingThemeRequest = null;
|
||||||
console.info("Theme: Processing queued theme request");
|
log.info("Processing queued theme request");
|
||||||
setDesiredTheme(req.kind, req.value, req.isLight, req.iconTheme, req.matugenType, req.stockColors);
|
setDesiredTheme(req.kind, req.value, req.isLight, req.iconTheme, req.matugenType, req.stockColors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FileView {
|
FileView {
|
||||||
id: customThemeFileView
|
id: customThemeFileView
|
||||||
|
blockLoading: false
|
||||||
watchChanges: currentTheme === "custom"
|
watchChanges: currentTheme === "custom"
|
||||||
|
|
||||||
function parseAndLoadTheme() {
|
function parseAndLoadTheme() {
|
||||||
@@ -2013,7 +2099,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Theme: Failed to parse dynamic colors:", e);
|
log.error("Failed to parse dynamic colors:", e);
|
||||||
if (typeof ToastService !== "undefined") {
|
if (typeof ToastService !== "undefined") {
|
||||||
ToastService.wallpaperErrorStatus = "error";
|
ToastService.wallpaperErrorStatus = "error";
|
||||||
ToastService.showError("Dynamic colors parse error: " + e.message);
|
ToastService.showError("Dynamic colors parse error: " + e.message);
|
||||||
@@ -2033,11 +2119,11 @@ Singleton {
|
|||||||
|
|
||||||
onLoadFailed: function (error) {
|
onLoadFailed: function (error) {
|
||||||
if (currentTheme === dynamic) {
|
if (currentTheme === dynamic) {
|
||||||
console.warn("Theme: Dynamic colors file load failed, marking for regeneration");
|
log.warn("Dynamic colors file load failed, marking for regeneration");
|
||||||
colorsFileLoadFailed = true;
|
colorsFileLoadFailed = true;
|
||||||
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
|
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
|
||||||
if (!isGreeterMode && matugenAvailable && rawWallpaperPath) {
|
if (!isGreeterMode && matugenAvailable && rawWallpaperPath) {
|
||||||
console.log("Theme: Matugen available, triggering immediate regeneration");
|
log.debug("Matugen available, triggering immediate regeneration");
|
||||||
generateSystemThemesFromCurrentTheme();
|
generateSystemThemesFromCurrentTheme();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2161,7 +2247,7 @@ Singleton {
|
|||||||
"endMinute": endMinute
|
"endMinute": endMinute
|
||||||
}, response => {
|
}, response => {
|
||||||
if (response && response.error) {
|
if (response && response.error) {
|
||||||
console.error("Theme automation: Failed to sync time schedule:", response.error);
|
log.error("Theme automation: Failed to sync time schedule:", response.error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2254,9 +2340,9 @@ Singleton {
|
|||||||
|
|
||||||
if (root.themeModeAutomationActive) {
|
if (root.themeModeAutomationActive) {
|
||||||
if (SessionData.nightModeUseIPLocation) {
|
if (SessionData.nightModeUseIPLocation) {
|
||||||
console.warn("Theme automation: Waiting for IP location from backend");
|
log.warn("Theme automation: Waiting for IP location from backend");
|
||||||
} else {
|
} else {
|
||||||
console.warn("Theme automation: Location mode requires coordinates");
|
log.warn("Theme automation: Location mode requires coordinates");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2338,7 +2424,7 @@ Singleton {
|
|||||||
"use": true
|
"use": true
|
||||||
}, response => {
|
}, response => {
|
||||||
if (response?.error) {
|
if (response?.error) {
|
||||||
console.warn("Theme automation: Failed to enable IP location", response.error);
|
log.warn("Theme automation: Failed to enable IP location", response.error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -2352,7 +2438,7 @@ Singleton {
|
|||||||
"longitude": SessionData.longitude
|
"longitude": SessionData.longitude
|
||||||
}, locResp => {
|
}, locResp => {
|
||||||
if (locResp?.error) {
|
if (locResp?.error) {
|
||||||
console.warn("Theme automation: Failed to set location", locResp.error);
|
log.warn("Theme automation: Failed to set location", locResp.error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
93
quickshell/Common/htmlElide.js
Normal file
93
quickshell/Common/htmlElide.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
.pragma library
|
||||||
|
|
||||||
|
function stripHtmlTags(html) {
|
||||||
|
if (!html)
|
||||||
|
return "";
|
||||||
|
return String(html)
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, "\"")
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function elideRichText(html, visibleBudget) {
|
||||||
|
if (!html)
|
||||||
|
return "";
|
||||||
|
if (visibleBudget <= 0)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
var out = "";
|
||||||
|
var visible = 0;
|
||||||
|
var i = 0;
|
||||||
|
var openTags = [];
|
||||||
|
var len = html.length;
|
||||||
|
|
||||||
|
while (i < len && visible < visibleBudget) {
|
||||||
|
var ch = html.charAt(i);
|
||||||
|
if (ch === "<") {
|
||||||
|
var end = html.indexOf(">", i);
|
||||||
|
if (end < 0)
|
||||||
|
break;
|
||||||
|
var tag = html.substring(i, end + 1);
|
||||||
|
out += tag;
|
||||||
|
var isClose = tag.charAt(1) === "/";
|
||||||
|
var match = tag.match(/^<\/?([a-zA-Z]+)/);
|
||||||
|
var name = match ? match[1] : "";
|
||||||
|
if (isClose) {
|
||||||
|
if (openTags.length > 0 && openTags[openTags.length - 1] === name)
|
||||||
|
openTags.pop();
|
||||||
|
} else if (!tag.endsWith("/>") && name) {
|
||||||
|
openTags.push(name);
|
||||||
|
}
|
||||||
|
i = end + 1;
|
||||||
|
} else if (ch === "&") {
|
||||||
|
var eend = html.indexOf(";", i);
|
||||||
|
if (eend < 0 || eend - i > 6) {
|
||||||
|
out += "&";
|
||||||
|
visible++;
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
out += html.substring(i, eend + 1);
|
||||||
|
visible++;
|
||||||
|
i = eend + 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out += ch;
|
||||||
|
visible++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < len && html.charAt(i) === "<") {
|
||||||
|
var tend = html.indexOf(">", i);
|
||||||
|
if (tend < 0)
|
||||||
|
break;
|
||||||
|
var ttag = html.substring(i, tend + 1);
|
||||||
|
out += ttag;
|
||||||
|
var tisClose = ttag.charAt(1) === "/";
|
||||||
|
var tmatch = ttag.match(/^<\/?([a-zA-Z]+)/);
|
||||||
|
var tname = tmatch ? tmatch[1] : "";
|
||||||
|
if (tisClose) {
|
||||||
|
if (openTags.length > 0 && openTags[openTags.length - 1] === tname)
|
||||||
|
openTags.pop();
|
||||||
|
} else if (!ttag.endsWith("/>") && tname) {
|
||||||
|
openTags.push(tname);
|
||||||
|
}
|
||||||
|
i = tend + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < len) {
|
||||||
|
out = out.replace(/\s+$/, "");
|
||||||
|
while (openTags.length > 0)
|
||||||
|
out += "</" + openTags.pop() + ">";
|
||||||
|
out += "…";
|
||||||
|
} else {
|
||||||
|
while (openTags.length > 0)
|
||||||
|
out += "</" + openTags.pop() + ">";
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ var SPEC = {
|
|||||||
isLightMode: { def: false },
|
isLightMode: { def: false },
|
||||||
doNotDisturb: { def: false },
|
doNotDisturb: { def: false },
|
||||||
doNotDisturbUntil: { def: 0 },
|
doNotDisturbUntil: { def: 0 },
|
||||||
|
terminalOverride: { def: "" },
|
||||||
|
|
||||||
wallpaperPath: { def: "" },
|
wallpaperPath: { def: "" },
|
||||||
perMonitorWallpaper: { def: false },
|
perMonitorWallpaper: { def: false },
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ var SPEC = {
|
|||||||
modalAnimationSpeed: { def: 1 },
|
modalAnimationSpeed: { def: 1 },
|
||||||
modalCustomAnimationDuration: { def: 150 },
|
modalCustomAnimationDuration: { def: 150 },
|
||||||
enableRippleEffects: { def: true },
|
enableRippleEffects: { def: true },
|
||||||
|
animationVariant: { def: 0 },
|
||||||
|
motionEffect: { def: 0 },
|
||||||
m3ElevationEnabled: { def: true },
|
m3ElevationEnabled: { def: true },
|
||||||
m3ElevationIntensity: { def: 12 },
|
m3ElevationIntensity: { def: 12 },
|
||||||
m3ElevationOpacity: { def: 30 },
|
m3ElevationOpacity: { def: 30 },
|
||||||
@@ -59,9 +61,11 @@ var SPEC = {
|
|||||||
popoutElevationEnabled: { def: true },
|
popoutElevationEnabled: { def: true },
|
||||||
barElevationEnabled: { def: true },
|
barElevationEnabled: { def: true },
|
||||||
blurEnabled: { def: false },
|
blurEnabled: { def: false },
|
||||||
|
blurForegroundLayers: { def: true },
|
||||||
|
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
|
||||||
blurBorderColor: { def: "outline" },
|
blurBorderColor: { def: "outline" },
|
||||||
blurBorderCustomColor: { def: "#ffffff" },
|
blurBorderCustomColor: { def: "#ffffff" },
|
||||||
blurBorderOpacity: { def: 1.0, coerce: percentToUnit },
|
blurBorderOpacity: { def: 0.35, coerce: percentToUnit },
|
||||||
wallpaperFillMode: { def: "Fill" },
|
wallpaperFillMode: { def: "Fill" },
|
||||||
blurredWallpaperLayer: { def: false },
|
blurredWallpaperLayer: { def: false },
|
||||||
blurWallpaperOnOverview: { def: false },
|
blurWallpaperOnOverview: { def: false },
|
||||||
@@ -79,6 +83,9 @@ var SPEC = {
|
|||||||
selectedGpuIndex: { def: 0 },
|
selectedGpuIndex: { def: 0 },
|
||||||
enabledGpuPciIds: { def: [] },
|
enabledGpuPciIds: { def: [] },
|
||||||
showSystemTray: { def: true },
|
showSystemTray: { def: true },
|
||||||
|
systemTrayIconTintMode: { def: "none" },
|
||||||
|
systemTrayIconTintSaturation: { def: 50 },
|
||||||
|
systemTrayIconTintStrength: { def: 135 },
|
||||||
showClock: { def: true },
|
showClock: { def: true },
|
||||||
showNotificationButton: { def: true },
|
showNotificationButton: { def: true },
|
||||||
showBattery: { def: true },
|
showBattery: { def: true },
|
||||||
@@ -235,6 +242,8 @@ var SPEC = {
|
|||||||
monoFontFamily: { def: "Fira Code" },
|
monoFontFamily: { def: "Fira Code" },
|
||||||
fontWeight: { def: 400 },
|
fontWeight: { def: 400 },
|
||||||
fontScale: { def: 1.0 },
|
fontScale: { def: 1.0 },
|
||||||
|
textRenderType: { def: 1 },
|
||||||
|
textRenderQuality: { def: 0 },
|
||||||
|
|
||||||
notepadUseMonospace: { def: true },
|
notepadUseMonospace: { def: true },
|
||||||
notepadFontFamily: { def: "" },
|
notepadFontFamily: { def: "" },
|
||||||
@@ -297,6 +306,7 @@ var SPEC = {
|
|||||||
matugenTemplatePywalfox: { def: true },
|
matugenTemplatePywalfox: { def: true },
|
||||||
matugenTemplateZenBrowser: { def: true },
|
matugenTemplateZenBrowser: { def: true },
|
||||||
matugenTemplateVesktop: { def: true },
|
matugenTemplateVesktop: { def: true },
|
||||||
|
matugenTemplateVencord: { def: true },
|
||||||
matugenTemplateEquibop: { def: true },
|
matugenTemplateEquibop: { def: true },
|
||||||
matugenTemplateGhostty: { def: true },
|
matugenTemplateGhostty: { def: true },
|
||||||
matugenTemplateKitty: { def: true },
|
matugenTemplateKitty: { def: true },
|
||||||
@@ -321,6 +331,7 @@ var SPEC = {
|
|||||||
showDock: { def: false },
|
showDock: { def: false },
|
||||||
dockAutoHide: { def: false },
|
dockAutoHide: { def: false },
|
||||||
dockSmartAutoHide: { def: false },
|
dockSmartAutoHide: { def: false },
|
||||||
|
dockHideOnFullscreen: { def: true },
|
||||||
dockGroupByApp: { def: false },
|
dockGroupByApp: { def: false },
|
||||||
dockRestoreSpecialWorkspaceOnClick: { def: false },
|
dockRestoreSpecialWorkspaceOnClick: { def: false },
|
||||||
dockOpenOnOverview: { def: false },
|
dockOpenOnOverview: { def: false },
|
||||||
@@ -345,6 +356,9 @@ var SPEC = {
|
|||||||
dockMaxVisibleApps: { def: 0 },
|
dockMaxVisibleApps: { def: 0 },
|
||||||
dockMaxVisibleRunningApps: { def: 0 },
|
dockMaxVisibleRunningApps: { def: 0 },
|
||||||
dockShowOverflowBadge: { def: true },
|
dockShowOverflowBadge: { def: true },
|
||||||
|
dockShowTrash: { def: false },
|
||||||
|
dockTrashFileManager: { def: "default" },
|
||||||
|
dockTrashCustomCommand: { def: "" },
|
||||||
|
|
||||||
notificationOverlayEnabled: { def: false },
|
notificationOverlayEnabled: { def: false },
|
||||||
notificationPopupShadowEnabled: { def: true },
|
notificationPopupShadowEnabled: { def: true },
|
||||||
@@ -420,6 +434,9 @@ var SPEC = {
|
|||||||
updaterUseCustomCommand: { def: false },
|
updaterUseCustomCommand: { def: false },
|
||||||
updaterCustomCommand: { def: "" },
|
updaterCustomCommand: { def: "" },
|
||||||
updaterTerminalAdditionalParams: { def: "" },
|
updaterTerminalAdditionalParams: { def: "" },
|
||||||
|
updaterIntervalSeconds: { def: 1800 },
|
||||||
|
updaterIncludeFlatpak: { def: true },
|
||||||
|
updaterAllowAUR: { def: true },
|
||||||
|
|
||||||
displayNameMode: { def: "system" },
|
displayNameMode: { def: "system" },
|
||||||
screenPreferences: { def: {} },
|
screenPreferences: { def: {} },
|
||||||
@@ -431,6 +448,7 @@ var SPEC = {
|
|||||||
displayProfileAutoSelect: { def: false },
|
displayProfileAutoSelect: { def: false },
|
||||||
displayShowDisconnected: { def: false },
|
displayShowDisconnected: { def: false },
|
||||||
displaySnapToEdge: { def: true },
|
displaySnapToEdge: { def: true },
|
||||||
|
connectedFrameBarStyleBackups: { def: {} },
|
||||||
|
|
||||||
barConfigs: {
|
barConfigs: {
|
||||||
def: [{
|
def: [{
|
||||||
@@ -468,6 +486,7 @@ var SPEC = {
|
|||||||
fontScale: 1.0,
|
fontScale: 1.0,
|
||||||
iconScale: 1.0,
|
iconScale: 1.0,
|
||||||
autoHide: false,
|
autoHide: false,
|
||||||
|
autoHideStrict: false,
|
||||||
autoHideDelay: 250,
|
autoHideDelay: 250,
|
||||||
showOnWindowsOpen: false,
|
showOnWindowsOpen: false,
|
||||||
openOnOverview: false,
|
openOnOverview: false,
|
||||||
@@ -475,6 +494,7 @@ var SPEC = {
|
|||||||
popupGapsAuto: true,
|
popupGapsAuto: true,
|
||||||
popupGapsManual: 4,
|
popupGapsManual: 4,
|
||||||
maximizeDetection: true,
|
maximizeDetection: true,
|
||||||
|
fullscreenDetection: true,
|
||||||
scrollEnabled: true,
|
scrollEnabled: true,
|
||||||
scrollXBehavior: "column",
|
scrollXBehavior: "column",
|
||||||
scrollYBehavior: "workspace",
|
scrollYBehavior: "workspace",
|
||||||
@@ -537,7 +557,21 @@ var SPEC = {
|
|||||||
clipboardEnterToPaste: { def: false },
|
clipboardEnterToPaste: { def: false },
|
||||||
|
|
||||||
launcherPluginVisibility: { def: {} },
|
launcherPluginVisibility: { def: {} },
|
||||||
launcherPluginOrder: { def: [] }
|
launcherPluginOrder: { def: [] },
|
||||||
|
|
||||||
|
frameEnabled: { def: false },
|
||||||
|
frameThickness: { def: 16 },
|
||||||
|
frameRounding: { def: 23 },
|
||||||
|
frameColor: { def: "" },
|
||||||
|
frameOpacity: { def: 1.0 },
|
||||||
|
frameScreenPreferences: { def: ["all"] },
|
||||||
|
frameBarSize: { def: 40 },
|
||||||
|
frameShowOnOverview: { def: false },
|
||||||
|
frameBlurEnabled: { def: true },
|
||||||
|
frameCloseGaps: { def: true },
|
||||||
|
frameLauncherEmergeSide: { def: "bottom" },
|
||||||
|
frameLauncherArcExtender: { def: false },
|
||||||
|
frameMode: { def: "separate" }
|
||||||
};
|
};
|
||||||
|
|
||||||
function getValidKeys() {
|
function getValidKeys() {
|
||||||
|
|||||||
@@ -248,6 +248,10 @@ function migrateToVersion(obj, targetVersion) {
|
|||||||
settings.configVersion = 6;
|
settings.configVersion = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentVersion < 11) {
|
||||||
|
settings.configVersion = 11;
|
||||||
|
}
|
||||||
|
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import qs.Common
|
|||||||
import qs.Modals
|
import qs.Modals
|
||||||
import qs.Modals.Changelog
|
import qs.Modals.Changelog
|
||||||
import qs.Modals.Clipboard
|
import qs.Modals.Clipboard
|
||||||
|
import qs.Modals.Common
|
||||||
import qs.Modals.Greeter
|
import qs.Modals.Greeter
|
||||||
import qs.Modals.Settings
|
import qs.Modals.Settings
|
||||||
import qs.Modals.DankLauncherV2
|
import qs.Modals.DankLauncherV2
|
||||||
@@ -21,11 +22,23 @@ import qs.Modules.OSD
|
|||||||
import qs.Modules.ProcessList
|
import qs.Modules.ProcessList
|
||||||
import qs.Modules.DankBar
|
import qs.Modules.DankBar
|
||||||
import qs.Modules.DankBar.Popouts
|
import qs.Modules.DankBar.Popouts
|
||||||
|
import qs.Modules.Frame
|
||||||
import qs.Modules.WorkspaceOverlays
|
import qs.Modules.WorkspaceOverlays
|
||||||
|
import qs.Modules.Settings.DisplayConfig
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
readonly property var log: Log.scoped("DMSShell")
|
||||||
|
|
||||||
|
property bool osdSurfacesLoaded: true
|
||||||
|
property int pendingOsdResumeReloads: 0
|
||||||
|
|
||||||
|
function recreateOsdSurfaces() {
|
||||||
|
OSDManager.currentOSDsByScreen = ({});
|
||||||
|
osdSurfacesLoaded = false;
|
||||||
|
osdSurfaceReloadTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
Instantiator {
|
Instantiator {
|
||||||
id: daemonPluginInstantiator
|
id: daemonPluginInstantiator
|
||||||
@@ -44,7 +57,7 @@ Item {
|
|||||||
item.popoutService = PopoutService;
|
item.popoutService = PopoutService;
|
||||||
}
|
}
|
||||||
item.pluginId = pluginId;
|
item.pluginId = pluginId;
|
||||||
console.info("Daemon plugin loaded:", pluginId);
|
log.info("Daemon plugin loaded:", pluginId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +96,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onFadeCancelled: {
|
onFadeCancelled: {
|
||||||
console.log("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
|
log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +136,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onFadeCancelled: {
|
onFadeCancelled: {
|
||||||
console.log("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
|
log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +165,22 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
property bool barSurfacesLoaded: true
|
||||||
|
|
||||||
|
function recreateBarSurfaces() {
|
||||||
|
if (barSurfacesLoaded)
|
||||||
|
barSurfacesLoaded = false;
|
||||||
|
barSurfaceReloadAction.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
DeferredAction {
|
||||||
|
id: barSurfaceReloadAction
|
||||||
|
onTriggered: root.barSurfacesLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
property string _barLayoutStateJson: {
|
property string _barLayoutStateJson: {
|
||||||
|
if (!barSurfacesLoaded)
|
||||||
|
return "[]";
|
||||||
const configs = SettingsData.barConfigs;
|
const configs = SettingsData.barConfigs;
|
||||||
const mapped = configs.map(c => ({
|
const mapped = configs.map(c => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -176,6 +204,21 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SettingsData
|
||||||
|
function onFrameEnabledChanged() {
|
||||||
|
root.recreateBarSurfaces();
|
||||||
|
}
|
||||||
|
function onConnectedFrameModeActiveChanged() {
|
||||||
|
root.recreateBarSurfaces();
|
||||||
|
}
|
||||||
|
function onForceDankBarLayoutRefresh() {
|
||||||
|
root.recreateBarSurfaces();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Frame {}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
id: dankBarRepeater
|
id: dankBarRepeater
|
||||||
model: ScriptModel {
|
model: ScriptModel {
|
||||||
@@ -189,7 +232,7 @@ Item {
|
|||||||
id: barLoader
|
id: barLoader
|
||||||
required property var modelData
|
required property var modelData
|
||||||
property var barConfig: SettingsData.barConfigs.find(cfg => cfg.id === modelData.id) || null
|
property var barConfig: SettingsData.barConfigs.find(cfg => cfg.id === modelData.id) || null
|
||||||
active: barConfig?.enabled ?? false
|
active: root.barSurfacesLoaded && (barConfig?.enabled ?? false)
|
||||||
asynchronous: false
|
asynchronous: false
|
||||||
|
|
||||||
sourceComponent: DankBar {
|
sourceComponent: DankBar {
|
||||||
@@ -232,10 +275,38 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: osdResumeRecreateTimer
|
||||||
|
interval: 400
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
root.recreateOsdSurfaces();
|
||||||
|
root.pendingOsdResumeReloads--;
|
||||||
|
|
||||||
|
if (root.pendingOsdResumeReloads <= 0) {
|
||||||
|
root.pendingOsdResumeReloads = 0;
|
||||||
|
interval = 400;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
interval = 1400;
|
||||||
|
restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: osdSurfaceReloadTimer
|
||||||
|
interval: 120
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.osdSurfacesLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
dockRecreateDebounce.start();
|
dockRecreateDebounce.start();
|
||||||
// Force PolkitService singleton to initialize
|
// Force PolkitService singleton to initialize
|
||||||
PolkitService.polkitAvailable;
|
PolkitService.polkitAvailable;
|
||||||
|
// Force DisplayConfigState singleton to initialize so auto-config runs at startup
|
||||||
|
DisplayConfigState.hasOutputBackend;
|
||||||
loginSoundTimer.start();
|
loginSoundTimer.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,11 +320,15 @@ Item {
|
|||||||
|
|
||||||
sourceComponent: Dock {
|
sourceComponent: Dock {
|
||||||
contextMenu: dockContextMenuLoader.item ? dockContextMenuLoader.item : null
|
contextMenu: dockContextMenuLoader.item ? dockContextMenuLoader.item : null
|
||||||
|
trashContextMenu: dockTrashContextMenuLoader.item ? dockTrashContextMenuLoader.item : null
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoaded: {
|
onLoaded: {
|
||||||
if (item) {
|
if (item) {
|
||||||
dockContextMenuLoader.active = true;
|
dockContextMenuLoader.active = true;
|
||||||
|
if (SettingsData.dockShowTrash) {
|
||||||
|
dockTrashContextMenuLoader.active = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +365,6 @@ Item {
|
|||||||
sourceComponent: Component {
|
sourceComponent: Component {
|
||||||
DankDashPopout {
|
DankDashPopout {
|
||||||
id: dankDashPopout
|
id: dankDashPopout
|
||||||
onPopoutClosed: PopoutService.unloadDankDash()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,6 +379,43 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LazyLoader {
|
||||||
|
id: dockTrashContextMenuLoader
|
||||||
|
|
||||||
|
active: false
|
||||||
|
|
||||||
|
DockTrashContextMenu {
|
||||||
|
id: dockTrashContextMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SettingsData
|
||||||
|
function onDockShowTrashChanged() {
|
||||||
|
if (SettingsData.dockShowTrash) {
|
||||||
|
dockTrashContextMenuLoader.active = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmModal {
|
||||||
|
id: emptyTrashConfirm
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: TrashService
|
||||||
|
function onEmptyTrashConfirmRequested(itemCount) {
|
||||||
|
emptyTrashConfirm.showWithOptions({
|
||||||
|
title: I18n.tr("Empty Trash?"),
|
||||||
|
message: I18n.tr("Permanently delete %1 item(s)? This cannot be undone.").arg(itemCount),
|
||||||
|
confirmText: I18n.tr("Empty"),
|
||||||
|
cancelText: I18n.tr("Cancel"),
|
||||||
|
confirmColor: Theme.error,
|
||||||
|
onConfirm: () => TrashService.emptyTrash()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LazyLoader {
|
LazyLoader {
|
||||||
id: notificationCenterLoader
|
id: notificationCenterLoader
|
||||||
|
|
||||||
@@ -412,6 +523,8 @@ Item {
|
|||||||
enabled: PolkitService.polkitAvailable
|
enabled: PolkitService.polkitAvailable
|
||||||
|
|
||||||
function onAuthenticationRequestStarted() {
|
function onAuthenticationRequestStarted() {
|
||||||
|
if (PopoutService.systemUpdatePopout?.shouldBeVisible)
|
||||||
|
return;
|
||||||
polkitAuthModalLoader.active = true;
|
polkitAuthModalLoader.active = true;
|
||||||
if (polkitAuthModalLoader.item)
|
if (polkitAuthModalLoader.item)
|
||||||
polkitAuthModalLoader.item.show();
|
polkitAuthModalLoader.item.show();
|
||||||
@@ -696,7 +809,7 @@ Item {
|
|||||||
cmd += " " + escapedPath;
|
cmd += " " + escapedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("FilePicker: Launching", cmd);
|
log.debug("FilePicker: Launching", cmd);
|
||||||
|
|
||||||
Quickshell.execDetached({
|
Quickshell.execDetached({
|
||||||
command: ["sh", "-c", cmd]
|
command: ["sh", "-c", cmd]
|
||||||
@@ -728,10 +841,10 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onAppPickerRequested(data) {
|
function onAppPickerRequested(data) {
|
||||||
console.log("DMSShell: App picker requested with data:", JSON.stringify(data));
|
log.debug("App picker requested with data:", JSON.stringify(data));
|
||||||
|
|
||||||
if (!data || !data.target) {
|
if (!data || !data.target) {
|
||||||
console.warn("DMSShell: Invalid app picker request data");
|
log.warn("Invalid app picker request data");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,6 +862,16 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SessionService
|
||||||
|
|
||||||
|
function onSessionResumed() {
|
||||||
|
root.pendingOsdResumeReloads = 2;
|
||||||
|
osdResumeRecreateTimer.interval = 400;
|
||||||
|
osdResumeRecreateTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DankColorPickerModal {
|
DankColorPickerModal {
|
||||||
id: colorPickerModal
|
id: colorPickerModal
|
||||||
|
|
||||||
@@ -790,10 +913,19 @@ Item {
|
|||||||
|
|
||||||
ProcessListModal {
|
ProcessListModal {
|
||||||
id: processListModal
|
id: processListModal
|
||||||
|
property bool wasShown: false
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
PopoutService.processListModal = processListModal;
|
PopoutService.processListModal = processListModal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onVisibleChanged: {
|
||||||
|
if (visible) {
|
||||||
|
wasShown = true;
|
||||||
|
} else if (wasShown) {
|
||||||
|
PopoutService.unloadProcessListModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,7 +940,12 @@ Item {
|
|||||||
|
|
||||||
SystemUpdatePopout {
|
SystemUpdatePopout {
|
||||||
id: systemUpdatePopout
|
id: systemUpdatePopout
|
||||||
onPopoutClosed: PopoutService.unloadSystemUpdate()
|
onPopoutClosed: {
|
||||||
|
if (systemUpdatePopout._reopenAfterUpgrade) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PopoutService.unloadSystemUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
PopoutService.systemUpdatePopout = systemUpdatePopout;
|
PopoutService.systemUpdatePopout = systemUpdatePopout;
|
||||||
@@ -827,7 +964,6 @@ Item {
|
|||||||
slideoutWidth: 480
|
slideoutWidth: 480
|
||||||
expandable: true
|
expandable: true
|
||||||
expandedWidthValue: 960
|
expandedWidthValue: 960
|
||||||
customTransparency: SettingsData.notepadTransparencyOverride
|
|
||||||
|
|
||||||
content: Component {
|
content: Component {
|
||||||
Notepad {
|
Notepad {
|
||||||
@@ -923,81 +1059,85 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
|
||||||
|
|
||||||
delegate: VolumeOSD {
|
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Variants {
|
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
|
||||||
|
|
||||||
delegate: MediaVolumeOSD {
|
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Variants {
|
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
|
||||||
|
|
||||||
delegate: MediaPlaybackOSD {
|
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Variants {
|
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
|
||||||
|
|
||||||
delegate: MicMuteOSD {
|
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Variants {
|
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
|
||||||
|
|
||||||
delegate: BrightnessOSD {
|
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Variants {
|
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
|
||||||
|
|
||||||
delegate: IdleInhibitorOSD {
|
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Loader {
|
Loader {
|
||||||
id: powerProfileWatcherLoader
|
id: osdSurfacesLoader
|
||||||
active: SettingsData.osdPowerProfileEnabled
|
active: root.osdSurfacesLoaded
|
||||||
source: "Services/PowerProfileWatcher.qml"
|
asynchronous: false
|
||||||
}
|
|
||||||
|
|
||||||
Variants {
|
sourceComponent: Component {
|
||||||
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
|
Item {
|
||||||
|
Variants {
|
||||||
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: PowerProfileOSD {
|
delegate: VolumeOSD {
|
||||||
modelData: item
|
modelData: item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: CapsLockOSD {
|
delegate: MediaVolumeOSD {
|
||||||
modelData: item
|
modelData: item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: AudioOutputOSD {
|
delegate: MediaPlaybackOSD {
|
||||||
modelData: item
|
modelData: item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Variants {
|
||||||
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
|
delegate: MicMuteOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Variants {
|
||||||
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
|
delegate: BrightnessOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Variants {
|
||||||
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
|
delegate: IdleInhibitorOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Variants {
|
||||||
|
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
|
||||||
|
|
||||||
|
delegate: PowerProfileOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Variants {
|
||||||
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
|
delegate: CapsLockOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Variants {
|
||||||
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
|
delegate: AudioOutputOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import qs.Modules.Settings.DisplayConfig
|
|||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
readonly property var log: Log.scoped("DMSShellIPC")
|
||||||
|
|
||||||
required property var powerMenuModalLoader
|
required property var powerMenuModalLoader
|
||||||
required property var processListModalLoader
|
required property var processListModalLoader
|
||||||
@@ -161,37 +162,36 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
|
function resolveTabIndex(tab: string): int {
|
||||||
|
switch ((tab || "").toLowerCase()) {
|
||||||
|
case "media":
|
||||||
|
return 1;
|
||||||
|
case "wallpaper":
|
||||||
|
return 2;
|
||||||
|
case "weather":
|
||||||
|
return SettingsData.weatherEnabled ? 3 : 0;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function open(tab: string): string {
|
function open(tab: string): string {
|
||||||
const bar = root.getPreferredBar("clockButtonRef");
|
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
|
||||||
if (!bar)
|
if (!bar)
|
||||||
return "DASH_OPEN_FAILED";
|
return "DASH_OPEN_FAILED";
|
||||||
|
|
||||||
|
const tabIndex = resolveTabIndex(tab);
|
||||||
const dash = root.dankDashPopoutLoader.item;
|
const dash = root.dankDashPopoutLoader.item;
|
||||||
const onSameScreen = dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name;
|
if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) {
|
||||||
|
dash.currentTabIndex = tabIndex;
|
||||||
if (!onSameScreen) {
|
if (dash.updateSurfacePosition)
|
||||||
bar.triggerWallpaperBrowser();
|
dash.updateSurfacePosition();
|
||||||
|
return "DASH_OPEN_SUCCESS";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!root.dankDashPopoutLoader.item)
|
if (!bar.triggerDashTab(tabIndex))
|
||||||
return "DASH_OPEN_FAILED";
|
return "DASH_OPEN_FAILED";
|
||||||
|
|
||||||
switch (tab.toLowerCase()) {
|
|
||||||
case "media":
|
|
||||||
root.dankDashPopoutLoader.item.currentTabIndex = 1;
|
|
||||||
break;
|
|
||||||
case "wallpaper":
|
|
||||||
root.dankDashPopoutLoader.item.currentTabIndex = 2;
|
|
||||||
break;
|
|
||||||
case "weather":
|
|
||||||
root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
root.dankDashPopoutLoader.item.currentTabIndex = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
root.dankDashPopoutLoader.item.dashVisible = true;
|
|
||||||
return "DASH_OPEN_SUCCESS";
|
return "DASH_OPEN_SUCCESS";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,25 +209,10 @@ Item {
|
|||||||
return "DASH_TOGGLE_SUCCESS";
|
return "DASH_TOGGLE_SUCCESS";
|
||||||
}
|
}
|
||||||
|
|
||||||
const bar = root.getPreferredBar("clockButtonRef");
|
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
|
||||||
if (bar) {
|
if (bar) {
|
||||||
bar.triggerWallpaperBrowser();
|
if (!bar.triggerDashTab(resolveTabIndex(tab)))
|
||||||
if (root.dankDashPopoutLoader.item) {
|
return "DASH_TOGGLE_FAILED";
|
||||||
switch (tab.toLowerCase()) {
|
|
||||||
case "media":
|
|
||||||
root.dankDashPopoutLoader.item.currentTabIndex = 1;
|
|
||||||
break;
|
|
||||||
case "wallpaper":
|
|
||||||
root.dankDashPopoutLoader.item.currentTabIndex = 2;
|
|
||||||
break;
|
|
||||||
case "weather":
|
|
||||||
root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
root.dankDashPopoutLoader.item.currentTabIndex = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "DASH_TOGGLE_SUCCESS";
|
return "DASH_TOGGLE_SUCCESS";
|
||||||
}
|
}
|
||||||
return "DASH_TOGGLE_FAILED";
|
return "DASH_TOGGLE_FAILED";
|
||||||
@@ -597,7 +582,7 @@ Item {
|
|||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
function wallpaper(): string {
|
function wallpaper(): string {
|
||||||
const bar = root.getPreferredBar("clockButtonRef");
|
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
|
||||||
if (bar) {
|
if (bar) {
|
||||||
bar.triggerWallpaperBrowser();
|
bar.triggerWallpaperBrowser();
|
||||||
return "SUCCESS: Toggled wallpaper browser";
|
return "SUCCESS: Toggled wallpaper browser";
|
||||||
@@ -714,6 +699,26 @@ Item {
|
|||||||
return barConfig.autoHide ? "BAR_MANUAL_HIDE_SUCCESS" : "BAR_AUTO_HIDE_SUCCESS";
|
return barConfig.autoHide ? "BAR_MANUAL_HIDE_SUCCESS" : "BAR_AUTO_HIDE_SUCCESS";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleReveal(selector: string, value: string): string {
|
||||||
|
const {
|
||||||
|
barConfig,
|
||||||
|
error
|
||||||
|
} = getBarConfig(selector, value);
|
||||||
|
if (error)
|
||||||
|
return error;
|
||||||
|
if (!barConfig.autoHide)
|
||||||
|
return "BAR_AUTO_HIDE_DISABLED";
|
||||||
|
if (!(barConfig.visible ?? true)) {
|
||||||
|
SettingsData.updateBarConfig(barConfig.id, {
|
||||||
|
visible: true
|
||||||
|
});
|
||||||
|
SettingsData.setBarIpcReveal(barConfig.id, true);
|
||||||
|
return "BAR_REVEAL_SUCCESS";
|
||||||
|
}
|
||||||
|
const revealed = SettingsData.toggleBarIpcReveal(barConfig.id);
|
||||||
|
return revealed ? "BAR_REVEAL_SUCCESS" : "BAR_TUCK_SUCCESS";
|
||||||
|
}
|
||||||
|
|
||||||
function getPosition(selector: string, value: string): string {
|
function getPosition(selector: string, value: string): string {
|
||||||
const {
|
const {
|
||||||
barConfig,
|
barConfig,
|
||||||
@@ -861,7 +866,7 @@ Item {
|
|||||||
|
|
||||||
function set(key: string, value: string): string {
|
function set(key: string, value: string): string {
|
||||||
if (!(key in SettingsData)) {
|
if (!(key in SettingsData)) {
|
||||||
console.warn("Cannot set property, not found:", key);
|
log.warn("Cannot set property, not found:", key);
|
||||||
return "SETTINGS_INVALID_KEY";
|
return "SETTINGS_INVALID_KEY";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,12 +899,12 @@ Item {
|
|||||||
throw "Unsupported type";
|
throw "Unsupported type";
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn("Setting:", key, value);
|
log.warn("Setting:", key, value);
|
||||||
SettingsData[key] = value;
|
SettingsData[key] = value;
|
||||||
SettingsData.saveSettings();
|
SettingsData.saveSettings();
|
||||||
return "SETTINGS_SET_SUCCESS";
|
return "SETTINGS_SET_SUCCESS";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to set property:", key, "error:", e);
|
log.warn("Failed to set property:", key, "error:", e);
|
||||||
return "SETTINGS_SET_FAILURE";
|
return "SETTINGS_SET_FAILURE";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1175,6 +1180,50 @@ Item {
|
|||||||
target: "plugins"
|
target: "plugins"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
function toggle(): string {
|
||||||
|
if (PopoutService.systemUpdatePopout?.shouldBeVisible) {
|
||||||
|
PopoutService.systemUpdatePopout.close();
|
||||||
|
return "SYSTEMUPDATER_TOGGLE_SUCCESS";
|
||||||
|
}
|
||||||
|
const bar = root.getPreferredBar("systemUpdateButtonRef");
|
||||||
|
if (bar) {
|
||||||
|
bar.triggerSystemUpdate();
|
||||||
|
return "SYSTEMUPDATER_TOGGLE_SUCCESS";
|
||||||
|
}
|
||||||
|
return "SYSTEMUPDATER_TOGGLE_FAILED";
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(): string {
|
||||||
|
if (PopoutService.systemUpdatePopout?.shouldBeVisible)
|
||||||
|
return "SYSTEMUPDATER_ALREADY_OPEN";
|
||||||
|
const bar = root.getPreferredBar("systemUpdateButtonRef");
|
||||||
|
if (bar) {
|
||||||
|
bar.triggerSystemUpdate();
|
||||||
|
return "SYSTEMUPDATER_OPEN_SUCCESS";
|
||||||
|
}
|
||||||
|
return "SYSTEMUPDATER_OPEN_FAILED";
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): string {
|
||||||
|
PopoutService.closeSystemUpdate();
|
||||||
|
return "SYSTEMUPDATER_CLOSE_SUCCESS";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatestatus(): string {
|
||||||
|
if (SystemUpdateService.isChecking) {
|
||||||
|
return "ERROR: already checking";
|
||||||
|
}
|
||||||
|
if (SystemUpdateService.backends.length === 0) {
|
||||||
|
return "ERROR: no package manager available";
|
||||||
|
}
|
||||||
|
SystemUpdateService.checkForUpdates();
|
||||||
|
return "SUCCESS: Now checking...";
|
||||||
|
}
|
||||||
|
|
||||||
|
target: "systemupdater"
|
||||||
|
}
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
function open(): string {
|
function open(): string {
|
||||||
if (!PopoutService.clipboardHistoryModal) {
|
if (!PopoutService.clipboardHistoryModal) {
|
||||||
@@ -1637,13 +1686,15 @@ Item {
|
|||||||
|
|
||||||
for (const id in profiles) {
|
for (const id in profiles) {
|
||||||
const p = profiles[id];
|
const p = profiles[id];
|
||||||
|
if (!p.name)
|
||||||
|
continue;
|
||||||
const flags = [];
|
const flags = [];
|
||||||
if (id === activeId)
|
if (id === activeId)
|
||||||
flags.push("active");
|
flags.push("active");
|
||||||
if (id === matchedId)
|
if (id === matchedId)
|
||||||
flags.push("matched");
|
flags.push("matched");
|
||||||
const flagStr = flags.length > 0 ? " [" + flags.join(",") + "]" : "";
|
const flagStr = flags.length > 0 ? " [" + flags.join(",") + "]" : "";
|
||||||
lines.push(p.name + flagStr + " -> " + JSON.stringify(p.outputSet));
|
lines.push(p.name + flagStr + " -> " + JSON.stringify(Object.keys(p.outputs)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lines.length === 0)
|
if (lines.length === 0)
|
||||||
@@ -1675,13 +1726,16 @@ Item {
|
|||||||
return `PROFILE_SET_SUCCESS: ${profileName}`;
|
return `PROFILE_SET_SUCCESS: ${profileName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ! TODO - auto profile switching is buggy on niri and other compositors
|
|
||||||
function toggleAuto(): string {
|
function toggleAuto(): string {
|
||||||
return "ERROR: Auto profile selection is temporarily disabled due to compositor bugs";
|
SettingsData.displayProfileAutoSelect = !SettingsData.displayProfileAutoSelect;
|
||||||
|
SettingsData.saveSettings();
|
||||||
|
if (SettingsData.displayProfileAutoSelect)
|
||||||
|
DisplayConfigState.applyAutoConfig();
|
||||||
|
return `Auto profile selection: ${SettingsData.displayProfileAutoSelect ? "enabled" : "disabled"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(): string {
|
function status(): string {
|
||||||
const auto = "off"; // disabled for now
|
const auto = SettingsData.displayProfileAutoSelect ? "on" : "off";
|
||||||
const activeId = SettingsData.getActiveDisplayProfile(CompositorService.compositor);
|
const activeId = SettingsData.getActiveDisplayProfile(CompositorService.compositor);
|
||||||
const matchedId = DisplayConfigState.matchedProfile;
|
const matchedId = DisplayConfigState.matchedProfile;
|
||||||
const profiles = DisplayConfigState.validatedProfiles;
|
const profiles = DisplayConfigState.validatedProfiles;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Modals.Common
|
import qs.Modals.Common
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
@@ -7,6 +6,7 @@ import qs.Services
|
|||||||
|
|
||||||
DankModal {
|
DankModal {
|
||||||
id: root
|
id: root
|
||||||
|
readonly property var log: Log.scoped("AppPickerModal")
|
||||||
|
|
||||||
property string title: I18n.tr("Select Application")
|
property string title: I18n.tr("Select Application")
|
||||||
property string targetData: ""
|
property string targetData: ""
|
||||||
@@ -30,52 +30,52 @@ DankModal {
|
|||||||
onBackgroundClicked: close()
|
onBackgroundClicked: close()
|
||||||
|
|
||||||
onDialogClosed: {
|
onDialogClosed: {
|
||||||
searchQuery = ""
|
searchQuery = "";
|
||||||
selectedIndex = 0
|
selectedIndex = 0;
|
||||||
keyboardNavigationActive = false
|
keyboardNavigationActive = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpened: {
|
onOpened: {
|
||||||
searchQuery = ""
|
searchQuery = "";
|
||||||
updateApplicationList()
|
updateApplicationList();
|
||||||
selectedIndex = 0
|
selectedIndex = 0;
|
||||||
Qt.callLater(() => {
|
Qt.callLater(() => {
|
||||||
if (contentLoader.item && contentLoader.item.searchField) {
|
if (contentLoader.item && contentLoader.item.searchField) {
|
||||||
contentLoader.item.searchField.text = ""
|
contentLoader.item.searchField.text = "";
|
||||||
contentLoader.item.searchField.forceActiveFocus()
|
contentLoader.item.searchField.forceActiveFocus();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateApplicationList() {
|
function updateApplicationList() {
|
||||||
applicationsModel.clear()
|
applicationsModel.clear();
|
||||||
const apps = AppSearchService.applications
|
const apps = AppSearchService.applications;
|
||||||
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {}
|
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {};
|
||||||
let filteredApps = []
|
let filteredApps = [];
|
||||||
|
|
||||||
for (const app of apps) {
|
for (const app of apps) {
|
||||||
if (!app || !app.categories) continue
|
if (!app || !app.categories)
|
||||||
|
continue;
|
||||||
let matchesCategory = categoryFilter.length === 0
|
let matchesCategory = categoryFilter.length === 0;
|
||||||
|
|
||||||
if (categoryFilter.length > 0) {
|
if (categoryFilter.length > 0) {
|
||||||
try {
|
try {
|
||||||
for (const cat of app.categories) {
|
for (const cat of app.categories) {
|
||||||
if (categoryFilter.includes(cat)) {
|
if (categoryFilter.includes(cat)) {
|
||||||
matchesCategory = true
|
matchesCategory = true;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("AppPicker: Error iterating categories for", app.name, ":", e)
|
log.warn("AppPicker: Error iterating categories for", app.name, ":", e);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchesCategory) {
|
if (matchesCategory) {
|
||||||
const name = app.name || ""
|
const name = app.name || "";
|
||||||
const lowerName = name.toLowerCase()
|
const lowerName = name.toLowerCase();
|
||||||
const lowerQuery = searchQuery.toLowerCase()
|
const lowerQuery = searchQuery.toLowerCase();
|
||||||
|
|
||||||
if (searchQuery === "" || lowerName.includes(lowerQuery)) {
|
if (searchQuery === "" || lowerName.includes(lowerQuery)) {
|
||||||
filteredApps.push({
|
filteredApps.push({
|
||||||
@@ -84,21 +84,21 @@ DankModal {
|
|||||||
exec: app.exec || app.execString || "",
|
exec: app.exec || app.execString || "",
|
||||||
startupClass: app.startupWMClass || "",
|
startupClass: app.startupWMClass || "",
|
||||||
appData: app
|
appData: app
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredApps.sort((a, b) => {
|
filteredApps.sort((a, b) => {
|
||||||
const aId = a.appData.id || a.appData.execString || a.appData.exec || ""
|
const aId = a.appData.id || a.appData.execString || a.appData.exec || "";
|
||||||
const bId = b.appData.id || b.appData.execString || b.appData.exec || ""
|
const bId = b.appData.id || b.appData.execString || b.appData.exec || "";
|
||||||
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0
|
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0;
|
||||||
const bUsage = usageHistory[bId] ? usageHistory[bId].count : 0
|
const bUsage = usageHistory[bId] ? usageHistory[bId].count : 0;
|
||||||
if (aUsage !== bUsage) {
|
if (aUsage !== bUsage) {
|
||||||
return bUsage - aUsage
|
return bUsage - aUsage;
|
||||||
}
|
}
|
||||||
return (a.name || "").localeCompare(b.name || "")
|
return (a.name || "").localeCompare(b.name || "");
|
||||||
})
|
});
|
||||||
|
|
||||||
filteredApps.forEach(app => {
|
filteredApps.forEach(app => {
|
||||||
applicationsModel.append({
|
applicationsModel.append({
|
||||||
@@ -107,10 +107,10 @@ DankModal {
|
|||||||
exec: app.exec,
|
exec: app.exec,
|
||||||
startupClass: app.startupClass,
|
startupClass: app.startupClass,
|
||||||
appId: app.appData.id || app.appData.execString || app.appData.exec || ""
|
appId: app.appData.id || app.appData.execString || app.appData.exec || ""
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
console.log("AppPicker: Found " + filteredApps.length + " applications")
|
log.debug("AppPicker: Found " + filteredApps.length + " applications");
|
||||||
}
|
}
|
||||||
|
|
||||||
onSearchQueryChanged: updateApplicationList()
|
onSearchQueryChanged: updateApplicationList()
|
||||||
@@ -129,56 +129,57 @@ DankModal {
|
|||||||
focus: true
|
focus: true
|
||||||
|
|
||||||
Keys.onEscapePressed: event => {
|
Keys.onEscapePressed: event => {
|
||||||
root.close()
|
root.close();
|
||||||
event.accepted = true
|
event.accepted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Keys.onPressed: event => {
|
Keys.onPressed: event => {
|
||||||
if (applicationsModel.count === 0) return
|
if (applicationsModel.count === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
// Toggle view mode with Tab key
|
// Toggle view mode with Tab key
|
||||||
if (event.key === Qt.Key_Tab) {
|
if (event.key === Qt.Key_Tab) {
|
||||||
root.viewMode = root.viewMode === "grid" ? "list" : "grid"
|
root.viewMode = root.viewMode === "grid" ? "list" : "grid";
|
||||||
event.accepted = true
|
event.accepted = true;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (root.viewMode === "grid") {
|
if (root.viewMode === "grid") {
|
||||||
if (event.key === Qt.Key_Left) {
|
if (event.key === Qt.Key_Left) {
|
||||||
root.keyboardNavigationActive = true
|
root.keyboardNavigationActive = true;
|
||||||
root.selectedIndex = Math.max(0, root.selectedIndex - 1)
|
root.selectedIndex = Math.max(0, root.selectedIndex - 1);
|
||||||
event.accepted = true
|
event.accepted = true;
|
||||||
} else if (event.key === Qt.Key_Right) {
|
} else if (event.key === Qt.Key_Right) {
|
||||||
root.keyboardNavigationActive = true
|
root.keyboardNavigationActive = true;
|
||||||
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1)
|
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1);
|
||||||
event.accepted = true
|
event.accepted = true;
|
||||||
} else if (event.key === Qt.Key_Up) {
|
} else if (event.key === Qt.Key_Up) {
|
||||||
root.keyboardNavigationActive = true
|
root.keyboardNavigationActive = true;
|
||||||
root.selectedIndex = Math.max(0, root.selectedIndex - root.gridColumns)
|
root.selectedIndex = Math.max(0, root.selectedIndex - root.gridColumns);
|
||||||
event.accepted = true
|
event.accepted = true;
|
||||||
} else if (event.key === Qt.Key_Down) {
|
} else if (event.key === Qt.Key_Down) {
|
||||||
root.keyboardNavigationActive = true
|
root.keyboardNavigationActive = true;
|
||||||
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + root.gridColumns)
|
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + root.gridColumns);
|
||||||
event.accepted = true
|
event.accepted = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (event.key === Qt.Key_Up) {
|
if (event.key === Qt.Key_Up) {
|
||||||
root.keyboardNavigationActive = true
|
root.keyboardNavigationActive = true;
|
||||||
root.selectedIndex = Math.max(0, root.selectedIndex - 1)
|
root.selectedIndex = Math.max(0, root.selectedIndex - 1);
|
||||||
event.accepted = true
|
event.accepted = true;
|
||||||
} else if (event.key === Qt.Key_Down) {
|
} else if (event.key === Qt.Key_Down) {
|
||||||
root.keyboardNavigationActive = true
|
root.keyboardNavigationActive = true;
|
||||||
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1)
|
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1);
|
||||||
event.accepted = true
|
event.accepted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||||
if (root.selectedIndex >= 0 && root.selectedIndex < applicationsModel.count) {
|
if (root.selectedIndex >= 0 && root.selectedIndex < applicationsModel.count) {
|
||||||
const app = applicationsModel.get(root.selectedIndex)
|
const app = applicationsModel.get(root.selectedIndex);
|
||||||
launchApplication(app)
|
launchApplication(app);
|
||||||
}
|
}
|
||||||
event.accepted = true
|
event.accepted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +218,7 @@ DankModal {
|
|||||||
iconColor: root.viewMode === "list" ? Theme.primary : Theme.surfaceText
|
iconColor: root.viewMode === "list" ? Theme.primary : Theme.surfaceText
|
||||||
backgroundColor: root.viewMode === "list" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
backgroundColor: root.viewMode === "list" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.viewMode = "list"
|
root.viewMode = "list";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +230,7 @@ DankModal {
|
|||||||
iconColor: root.viewMode === "grid" ? Theme.primary : Theme.surfaceText
|
iconColor: root.viewMode === "grid" ? Theme.primary : Theme.surfaceText
|
||||||
backgroundColor: root.viewMode === "grid" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
backgroundColor: root.viewMode === "grid" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.viewMode = "grid"
|
root.viewMode = "grid";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,42 +258,42 @@ DankModal {
|
|||||||
keyForwardTargets: [appContent]
|
keyForwardTargets: [appContent]
|
||||||
|
|
||||||
onTextEdited: {
|
onTextEdited: {
|
||||||
root.searchQuery = text
|
root.searchQuery = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
Keys.onPressed: function (event) {
|
Keys.onPressed: function (event) {
|
||||||
if (event.key === Qt.Key_Escape) {
|
if (event.key === Qt.Key_Escape) {
|
||||||
root.close()
|
root.close();
|
||||||
event.accepted = true
|
event.accepted = true;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key)
|
const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key);
|
||||||
const hasText = text.length > 0
|
const hasText = text.length > 0;
|
||||||
|
|
||||||
if (isEnterKey && hasText) {
|
if (isEnterKey && hasText) {
|
||||||
if (root.keyboardNavigationActive && applicationsModel.count > 0) {
|
if (root.keyboardNavigationActive && applicationsModel.count > 0) {
|
||||||
const app = applicationsModel.get(root.selectedIndex)
|
const app = applicationsModel.get(root.selectedIndex);
|
||||||
launchApplication(app)
|
launchApplication(app);
|
||||||
} else if (applicationsModel.count > 0) {
|
} else if (applicationsModel.count > 0) {
|
||||||
const app = applicationsModel.get(0)
|
const app = applicationsModel.get(0);
|
||||||
launchApplication(app)
|
launchApplication(app);
|
||||||
}
|
}
|
||||||
event.accepted = true
|
event.accepted = true;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigationKeys = [Qt.Key_Down, Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Tab, Qt.Key_Backtab]
|
const navigationKeys = [Qt.Key_Down, Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Tab, Qt.Key_Backtab];
|
||||||
const isNavigationKey = navigationKeys.includes(event.key)
|
const isNavigationKey = navigationKeys.includes(event.key);
|
||||||
const isEmptyEnter = isEnterKey && !hasText
|
const isEmptyEnter = isEnterKey && !hasText;
|
||||||
|
|
||||||
event.accepted = !(isNavigationKey || isEmptyEnter)
|
event.accepted = !(isNavigationKey || isEmptyEnter);
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
function onShouldBeVisibleChanged() {
|
function onShouldBeVisibleChanged() {
|
||||||
if (!root.shouldBeVisible) {
|
if (!root.shouldBeVisible) {
|
||||||
searchField.focus = false
|
searchField.focus = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,12 +304,12 @@ DankModal {
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: {
|
height: {
|
||||||
let usedHeight = 40 + Theme.spacingS
|
let usedHeight = 40 + Theme.spacingS;
|
||||||
usedHeight += 52 + Theme.spacingS
|
usedHeight += 52 + Theme.spacingS;
|
||||||
if (root.showTargetData) {
|
if (root.showTargetData) {
|
||||||
usedHeight += 36 + Theme.spacingS
|
usedHeight += 36 + Theme.spacingS;
|
||||||
}
|
}
|
||||||
return parent.height - usedHeight
|
return parent.height - usedHeight;
|
||||||
}
|
}
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
@@ -320,14 +321,14 @@ DankModal {
|
|||||||
property int itemSpacing: Theme.spacingS
|
property int itemSpacing: Theme.spacingS
|
||||||
|
|
||||||
function ensureVisible(index) {
|
function ensureVisible(index) {
|
||||||
if (index < 0 || index >= count) return
|
if (index < 0 || index >= count)
|
||||||
|
return;
|
||||||
const itemY = index * (itemHeight + itemSpacing)
|
const itemY = index * (itemHeight + itemSpacing);
|
||||||
const itemBottom = itemY + itemHeight
|
const itemBottom = itemY + itemHeight;
|
||||||
if (itemY < contentY) {
|
if (itemY < contentY) {
|
||||||
contentY = itemY
|
contentY = itemY;
|
||||||
} else if (itemBottom > contentY + height) {
|
} else if (itemBottom > contentY + height) {
|
||||||
contentY = itemBottom - height
|
contentY = itemBottom - height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,9 +344,9 @@ DankModal {
|
|||||||
spacing: itemSpacing
|
spacing: itemSpacing
|
||||||
|
|
||||||
onCurrentIndexChanged: {
|
onCurrentIndexChanged: {
|
||||||
root.selectedIndex = currentIndex
|
root.selectedIndex = currentIndex;
|
||||||
if (root.keyboardNavigationActive) {
|
if (root.keyboardNavigationActive) {
|
||||||
ensureVisible(currentIndex)
|
ensureVisible(currentIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,11 +361,11 @@ DankModal {
|
|||||||
hoverUpdatesSelection: true
|
hoverUpdatesSelection: true
|
||||||
|
|
||||||
onItemClicked: (idx, modelData) => {
|
onItemClicked: (idx, modelData) => {
|
||||||
launchApplication(modelData)
|
launchApplication(modelData);
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyboardNavigationReset: {
|
onKeyboardNavigationReset: {
|
||||||
root.keyboardNavigationActive = false
|
root.keyboardNavigationActive = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,14 +374,14 @@ DankModal {
|
|||||||
id: appGrid
|
id: appGrid
|
||||||
|
|
||||||
function ensureVisible(index) {
|
function ensureVisible(index) {
|
||||||
if (index < 0 || index >= count) return
|
if (index < 0 || index >= count)
|
||||||
|
return;
|
||||||
const itemY = Math.floor(index / root.gridColumns) * cellHeight
|
const itemY = Math.floor(index / root.gridColumns) * cellHeight;
|
||||||
const itemBottom = itemY + cellHeight
|
const itemBottom = itemY + cellHeight;
|
||||||
if (itemY < contentY) {
|
if (itemY < contentY) {
|
||||||
contentY = itemY
|
contentY = itemY;
|
||||||
} else if (itemBottom > contentY + height) {
|
} else if (itemBottom > contentY + height) {
|
||||||
contentY = itemBottom - height
|
contentY = itemBottom - height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,9 +398,9 @@ DankModal {
|
|||||||
currentIndex: root.selectedIndex
|
currentIndex: root.selectedIndex
|
||||||
|
|
||||||
onCurrentIndexChanged: {
|
onCurrentIndexChanged: {
|
||||||
root.selectedIndex = currentIndex
|
root.selectedIndex = currentIndex;
|
||||||
if (root.keyboardNavigationActive) {
|
if (root.keyboardNavigationActive) {
|
||||||
ensureVisible(currentIndex)
|
ensureVisible(currentIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,11 +414,11 @@ DankModal {
|
|||||||
hoverUpdatesSelection: true
|
hoverUpdatesSelection: true
|
||||||
|
|
||||||
onItemClicked: (idx, modelData) => {
|
onItemClicked: (idx, modelData) => {
|
||||||
launchApplication(modelData)
|
launchApplication(modelData);
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyboardNavigationReset: {
|
onKeyboardNavigationReset: {
|
||||||
root.keyboardNavigationActive = false
|
root.keyboardNavigationActive = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,22 +450,22 @@ DankModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function launchApplication(app) {
|
function launchApplication(app) {
|
||||||
if (!app) return
|
if (!app)
|
||||||
|
return;
|
||||||
root.applicationSelected(app, root.targetData)
|
root.applicationSelected(app, root.targetData);
|
||||||
|
|
||||||
if (usageHistoryKey && app.appId) {
|
if (usageHistoryKey && app.appId) {
|
||||||
const usageHistory = SettingsData[usageHistoryKey] || {}
|
const usageHistory = SettingsData[usageHistoryKey] || {};
|
||||||
const currentCount = usageHistory[app.appId] ? usageHistory[app.appId].count : 0
|
const currentCount = usageHistory[app.appId] ? usageHistory[app.appId].count : 0;
|
||||||
usageHistory[app.appId] = {
|
usageHistory[app.appId] = {
|
||||||
count: currentCount + 1,
|
count: currentCount + 1,
|
||||||
lastUsed: Date.now(),
|
lastUsed: Date.now(),
|
||||||
name: app.name
|
name: app.name
|
||||||
}
|
};
|
||||||
SettingsData.set(usageHistoryKey, usageHistory)
|
SettingsData.set(usageHistoryKey, usageHistory);
|
||||||
}
|
}
|
||||||
|
|
||||||
root.close()
|
root.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import qs.Widgets
|
|||||||
|
|
||||||
DankModal {
|
DankModal {
|
||||||
id: root
|
id: root
|
||||||
|
readonly property var log: Log.scoped("BluetoothPairingModal")
|
||||||
|
|
||||||
layerNamespace: "dms:bluetooth-pairing"
|
layerNamespace: "dms:bluetooth-pairing"
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ DankModal {
|
|||||||
property string passkeyInput: ""
|
property string passkeyInput: ""
|
||||||
|
|
||||||
function show(pairingData) {
|
function show(pairingData) {
|
||||||
console.log("BluetoothPairingModal.show() called:", JSON.stringify(pairingData));
|
log.debug("BluetoothPairingModal.show() called:", JSON.stringify(pairingData));
|
||||||
token = pairingData.token || "";
|
token = pairingData.token || "";
|
||||||
deviceName = pairingData.deviceName || "";
|
deviceName = pairingData.deviceName || "";
|
||||||
deviceAddress = pairingData.deviceAddr || "";
|
deviceAddress = pairingData.deviceAddr || "";
|
||||||
@@ -33,7 +34,7 @@ DankModal {
|
|||||||
pinInput = "";
|
pinInput = "";
|
||||||
passkeyInput = "";
|
passkeyInput = "";
|
||||||
|
|
||||||
console.log("BluetoothPairingModal: Calling open()");
|
log.debug("Calling open()");
|
||||||
open();
|
open();
|
||||||
Qt.callLater(() => {
|
Qt.callLater(() => {
|
||||||
if (contentLoader.item) {
|
if (contentLoader.item) {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import QtQuick
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Modals
|
import qs.Modals
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
AppPickerModal {
|
AppPickerModal {
|
||||||
id: root
|
id: root
|
||||||
|
readonly property var log: Log.scoped("BrowserPickerModal")
|
||||||
|
|
||||||
property string url: ""
|
property string url: ""
|
||||||
|
|
||||||
@@ -17,35 +19,44 @@ AppPickerModal {
|
|||||||
showTargetData: true
|
showTargetData: true
|
||||||
|
|
||||||
function shellEscape(str) {
|
function shellEscape(str) {
|
||||||
return "'" + str.replace(/'/g, "'\\''") + "'"
|
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||||
}
|
}
|
||||||
|
|
||||||
onApplicationSelected: (app, url) => {
|
onApplicationSelected: (app, url) => {
|
||||||
if (!app) return
|
if (!app)
|
||||||
|
return;
|
||||||
|
let cmd = app.exec || "";
|
||||||
|
const escapedUrl = shellEscape(url);
|
||||||
|
|
||||||
let cmd = app.exec || ""
|
let hasField = false;
|
||||||
const escapedUrl = shellEscape(url)
|
if (cmd.includes("%u")) {
|
||||||
|
cmd = cmd.replace("%u", escapedUrl);
|
||||||
let hasField = false
|
hasField = true;
|
||||||
if (cmd.includes("%u")) { cmd = cmd.replace("%u", escapedUrl); hasField = true }
|
} else if (cmd.includes("%U")) {
|
||||||
else if (cmd.includes("%U")) { cmd = cmd.replace("%U", escapedUrl); hasField = true }
|
cmd = cmd.replace("%U", escapedUrl);
|
||||||
else if (cmd.includes("%f")) { cmd = cmd.replace("%f", escapedUrl); hasField = true }
|
hasField = true;
|
||||||
else if (cmd.includes("%F")) { cmd = cmd.replace("%F", escapedUrl); hasField = true }
|
} else if (cmd.includes("%f")) {
|
||||||
|
cmd = cmd.replace("%f", escapedUrl);
|
||||||
cmd = cmd.replace(/%[ikc]/g, "")
|
hasField = true;
|
||||||
|
} else if (cmd.includes("%F")) {
|
||||||
if (!hasField) {
|
cmd = cmd.replace("%F", escapedUrl);
|
||||||
cmd += " " + escapedUrl
|
hasField = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("BrowserPicker: Launching", cmd)
|
cmd = cmd.replace(/%[ikc]/g, "");
|
||||||
|
|
||||||
|
if (!hasField) {
|
||||||
|
cmd += " " + escapedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("BrowserPicker: Launching", cmd);
|
||||||
|
|
||||||
Quickshell.execDetached({
|
Quickshell.execDetached({
|
||||||
command: ["sh", "-c", cmd]
|
command: ["sh", "-c", cmd]
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onViewModeChanged: {
|
onViewModeChanged: {
|
||||||
SettingsData.set("browserPickerViewMode", viewMode)
|
SettingsData.set("browserPickerViewMode", viewMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user