1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-05 20:12:07 -04:00

Compare commits

..

28 Commits

Author SHA1 Message Date
purian23
878754b2dd Sync up Frame w/Master branch updates 2026-04-25 23:59:46 -04:00
bbedward
b9064d3bbb restore niri overview connected mode 2026-04-23 13:11:17 -04:00
bbedward
1905d51595 some more simplifications and bug fixes 2026-04-23 13:11:17 -04:00
bbedward
7f906128fa de-dupe and cleanup 2026-04-23 13:11:17 -04:00
bbedward
b588d2dac5 restore CC and notification standalone behavior 2026-04-23 13:11:17 -04:00
bbedward
e0abeb3708 refactor connected/standalone architecture 2026-04-23 13:11:17 -04:00
purian23
bdbbfc79c0 (frameMode): New Modal & Launcher connections 2026-04-23 13:11:17 -04:00
purian23
62926d8f0c (Notifications): Update body card expansions 2026-04-23 13:11:17 -04:00
purian23
86dc14b58d (frame): QOL Control Center & Notification updates 2026-04-23 13:11:17 -04:00
purian23
397ccad113 feat(Frame): Close the gaps 2026-04-23 13:11:17 -04:00
purian23
9036368da3 frame(Notifications): Update Arc path & Motion 2026-04-23 13:11:17 -04:00
purian23
c96136f381 (frame): Update animation sync w/Dank Popouts 2026-04-23 13:11:17 -04:00
purian23
1660e0c065 (frame): Performance round 2026-04-23 13:11:17 -04:00
purian23
7c7299d33f (frame): Update Connected blur Arcs & Enable shadow modes 2026-04-23 13:11:17 -04:00
purian23
dbf9b86956 frame(ConnectedMode): Wire up Notifications 2026-04-23 13:11:17 -04:00
purian23
7d7819c693 (frame): Update connected mode animation & motion logic 2026-04-23 13:11:17 -04:00
purian23
7b9dcab54b (frame): implement ConnectedModeState to better handle component sync 2026-04-23 13:11:17 -04:00
purian23
9946421dac (frameMode): Restore user settings when exiting frame mode
- Align blur settings in non-FrameMode motion settings
2026-04-23 13:11:17 -04:00
purian23
c66de3b5df (frame): Update connected mode with blur 2026-04-23 13:11:17 -04:00
purian23
09088bbb27 (frame): Update connected mode & opacity connection settings 2026-04-23 13:11:17 -04:00
purian23
0310b76ab5 (frameInMotion): Initial Unified Frame Connected Mode 2026-04-23 13:11:17 -04:00
purian23
e9f3e25e24 Add Directional Motion options 2026-04-23 13:11:17 -04:00
purian23
7596423d69 Initial staging for Animation & Motion effects 2026-04-23 13:11:17 -04:00
purian23
1987f2c279 (frame): Add blur support & cleanup 2026-04-23 13:11:17 -04:00
purian23
f64e0653f4 (frame): Multi-monitor support 2026-04-23 13:11:17 -04:00
purian23
ac49332d5d Connected frames & defaults 2026-04-23 13:11:17 -04:00
purian23
cf3d553ace Continue frame implementation 2026-04-23 13:11:17 -04:00
purian23
bc79b6575c Initial framework 2026-04-23 13:11:17 -04:00
247 changed files with 8805 additions and 27754 deletions

View File

@@ -1,4 +1,4 @@
name: Nix flake and NixOS tests
name: Check nix flake
on:
pull_request:
@@ -9,7 +9,6 @@ on:
jobs:
check-flake:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- name: Checkout
@@ -19,25 +18,6 @@ jobs:
- name: Install Nix
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
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
run: nix flake check

View File

@@ -22,13 +22,12 @@ on:
jobs:
check-updates:
name: Check package/series updates
name: Check for updates
runs-on: ubuntu-latest
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
targets: ${{ steps.check.outputs.targets }}
targets_json: ${{ steps.check.outputs.targets_json }}
packages: ${{ steps.check.outputs.packages }}
steps:
- name: Checkout
@@ -36,57 +35,125 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq curl git
- name: Check for updates
id: check
run: |
chmod +x distro/scripts/ppa-sync-plan.sh
# Helper function to check dms-git commit
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
PACKAGE="dms-git"
# Scheduled run - check dms-git only
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
PACKAGE="${{ github.event.inputs.package }}"
fi
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"
# Fallback
echo "packages=dms" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
fi
upload-ppa:
name: Upload ${{ matrix.target }}
name: Upload to PPA
needs: check-updates
runs-on: ubuntu-latest
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:
- name: Checkout
@@ -110,8 +177,7 @@ jobs:
lftp \
build-essential \
fakeroot \
dpkg-dev \
openssh-client
dpkg-dev
- name: Configure GPG
env:
@@ -119,32 +185,106 @@ jobs:
run: |
echo "$GPG_KEY" | gpg --import
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV"
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
- name: Upload target
env:
TARGET: ${{ matrix.target }}
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
- name: Determine packages to upload
id: packages
run: |
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
# Use packages determined by check-updates job
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
case "$PACKAGE" in
dms) PPA_NAME="dms" ;;
dms-git) PPA_NAME="dms-git" ;;
dms-greeter) PPA_NAME="danklinux" ;;
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
esac
if [[ "${{ github.event_name }}" == "schedule" ]]; then
echo "Triggered by schedule: uploading git package"
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
echo "Manual trigger: ${{ needs.check-updates.outputs.packages }}"
fi
echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
- name: Upload to PPA
run: |
PACKAGES="${{ steps.packages.outputs.packages }}"
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
if [[ -z "$PACKAGES" ]]; then
echo "✓ No packages need uploading. All up to date!"
exit 0
fi
# Export REBUILD_RELEASE so ppa-build.sh can use it
if [[ -n "$REBUILD_RELEASE" ]]; then
export REBUILD_RELEASE
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
fi
# PACKAGES can be space-separated list (e.g., "dms-git dms dms-greeter" from "all" check)
# Loop through each package and upload
for PKG in $PACKAGES; do
# Map package to PPA name
case "$PKG" in
dms)
PPA_NAME="dms"
;;
dms-git)
PPA_NAME="dms-git"
;;
dms-greeter)
PPA_NAME="danklinux"
;;
*)
echo "⚠️ Unknown package: $PKG, skipping"
continue
;;
esac
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
if: always()
run: |
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
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"
echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "### PPA Package Upload Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
PACKAGES="${{ steps.packages.outputs.packages }}"
if [[ -z "$PACKAGES" ]]; then
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

View File

@@ -20,11 +20,3 @@ repos:
language: system
files: ^core/.*\.(go|mod|sum)$
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/)

View File

@@ -26,17 +26,6 @@ var runCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
daemon, _ := cmd.Flags().GetBool("daemon")
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 {
runShellDaemon(session)
} else {
@@ -537,7 +526,5 @@ func getCommonCommands() []*cobra.Command {
dlCmd,
randrCmd,
blurCmd,
trashCmd,
systemCmd,
}
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -12,7 +11,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
@@ -269,8 +267,6 @@ func runSetupDmsConfig(name string) error {
func runSetup() error {
fmt.Println("=== DMS Configuration Setup ===")
ensureInputGroup()
wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal()
useSystemd := promptSystemd()
@@ -344,37 +340,6 @@ func runSetup() error {
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) {
fmt.Println("Select compositor:")
fmt.Println("1) Niri")

View File

@@ -1,365 +0,0 @@
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
}

View File

@@ -1,122 +0,0 @@
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)
}
}

View File

@@ -15,8 +15,6 @@ func init() {
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("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")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)

View File

@@ -15,8 +15,6 @@ func init() {
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("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")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)

View File

@@ -80,16 +80,6 @@ func getRuntimeDir() string {
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 {
_, err := exec.LookPath("systemd-run")
return err == nil
@@ -226,8 +216,6 @@ func runShellInteractive(session bool) {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
}
cmd.Env = appendLogEnv(cmd.Env)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -471,8 +459,6 @@ func runShellDaemon(session bool) {
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)
if err != nil {
log.Fatalf("Error opening /dev/null: %v", err)

View File

@@ -15,7 +15,7 @@ func isReadOnlyCommand(args []string) bool {
continue
}
switch arg {
case "completion", "help", "__complete", "system":
case "completion", "help", "__complete":
return true
}
return false

View File

@@ -1,100 +1,84 @@
module github.com/AvengeMedia/DankMaterialShell/core
go 1.26.1
go 1.26.0
toolchain go1.26.1
require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.24.1
github.com/alecthomas/chroma/v2 v2.23.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v1.0.0
github.com/fsnotify/fsnotify v1.10.1
github.com/charmbracelet/log v0.4.2
github.com/fsnotify/fsnotify v1.9.0
github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark v1.7.16
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
tailscale.com v1.96.5
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
golang.org/x/image v0.36.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/emirpasic/gods v1.18.1 // 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/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // 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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.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/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sergi/go-diff v1.4.0 // 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
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // 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
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-alpha.2
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mattn/go-isatty v0.0.22
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.43.0
golang.org/x/text v0.36.0
golang.org/x/sys v0.41.0
golang.org/x/text v0.34.0
gopkg.in/yaml.v3 v3.0.1
)

View File

@@ -1,18 +1,14 @@
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/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
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/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/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.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
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/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
@@ -28,71 +24,54 @@ 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/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
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/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/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/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/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.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.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.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
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/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 h1:wH21vHuv323v9x78JNFNJ6P7HEAsdwr9yq2k9/o4zEE=
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
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-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
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-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -105,16 +84,12 @@ 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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc=
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/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/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -126,20 +101,14 @@ 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
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=
@@ -148,13 +117,12 @@ 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/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
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.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/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -178,12 +146,6 @@ 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
@@ -193,47 +155,37 @@ 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/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/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/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
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=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 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-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.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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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=

View File

@@ -1,16 +1,12 @@
package log
import (
"io"
"os"
"regexp"
"strings"
"sync"
"github.com/charmbracelet/lipgloss"
cblog "github.com/charmbracelet/log"
"github.com/mattn/go-isatty"
"github.com/muesli/termenv"
)
// Logger embeds the Charm Logger and adds Printf/Fatalf
@@ -25,26 +21,8 @@ func (l *Logger) Fatalf(format string, v ...any) { l.Logger.Fatalf(format, v...)
var (
logger *Logger
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 {
switch strings.ToLower(level) {
case "debug":
@@ -108,7 +86,7 @@ func GetLogger() *Logger {
SetString(" DEBUG").
Foreground(lipgloss.Color("4"))
base := cblog.New(logStderr)
base := cblog.New(os.Stderr)
base.SetStyles(styles)
base.SetReportTimestamp(false)
@@ -120,85 +98,10 @@ func GetLogger() *Logger {
base.SetPrefix(" go")
logger = &Logger{base}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
_ = SetLogFile(path)
}
})
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
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }

View File

@@ -60,7 +60,6 @@ var templateRegistry = []TemplateDef{
{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: "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: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},

View File

@@ -391,7 +391,7 @@ func (m *Manager) Close() {
func InitializeManager() (*Manager, error) {
if os.Getuid() != 0 && !hasInputGroupAccess() {
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 nil, fmt.Errorf("insufficient permissions to access input devices")
}
return NewManager()

View File

@@ -104,7 +104,7 @@ func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface
return false
}
if reply != dbus.RequestNameReplyPrimaryOwner {
log.Infof("Screensaver name %s already owned by another process (e.g. hypridle/swayidle)", name)
log.Warnf("Screensaver name %s already owned by another process", name)
return false
}
if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {

View File

@@ -20,8 +20,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
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"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -111,15 +109,6 @@ func RouteRequest(conn net.Conn, req models.Request) {
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 dwlManager == nil {
models.RespondError(conn, req.ID, "dwl manager not initialized")
@@ -213,15 +202,6 @@ func RouteRequest(conn net.Conn, req models.Request) {
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 {
case "ping":
models.Respond(conn, req.ID, "pong")

View File

@@ -30,8 +30,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"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/trayrecovery"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -66,7 +64,6 @@ var waylandManager *wayland.Manager
var bluezManager *bluez.Manager
var appPickerManager *apppicker.Manager
var cupsManager *cups.Manager
var tailscaleManager *tailscale.Manager
var dwlManager *dwl.Manager
var extWorkspaceManager *extworkspace.Manager
var brightnessManager *brightness.Manager
@@ -78,7 +75,6 @@ var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager
var trayRecoveryManager *trayrecovery.Manager
var locationManager *location.Manager
var sysUpdateManager *sysupdate.Manager
var geoClientInstance geolocation.Client
const dbusClientID = "dms-dbus-client"
@@ -425,19 +421,6 @@ func InitializeLocationManager(geoClient geolocation.Client) error {
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) {
defer conn.Close()
@@ -491,10 +474,6 @@ func getCapabilities() Capabilities {
caps = append(caps, "cups")
}
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
caps = append(caps, "tailscale")
}
if dwlManager != nil {
caps = append(caps, "dwl")
}
@@ -527,10 +506,6 @@ func getCapabilities() Capabilities {
caps = append(caps, "dbus")
}
if sysUpdateManager != nil {
caps = append(caps, "sysupdate")
}
return Capabilities{Capabilities: caps}
}
@@ -565,10 +540,6 @@ func getServerInfo() ServerInfo {
caps = append(caps, "cups")
}
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
caps = append(caps, "tailscale")
}
if dwlManager != nil {
caps = append(caps, "dwl")
}
@@ -605,10 +576,6 @@ func getServerInfo() ServerInfo {
caps = append(caps, "dbus")
}
if sysUpdateManager != nil {
caps = append(caps, "sysupdate")
}
return ServerInfo{
APIVersion: APIVersion,
CLIVersion: CLIVersion,
@@ -1049,38 +1016,6 @@ 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 {
wg.Add(1)
dwlChan := dwlManager.Subscribe(clientID + "-dwl")
@@ -1308,38 +1243,6 @@ 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 {
wg.Add(1)
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
@@ -1445,28 +1348,14 @@ func cleanupManagers() {
if locationManager != nil {
locationManager.Close()
}
if sysUpdateManager != nil {
sysUpdateManager.Close()
}
if geoClientInstance != nil {
geoClientInstance.Close()
}
if tailscaleManager != nil {
tailscaleManager.Close()
}
}
func Start(printDocs bool) error {
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()
os.Remove(socketPath)
@@ -1844,10 +1733,6 @@ func Start(printDocs bool) error {
}
}()
if err := InitializeSysUpdateManager(); err != nil {
log.Warnf("Sysupdate manager unavailable: %v", err)
}
log.Info("")
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)

View File

@@ -1,96 +0,0 @@
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
}

View File

@@ -1,81 +0,0 @@
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
}

View File

@@ -1,72 +0,0 @@
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)
}
})
}
}

View File

@@ -1,136 +0,0 @@
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
}

View File

@@ -1,114 +0,0 @@
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)
}
})
}
}

View File

@@ -1,156 +0,0 @@
package sysupdate
import (
"context"
"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) {
cmd := exec.CommandContext(ctx, "flatpak", "remote-ls", "--updates", "--columns=application,version,branch,commit,name")
out, err := cmd.Output()
if err != nil {
return nil, err
}
installed := flatpakInstalled(ctx)
return parseFlatpakUpdates(string(out), installed), nil
}
func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
out, err := exec.CommandContext(ctx, "flatpak", "list", "--columns=application,version,branch,active").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]
}
if len(fields) > 3 {
entry.commit = fields[3]
}
key := appID
if entry.branch != "" {
key = appID + "//" + entry.branch
}
m[key] = entry
}
return m
}
type flatpakInstalledEntry struct {
version string
branch string
commit string
}
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 parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) == 0 || fields[0] == "" {
continue
}
appID := fields[0]
version, branch, commit := "", "", ""
if len(fields) > 1 {
version = fields[1]
}
if len(fields) > 2 {
branch = fields[2]
}
if len(fields) > 3 {
commit = fields[3]
}
display := appID
if len(fields) > 4 && fields[4] != "" {
display = fields[4]
}
key := appID
if branch != "" {
key = appID + "//" + branch
}
inst := installed[key]
if inst.commit != "" && commit != "" && strings.HasPrefix(commit, inst.commit) {
continue
}
from, to := flatpakVersionPair(inst.version, inst.commit, version, commit)
ref := appID
if branch != "" {
ref = appID + "//" + branch
}
pkgs = append(pkgs, Package{
Name: display,
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: from,
ToVersion: to,
Ref: ref,
})
}
return pkgs
}
func flatpakVersionPair(installedVer, installedCommit, remoteVer, remoteCommit string) (from, to string) {
if remoteVer != "" {
return installedVer, remoteVer
}
return shortCommit(installedCommit), shortCommit(remoteCommit)
}
func shortCommit(c string) string {
if len(c) > 8 {
return c[:8]
}
return c
}

View File

@@ -1,150 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseFlatpakUpdates(t *testing.T) {
tests := []struct {
name string
input string
installed map[string]flatpakInstalledEntry
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "real flathub-style row with empty version, falls back to commit",
// columns: application,version,branch,commit,name
input: "com.discordapp.Discord\t\tstable\t43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586\tDiscord",
installed: map[string]flatpakInstalledEntry{
"com.discordapp.Discord//stable": {commit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855"},
},
want: []Package{
{
Name: "Discord",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "8b16fa1a",
ToVersion: "43a1e5d2",
Ref: "com.discordapp.Discord//stable",
},
},
},
{
name: "remote provides version, installed version known",
input: "com.example.App\t1.5.0\tstable\tdeadbeefcafe\tExample App",
installed: map[string]flatpakInstalledEntry{
"com.example.App//stable": {version: "1.4.2"},
},
want: []Package{
{
Name: "Example App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "1.4.2",
ToVersion: "1.5.0",
Ref: "com.example.App//stable",
},
},
},
{
name: "no installed entry, remote has no version, falls back to commit on both sides",
input: "org.gnome.Platform\t\t49\tbadcd4afb1fe\tgnome platform",
installed: nil,
want: []Package{
{
Name: "gnome platform",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "badcd4af",
Ref: "org.gnome.Platform//49",
},
},
},
{
name: "missing display name falls back to application id",
input: "com.example.NoName\t2.0\tstable\tabcdef123456\t",
want: []Package{
{
Name: "com.example.NoName",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "2.0",
Ref: "com.example.NoName//stable",
},
},
},
{
name: "skips blank lines and rows with empty application id",
input: "\n\t\t\t\t\norg.real.App\t1.0\tstable\tdeadbeef\tReal App",
want: []Package{
{
Name: "Real App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "1.0",
Ref: "org.real.App//stable",
},
},
},
{
name: "skips phantom updates where remote commit matches installed",
input: "com.phantom.App\t\tstable\tabc12345deadbeef\tPhantom",
installed: map[string]flatpakInstalledEntry{
"com.phantom.App//stable": {commit: "abc12345"},
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseFlatpakUpdates(tt.input, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseFlatpakUpdates() = %#v\nwant %#v", got, tt.want)
}
})
}
}
func TestFlatpakVersionPair(t *testing.T) {
tests := []struct {
name string
installedVer, installedCommit, remoteVer, remoteCommit string
wantFrom, wantTo string
}{
{
name: "remote has version - prefer versions",
installedVer: "1.0.0", remoteVer: "1.1.0",
wantFrom: "1.0.0", wantTo: "1.1.0",
},
{
name: "remote has no version - both sides fall to short commit",
installedCommit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855",
remoteCommit: "43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586",
wantFrom: "8b16fa1a", wantTo: "43a1e5d2",
},
{
name: "short commits left as-is",
installedCommit: "abc123", remoteCommit: "def456",
wantFrom: "abc123", wantTo: "def456",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
from, to := flatpakVersionPair(tt.installedVer, tt.installedCommit, tt.remoteVer, tt.remoteCommit)
if from != tt.wantFrom || to != tt.wantTo {
t.Errorf("flatpakVersionPair() = (%q, %q), want (%q, %q)", from, to, tt.wantFrom, tt.wantTo)
}
})
}
}

View File

@@ -1,253 +0,0 @@
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
}

View File

@@ -1,114 +0,0 @@
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)
}
})
}
}

View File

@@ -1,132 +0,0 @@
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
}

View File

@@ -1,104 +0,0 @@
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)
}
})
}
}

View File

@@ -1,85 +0,0 @@
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")
}

View File

@@ -1,80 +0,0 @@
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)
}
})
}
}

View File

@@ -1,153 +0,0 @@
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.Stdin = os.Stdin
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}
}
}

View File

@@ -1,55 +0,0 @@
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())
}

View File

@@ -1,527 +0,0 @@
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
}

View File

@@ -1,60 +0,0 @@
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
}

View File

@@ -1,88 +0,0 @@
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
}

View File

@@ -1,147 +0,0 @@
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)
}
}

View File

@@ -1,135 +0,0 @@
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)
}
}

View File

@@ -1,223 +0,0 @@
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)
})
}
}

View File

@@ -1,30 +0,0 @@
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"})
}

View File

@@ -1,97 +0,0 @@
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")
}

View File

@@ -1,277 +0,0 @@
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)
}

View File

@@ -1,307 +0,0 @@
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)
}

View File

@@ -1,31 +0,0 @@
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"`
}

View File

@@ -1,455 +0,0 @@
// 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
}

View File

@@ -1,315 +0,0 @@
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)
}
}
}

View File

@@ -1,52 +0,0 @@
{
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
;
};
}

View File

@@ -1,60 +0,0 @@
{
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)
'';
}

View File

@@ -1,107 +0,0 @@
{
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)
'';
}

View File

@@ -1,84 +0,0 @@
{
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'")
'';
}

View File

@@ -1,47 +0,0 @@
{
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)
'';
}

View File

@@ -1,48 +0,0 @@
{
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")
'';
}

View File

@@ -1,160 +0,0 @@
#!/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

View File

@@ -217,42 +217,6 @@ fi
PACKAGE_DIR=$(cd "$PACKAGE_DIR" && pwd)
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
SOURCE_FORMAT_LINE=$(head -1 "$PACKAGE_DIR/debian/source/format" 2>/dev/null || echo "")
IS_NATIVE_DUAL=false
@@ -366,30 +330,8 @@ if [ "$PPA_NAME" = "danklinux" ] || [ "$PPA_NAME" = "dms" ] || [ "$PPA_NAME" = "
info " - $BUILDINFO"
echo
if [[ -n "${GITHUB_ACTIONS:-}" || -n "${CI:-}" ]] && command -v dput >/dev/null 2>&1; then
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
LFTP_SCRIPT=$(mktemp)
cat >"$LFTP_SCRIPT" <<EOF
cd ~avengemedia/ubuntu/$PPA_NAME/
lcd $BUILD_DIR
mput $CHANGES_BASENAME
@@ -399,14 +341,13 @@ mput $BUILDINFO
bye
EOF
if lftp -d ftp://anonymous:@ppa.launchpad.net <"$LFTP_SCRIPT"; then
success "Upload successful!"
rm -f "$LFTP_SCRIPT"
else
error "Upload failed!"
rm -f "$LFTP_SCRIPT"
exit 1
fi
if lftp -d ftp://anonymous:@ppa.launchpad.net <"$LFTP_SCRIPT"; then
success "Upload successful!"
rm -f "$LFTP_SCRIPT"
else
error "Upload failed!"
rm -f "$LFTP_SCRIPT"
exit 1
fi
else
# This branch should not be reached for DMS packages

170
flake.nix
View File

@@ -45,12 +45,10 @@
nixpkgs.lib.genAttrs [ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ] (
system: fn system nixpkgs.legacyPackages.${system}
);
forEachLinuxSystem =
fn:
nixpkgs.lib.genAttrs [ "aarch64-linux" "x86_64-linux" ] (
system: fn system nixpkgs.legacyPackages.${system}
);
buildDmsPkgs = pkgs: {
dms-shell = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
mkModuleWithDmsPkgs =
modulePath:
args@{ pkgs, ... }:
@@ -59,7 +57,6 @@
(import modulePath (args // { dmsPkgs = buildDmsPkgs pkgs; }))
];
};
mkQmlImportPath =
pkgs: qmlPkgs:
pkgs.lib.concatStringsSep ":" (map (o: "${o}/${pkgs.qt6.qtbase.qtQmlPrefix}") qmlPkgs);
@@ -76,11 +73,10 @@
qtimageformats
kimageformats
];
# Allows downstream modules to provide their own 'pkgs' (with overlays)
# instead of being forced to use the flake's locked nixpkgs.
mkDmsShell =
pkgs:
in
{
packages = forEachSystem (
system: pkgs:
let
mkDate =
longDate:
@@ -98,96 +94,89 @@
in
"${cleanVersion}${dateSuffix}${revSuffix}";
in
pkgs.lib.makeOverridable (
{
extraQtPackages ? [ ],
}:
(pkgs.buildGoModule.override { go = goForPkgs pkgs; }) (
let
rootSrc = ./.;
qtPackages = (qmlPkgs pkgs) ++ extraQtPackages;
in
{
dms-shell = pkgs.lib.makeOverridable (
{
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-nvxFHQhOfBGl3h51fgYDb39K0NCj+H8mAEyKr1qOwJQ=";
extraQtPackages ? [ ],
}:
(pkgs.buildGoModule.override { go = goForPkgs pkgs; }) (
let
rootSrc = ./.;
qtPackages = (qmlPkgs pkgs) ++ extraQtPackages;
in
{
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-dEk7IOd6aQwaxZruxQclN7TGMyb8EJOl6NBWRsoZ9HQ=";
subPackages = [ "cmd/dms" ];
subPackages = [ "cmd/dms" ];
ldflags = [
"-s"
"-w"
"-X 'main.Version=${version}'"
];
ldflags = [
"-s"
"-w"
"-X 'main.Version=${version}'"
];
nativeBuildInputs = with pkgs; [
installShellFiles
makeWrapper
];
nativeBuildInputs = with pkgs; [
installShellFiles
makeWrapper
];
postInstall = ''
mkdir -p $out/share/quickshell/dms
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
postInstall = ''
mkdir -p $out/share/quickshell/dms
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
chmod u+w $out/share/quickshell/dms/VERSION
echo "${version}" > $out/share/quickshell/dms/VERSION
chmod u+w $out/share/quickshell/dms/VERSION
echo "${version}" > $out/share/quickshell/dms/VERSION
# Install desktop file and icon
install -D ${rootSrc}/assets/dms-open.desktop \
$out/share/applications/dms-open.desktop
install -D ${rootSrc}/core/assets/danklogo.svg \
$out/share/hicolor/scalable/apps/danklogo.svg
# Install desktop file and icon
install -D ${rootSrc}/assets/dms-open.desktop \
$out/share/applications/dms-open.desktop
install -D ${rootSrc}/core/assets/danklogo.svg \
$out/share/hicolor/scalable/apps/danklogo.svg
wrapProgram $out/bin/dms \
--add-flags "-c $out/share/quickshell/dms" \
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
wrapProgram $out/bin/dms \
--add-flags "-c $out/share/quickshell/dms" \
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
install -Dm644 ${rootSrc}/assets/systemd/dms.service \
$out/lib/systemd/user/dms.service
install -Dm644 ${rootSrc}/assets/systemd/dms.service \
$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/pkill ${pkgs.procps}/bin/pkill
substituteInPlace $out/lib/systemd/user/dms.service \
--replace-fail /usr/bin/dms $out/bin/dms \
--replace-fail /usr/bin/pkill ${pkgs.procps}/bin/pkill
substituteInPlace $out/share/quickshell/dms/Modules/Greetd/assets/dms-greeter \
--replace-fail /bin/bash ${pkgs.bashInteractive}/bin/bash
substituteInPlace $out/share/quickshell/dms/Modules/Greetd/assets/dms-greeter \
--replace-fail /bin/bash ${pkgs.bashInteractive}/bin/bash
substituteInPlace $out/share/quickshell/dms/assets/pam/fprint \
--replace-fail pam_fprintd.so ${pkgs.fprintd}/lib/security/pam_fprintd.so
substituteInPlace $out/share/quickshell/dms/assets/pam/fprint \
--replace-fail pam_fprintd.so ${pkgs.fprintd}/lib/security/pam_fprintd.so
substituteInPlace $out/share/quickshell/dms/assets/pam/u2f \
--replace-fail pam_u2f.so ${pkgs.pam_u2f}/lib/security/pam_u2f.so
substituteInPlace $out/share/quickshell/dms/assets/pam/u2f \
--replace-fail pam_u2f.so ${pkgs.pam_u2f}/lib/security/pam_u2f.so
installShellCompletion --cmd dms \
--bash <($out/bin/dms completion bash) \
--fish <($out/bin/dms completion fish) \
--zsh <($out/bin/dms completion zsh)
'';
installShellCompletion --cmd dms \
--bash <($out/bin/dms completion bash) \
--fish <($out/bin/dms completion fish) \
--zsh <($out/bin/dms completion zsh)
'';
meta = {
description = "Desktop shell for wayland compositors built with Quickshell & GO";
homepage = "https://danklinux.com";
changelog = "https://github.com/AvengeMedia/DankMaterialShell/releases/tag/v${version}";
license = pkgs.lib.licenses.mit;
mainProgram = "dms";
platforms = pkgs.lib.platforms.linux;
};
}
)
) { };
meta = {
description = "Desktop shell for wayland compositors built with Quickshell & GO";
homepage = "https://danklinux.com";
changelog = "https://github.com/AvengeMedia/DankMaterialShell/releases/tag/v${version}";
license = pkgs.lib.licenses.mit;
mainProgram = "dms";
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;
default = self.packages.${system}.dms-shell;
}
);
@@ -251,16 +240,5 @@
};
}
);
nixosTests = forEachLinuxSystem (
system: pkgs:
import ./distro/nix/tests {
inherit
self
pkgs
;
lib = pkgs.lib;
}
);
};
}

View File

@@ -55,7 +55,7 @@ Singleton {
readonly property bool isDirectionalEffect: isConnectedEffect || _effect === 1
readonly property bool isDepthEffect: _effect === 2
readonly property bool isConnectedEffect: (typeof SettingsData !== "undefined") && SettingsData.connectedFrameModeActive
readonly property bool isConnectedEffect: (typeof SettingsData !== "undefined") && SettingsData.frameEnabled && _effect === 1 && SettingsData.directionalAnimationMode === 3
readonly property real effectScaleCollapsed: _effectScaleCollapsed[_effect] !== undefined ? _effectScaleCollapsed[_effect] : 0.96
readonly property real effectAnimOffset: _effectAnimOffsets[_effect] !== undefined ? _effectAnimOffsets[_effect] : 16

View File

@@ -5,11 +5,9 @@ import QtCore
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("CacheData")
readonly property int cacheConfigVersion: 1
@@ -133,7 +131,7 @@ Singleton {
}
}
} catch (e) {
log.warn("Failed to parse cache:", e.message);
console.warn("CacheData: Failed to parse cache:", e.message);
} finally {
_loading = false;
}
@@ -151,7 +149,7 @@ Singleton {
}
function migrateFromUndefinedToV1(cache) {
log.info("Migrating configuration from undefined to version 1");
console.info("CacheData: Migrating configuration from undefined to version 1");
}
function cleanupUnusedKeys() {
@@ -166,7 +164,7 @@ Singleton {
for (const key in cache) {
if (!validKeys.includes(key)) {
log.debug("Removing unused key:", key);
console.log("CacheData: Removing unused key:", key);
delete cache[key];
needsSave = true;
}
@@ -176,7 +174,7 @@ Singleton {
cacheFile.setText(JSON.stringify(cache, null, 2));
}
} catch (e) {
log.warn("Failed to cleanup unused keys:", e.message);
console.warn("CacheData: Failed to cleanup unused keys:", e.message);
}
}
@@ -186,7 +184,7 @@ Singleton {
if (content && content.trim())
return JSON.parse(content);
} catch (e) {
log.warn("Failed to parse launcher cache:", e.message);
console.warn("CacheData: Failed to parse launcher cache:", e.message);
}
return null;
}
@@ -222,7 +220,7 @@ Singleton {
}
onLoadFailed: error => {
if (!isGreeterMode) {
log.info("No cache file found, starting fresh");
console.info("CacheData: No cache file found, starting fresh");
}
}
}

View File

@@ -161,22 +161,12 @@ Singleton {
};
}
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;
next[screenName] = _normalizeDockState(state);
dockStates = next;
return true;
}
@@ -201,20 +191,17 @@ Singleton {
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
"x": Number(x),
"y": Number(y)
};
dockSlides = next;
return true;
}
// ─── Notification state (per screen, updated by NotificationSurface) ──────
readonly property var emptyNotificationState: ({
"visible": false,
"barSide": "top",
@@ -382,6 +369,8 @@ Singleton {
return true;
}
// ─── Dock retract coordination ────────────────────────────────
property var dockRetractRequests: ({})
function requestDockRetract(requesterId, screenName, side) {
@@ -418,64 +407,4 @@ Singleton {
}
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;
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();
}
}
}

View File

@@ -1,55 +0,0 @@
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()
}

View File

@@ -5,11 +5,9 @@ import QtQuick
import Qt.labs.folderlistmodel
import Quickshell
import Quickshell.Io
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("I18n")
property string _resolvedLocale: "en"
@@ -56,15 +54,15 @@ Singleton {
try {
root.translations = JSON.parse(text());
root.translationsLoaded = true;
log.info(`I18n: Loaded translations for '${root._resolvedLocale}' (${Object.keys(root.translations).length} contexts)`);
console.info(`I18n: Loaded translations for '${root._resolvedLocale}' (${Object.keys(root.translations).length} contexts)`);
} catch (e) {
log.warn(`I18n: Error parsing '${root._resolvedLocale}':`, e, "- falling back to English");
console.warn(`I18n: Error parsing '${root._resolvedLocale}':`, e, "- falling back to English");
root._fallbackToEnglish();
}
}
onLoadFailed: error => {
log.warn(`I18n: Failed to load '${root._resolvedLocale}' (${error}), ` + "falling back to English");
console.warn(`I18n: Failed to load '${root._resolvedLocale}' (${error}), ` + "falling back to English");
root._fallbackToEnglish();
}
}
@@ -107,14 +105,14 @@ Singleton {
_selectedPath = fileUrl;
translationsLoaded = false;
translations = ({});
log.info(`I18n: Using locale '${localeTag}' from ${fileUrl}`);
console.info(`I18n: Using locale '${localeTag}' from ${fileUrl}`);
}
function _fallbackToEnglish() {
_selectedPath = "";
translationsLoaded = false;
translations = ({});
log.warn("Falling back to built-in English strings");
console.warn("I18n: Falling back to built-in English strings");
}
function tr(term, context) {

View File

@@ -3,11 +3,9 @@ pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("Proc")
readonly property int noTimeout: -1
property int defaultDebounceMs: 50
@@ -114,7 +112,7 @@ Singleton {
const safeExitCode = exitCodeValue !== null && exitCodeValue !== undefined ? exitCodeValue : -1;
entry.callback(safeOutput, safeExitCode);
} catch (e) {
log.warn("runCommand callback error for command:", entry.command, "Error:", e);
console.warn("runCommand callback error for command:", entry.command, "Error:", e);
}
}
try {

View File

@@ -12,7 +12,6 @@ import "settings/SessionStore.js" as Store
Singleton {
id: root
readonly property var log: Log.scoped("SessionData")
readonly property int sessionConfigVersion: 3
@@ -31,36 +30,9 @@ Singleton {
property bool isLightMode: false
property bool doNotDisturb: false
property real doNotDisturbUntil: 0
property string terminalOverride: ""
property bool isSwitchingMode: false
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 {
id: dndExpireTimer
repeat: false
@@ -258,7 +230,7 @@ Singleton {
} catch (e) {
_parseError = true;
const msg = e.message;
log.error("Failed to parse session.json - file will not be overwritten.");
console.error("SessionData: Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
}
}
@@ -338,7 +310,7 @@ Singleton {
} catch (e) {
_parseError = true;
const msg = e.message;
log.error("Failed to parse session.json - file will not be overwritten.");
console.error("SessionData: Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
}
}
@@ -553,7 +525,7 @@ Singleton {
}
if (!screen) {
log.warn("Screen not found");
console.warn("SessionData: Screen not found");
return;
}
@@ -650,7 +622,7 @@ Singleton {
}
if (!screen) {
log.warn("Screen not found");
console.warn("SessionData: Screen not found");
return;
}
@@ -681,7 +653,7 @@ Singleton {
}
if (!screen) {
log.warn("Screen not found");
console.warn("SessionData: Screen not found");
return;
}
@@ -712,7 +684,7 @@ Singleton {
}
if (!screen) {
log.warn("Screen not found");
console.warn("SessionData: Screen not found");
return;
}
@@ -743,7 +715,7 @@ Singleton {
}
if (!screen) {
log.warn("Screen not found");
console.warn("SessionData: Screen not found");
return;
}

View File

@@ -13,7 +13,6 @@ import "settings/SettingsStore.js" as Store
Singleton {
id: root
readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 11
@@ -185,6 +184,8 @@ Singleton {
onAnimationVariantChanged: saveSettings()
property int motionEffect: SettingsData.AnimationEffect.Standard
onMotionEffectChanged: saveSettings()
property int directionalAnimationMode: 0
onDirectionalAnimationModeChanged: saveSettings()
property bool m3ElevationEnabled: true
onM3ElevationEnabledChanged: saveSettings()
property int m3ElevationIntensity: 12
@@ -238,20 +239,16 @@ Singleton {
onFrameShowOnOverviewChanged: saveSettings()
property bool frameBlurEnabled: true
onFrameBlurEnabledChanged: saveSettings()
property bool frameCloseGaps: true
property bool frameCloseGaps: false
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 int previousDirectionalMode: 1
onPreviousDirectionalModeChanged: saveSettings()
property var connectedFrameBarStyleBackups: ({})
onConnectedFrameBarStyleBackupsChanged: saveSettings()
property var connectedFrameModalDarkenBackup: null
onConnectedFrameModalDarkenBackupChanged: saveSettings()
readonly property bool connectedFrameModeActive: frameEnabled && frameMode === "connected"
readonly property bool connectedFrameModeActive: frameEnabled && motionEffect === SettingsData.AnimationEffect.Directional && directionalAnimationMode === 3
onConnectedFrameModeActiveChanged: {
if (_loading)
return;
@@ -560,7 +557,6 @@ Singleton {
property bool matugenTemplatePywalfox: true
property bool matugenTemplateZenBrowser: true
property bool matugenTemplateVesktop: true
property bool matugenTemplateVencord: true
property bool matugenTemplateEquibop: true
property bool matugenTemplateGhostty: true
property bool matugenTemplateKitty: true
@@ -589,7 +585,6 @@ Singleton {
property bool showDock: false
property bool dockAutoHide: false
property bool dockSmartAutoHide: false
property bool dockHideOnFullscreen: true
property bool dockGroupByApp: false
property bool dockRestoreSpecialWorkspaceOnClick: false
property bool dockOpenOnOverview: false
@@ -614,9 +609,6 @@ Singleton {
property int dockMaxVisibleApps: 0
property int dockMaxVisibleRunningApps: 0
property bool dockShowOverflowBadge: true
property bool dockShowTrash: false
property string dockTrashFileManager: "default"
property string dockTrashCustomCommand: ""
property bool notificationOverlayEnabled: false
property bool notificationPopupShadowEnabled: true
@@ -709,9 +701,6 @@ Singleton {
property bool updaterUseCustomCommand: false
property string updaterCustomCommand: ""
property string updaterTerminalAdditionalParams: ""
property int updaterIntervalSeconds: 1800
property bool updaterIncludeFlatpak: true
property bool updaterAllowAUR: true
property string displayNameMode: "system"
property var screenPreferences: ({})
@@ -1343,9 +1332,6 @@ Singleton {
Store.parse(root, obj);
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
frameMode = "connected";
if (obj?.weatherLocation !== undefined)
_legacyWeatherLocation = obj.weatherLocation;
if (obj?.weatherCoordinates !== undefined)
@@ -1366,7 +1352,7 @@ Singleton {
} catch (e) {
_parseError = true;
const msg = e.message;
log.error("Failed to parse settings.json - file will not be overwritten. Error:", msg);
console.error("SettingsData: Failed to parse settings.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
applyStoredTheme();
} finally {
@@ -1388,12 +1374,12 @@ Singleton {
if (_isReadOnly) {
_hasUnsavedChanges = _checkForUnsavedChanges();
if (!wasReadOnly)
log.info("settings.json is now read-only");
console.info("SettingsData: settings.json is now read-only");
} else {
_loadedSettingsSnapshot = JSON.stringify(Store.toJson(root));
_hasUnsavedChanges = false;
if (wasReadOnly)
log.info("settings.json is now writable");
console.info("SettingsData: settings.json is now writable");
if (_pendingMigration)
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
}
@@ -1447,7 +1433,7 @@ Singleton {
} catch (e) {
const msg = e.message || String(e);
if (!_isMissingPluginSettingsError(e))
log.warn("Failed to load plugin_settings.json. Error:", msg);
console.warn("SettingsData: Failed to load plugin_settings.json. Error:", msg);
_resetPluginSettings();
}
}
@@ -1464,7 +1450,7 @@ Singleton {
} catch (e) {
_pluginParseError = true;
const msg = e.message;
log.error("Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg);
console.error("SettingsData: 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));
pluginSettings = {};
} finally {
@@ -1605,15 +1591,12 @@ Singleton {
};
}
// Single entry point for connected-mode settings state.
// Single entry point for connected-mode bar-style state.
// active → capture backups (if not yet) and sanitize bar configs
// !active → restore backups
function _reconcileConnectedFrameBarStyles() {
if (!connectedFrameModeActive) {
_restoreConnectedFrameBarStyleBackups();
if (connectedFrameModalDarkenBackup === true) {
connectedFrameModalDarkenBackup = null;
set("modalDarkenBackground", true);
}
return;
}
if (!_hasConnectedFrameBarStyleBackups())
@@ -1623,11 +1606,6 @@ Singleton {
barConfigs = result.configs;
updateBarConfigs();
}
// Force modalDarkenBackground off; capture backup if not already set
if (modalDarkenBackground) {
connectedFrameModalDarkenBackup = true;
set("modalDarkenBackground", false);
}
}
function detectAvailableIconThemes() {
@@ -3110,7 +3088,7 @@ Singleton {
} catch (e) {
_parseError = true;
const msg = e.message;
log.error("Failed to reload settings.json - file will not be overwritten. Error:", msg);
console.error("SettingsData: Failed to reload settings.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
} finally {
_loading = false;
@@ -3145,7 +3123,7 @@ Singleton {
if (!isGreeterMode) {
const msg = String(error || "");
if (!_isMissingPluginSettingsError(error))
log.warn("Failed to load plugin_settings.json. Error:", msg);
console.warn("SettingsData: Failed to load plugin_settings.json. Error:", msg);
_resetPluginSettings();
}
}

View File

@@ -12,7 +12,6 @@ import "StockThemes.js" as StockThemes
Singleton {
id: root
readonly property var log: Log.scoped("Theme")
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"
@@ -149,7 +148,7 @@ Singleton {
}
if (colorsFileLoadFailed && currentTheme === dynamic && rawWallpaperPath) {
log.info("Matugen now available, regenerating colors for dynamic theme");
console.info("Theme: Matugen now available, regenerating colors for dynamic theme");
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
const iconTheme = (typeof SettingsData !== "undefined" && SettingsData.iconTheme) ? SettingsData.iconTheme : "System Default";
const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot";
@@ -377,7 +376,7 @@ Singleton {
"use": true
}, response => {
if (!response.error) {
log.info("Theme automation: IP location enabled after connection");
console.info("Theme automation: IP location enabled after connection");
}
});
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
@@ -390,13 +389,13 @@ Singleton {
"longitude": SessionData.longitude
}, locationResponse => {
if (locationResponse?.error) {
log.warn("Theme automation: Failed to set location", locationResponse.error);
console.warn("Theme automation: Failed to set location", locationResponse.error);
}
});
}
});
} else {
log.warn("Theme automation: No location configured");
console.warn("Theme automation: No location configured");
}
}
}
@@ -986,6 +985,7 @@ Singleton {
"expressiveEffects": [0.34, 0.8, 0.34, 1, 1, 1]
}
// ─── Animation variant proxy ──────────────────────────────────────────────
// 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 /
@@ -1575,12 +1575,12 @@ Singleton {
function setDesiredTheme(kind, value, isLight, iconTheme, matugenType, stockColors) {
if (!matugenAvailable) {
log.warn("matugen not available or disabled - cannot set system theme");
console.warn("Theme: matugen not available or disabled - cannot set system theme");
return;
}
if (workerRunning) {
log.info("Worker already running, queueing request");
console.info("Theme: Worker already running, queueing request");
pendingThemeRequest = {
kind,
value,
@@ -1592,7 +1592,7 @@ Singleton {
return;
}
log.info("Setting desired theme -", kind, "mode:", isLight ? "light" : "dark", stockColors ? "(stock colors)" : "(dynamic)");
console.info("Theme: Setting desired theme -", kind, "mode:", isLight ? "light" : "dark", stockColors ? "(stock colors)" : "(dynamic)");
if (typeof NiriService !== "undefined" && CompositorService.isNiri) {
NiriService.suppressNextToast();
@@ -1607,7 +1607,7 @@ Singleton {
"runUserTemplates": (typeof SettingsData !== "undefined") ? SettingsData.runUserMatugenTemplates : true
};
log.debug("Starting matugen worker");
console.log("Theme: Starting matugen worker");
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,];
@@ -1631,7 +1631,7 @@ Singleton {
if (typeof SettingsData !== "undefined") {
const skipTemplates = [];
if (!SettingsData.runDmsMatugenTemplates) {
skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "firefox", "pywalfox", "zenbrowser", "vesktop", "vencord", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "firefox", "pywalfox", "zenbrowser", "vesktop", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
} else {
if (!SettingsData.matugenTemplateGtk)
skipTemplates.push("gtk");
@@ -1653,8 +1653,6 @@ Singleton {
skipTemplates.push("zenbrowser");
if (!SettingsData.matugenTemplateVesktop)
skipTemplates.push("vesktop");
if (!SettingsData.matugenTemplateVencord)
skipTemplates.push("vencord");
if (!SettingsData.matugenTemplateEquibop)
skipTemplates.push("equibop");
if (!SettingsData.matugenTemplateGhostty)
@@ -1767,7 +1765,7 @@ Singleton {
}
if (!darkTheme || !darkTheme.primary) {
log.warn("Theme data not available for:", currentTheme);
console.warn("Theme data not available for:", currentTheme);
return;
}
@@ -2011,10 +2009,10 @@ Singleton {
id: systemThemeGenerator
running: false
stdout: SplitParser {
onRead: data => log.info("Theme worker:", data)
onRead: data => console.info("Theme worker:", data)
}
stderr: SplitParser {
onRead: data => log.warn("Theme worker:", data)
onRead: data => console.warn("Theme worker:", data)
}
onExited: exitCode => {
@@ -2023,18 +2021,18 @@ Singleton {
switch (exitCode) {
case 0:
log.info("Matugen worker completed successfully");
console.info("Theme: Matugen worker completed successfully");
root.matugenCompleted(currentMode, "success");
break;
case 2:
log.debug("Matugen worker completed with code 2 (no changes needed)");
console.log("Theme: Matugen worker completed with code 2 (no changes needed)");
root.matugenCompleted(currentMode, "no-changes");
break;
default:
if (typeof ToastService !== "undefined") {
ToastService.showError("Theme worker failed (" + exitCode + ")");
}
log.warn("Matugen worker failed with exit code:", exitCode);
console.warn("Theme: Matugen worker failed with exit code:", exitCode);
root.matugenCompleted(currentMode, "error");
}
@@ -2043,7 +2041,7 @@ Singleton {
const req = pendingThemeRequest;
pendingThemeRequest = null;
log.info("Processing queued theme request");
console.info("Theme: Processing queued theme request");
setDesiredTheme(req.kind, req.value, req.isLight, req.iconTheme, req.matugenType, req.stockColors);
}
}
@@ -2097,7 +2095,7 @@ Singleton {
}
}
} catch (e) {
log.error("Failed to parse dynamic colors:", e);
console.error("Theme: Failed to parse dynamic colors:", e);
if (typeof ToastService !== "undefined") {
ToastService.wallpaperErrorStatus = "error";
ToastService.showError("Dynamic colors parse error: " + e.message);
@@ -2117,11 +2115,11 @@ Singleton {
onLoadFailed: function (error) {
if (currentTheme === dynamic) {
log.warn("Dynamic colors file load failed, marking for regeneration");
console.warn("Theme: Dynamic colors file load failed, marking for regeneration");
colorsFileLoadFailed = true;
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
if (!isGreeterMode && matugenAvailable && rawWallpaperPath) {
log.debug("Matugen available, triggering immediate regeneration");
console.log("Theme: Matugen available, triggering immediate regeneration");
generateSystemThemesFromCurrentTheme();
}
}
@@ -2245,7 +2243,7 @@ Singleton {
"endMinute": endMinute
}, response => {
if (response && response.error) {
log.error("Theme automation: Failed to sync time schedule:", response.error);
console.error("Theme automation: Failed to sync time schedule:", response.error);
}
});
@@ -2338,9 +2336,9 @@ Singleton {
if (root.themeModeAutomationActive) {
if (SessionData.nightModeUseIPLocation) {
log.warn("Theme automation: Waiting for IP location from backend");
console.warn("Theme automation: Waiting for IP location from backend");
} else {
log.warn("Theme automation: Location mode requires coordinates");
console.warn("Theme automation: Location mode requires coordinates");
}
}
}
@@ -2422,7 +2420,7 @@ Singleton {
"use": true
}, response => {
if (response?.error) {
log.warn("Theme automation: Failed to enable IP location", response.error);
console.warn("Theme automation: Failed to enable IP location", response.error);
}
});
return true;
@@ -2436,7 +2434,7 @@ Singleton {
"longitude": SessionData.longitude
}, locResp => {
if (locResp?.error) {
log.warn("Theme automation: Failed to set location", locResp.error);
console.warn("Theme automation: Failed to set location", locResp.error);
}
});
}

View File

@@ -4,7 +4,6 @@ var SPEC = {
isLightMode: { def: false },
doNotDisturb: { def: false },
doNotDisturbUntil: { def: 0 },
terminalOverride: { def: "" },
wallpaperPath: { def: "" },
perMonitorWallpaper: { def: false },

View File

@@ -51,6 +51,8 @@ var SPEC = {
enableRippleEffects: { def: true },
animationVariant: { def: 0 },
motionEffect: { def: 0 },
directionalAnimationMode: { def: 0 },
previousDirectionalMode: { def: 1 },
m3ElevationEnabled: { def: true },
m3ElevationIntensity: { def: 12 },
m3ElevationOpacity: { def: 30 },
@@ -304,7 +306,6 @@ var SPEC = {
matugenTemplatePywalfox: { def: true },
matugenTemplateZenBrowser: { def: true },
matugenTemplateVesktop: { def: true },
matugenTemplateVencord: { def: true },
matugenTemplateEquibop: { def: true },
matugenTemplateGhostty: { def: true },
matugenTemplateKitty: { def: true },
@@ -329,7 +330,6 @@ var SPEC = {
showDock: { def: false },
dockAutoHide: { def: false },
dockSmartAutoHide: { def: false },
dockHideOnFullscreen: { def: true },
dockGroupByApp: { def: false },
dockRestoreSpecialWorkspaceOnClick: { def: false },
dockOpenOnOverview: { def: false },
@@ -354,9 +354,6 @@ var SPEC = {
dockMaxVisibleApps: { def: 0 },
dockMaxVisibleRunningApps: { def: 0 },
dockShowOverflowBadge: { def: true },
dockShowTrash: { def: false },
dockTrashFileManager: { def: "default" },
dockTrashCustomCommand: { def: "" },
notificationOverlayEnabled: { def: false },
notificationPopupShadowEnabled: { def: true },
@@ -432,9 +429,6 @@ var SPEC = {
updaterUseCustomCommand: { def: false },
updaterCustomCommand: { def: "" },
updaterTerminalAdditionalParams: { def: "" },
updaterIntervalSeconds: { def: 1800 },
updaterIncludeFlatpak: { def: true },
updaterAllowAUR: { def: true },
displayNameMode: { def: "system" },
screenPreferences: { def: {} },
@@ -447,7 +441,6 @@ var SPEC = {
displayShowDisconnected: { def: false },
displaySnapToEdge: { def: true },
connectedFrameBarStyleBackups: { def: {} },
connectedFrameModalDarkenBackup: { def: null },
barConfigs: {
def: [{
@@ -492,7 +485,6 @@ var SPEC = {
popupGapsAuto: true,
popupGapsManual: 4,
maximizeDetection: true,
fullscreenDetection: true,
scrollEnabled: true,
scrollXBehavior: "column",
scrollYBehavior: "workspace",
@@ -566,10 +558,8 @@ var SPEC = {
frameBarSize: { def: 40 },
frameShowOnOverview: { def: false },
frameBlurEnabled: { def: true },
frameCloseGaps: { def: true },
frameLauncherEmergeSide: { def: "bottom" },
frameLauncherArcExtender: { def: false },
frameMode: { def: "separate" }
frameCloseGaps: { def: false },
frameLauncherEmergeSide: { def: "bottom" }
};
function getValidKeys() {

View File

@@ -4,7 +4,6 @@ import qs.Common
import qs.Modals
import qs.Modals.Changelog
import qs.Modals.Clipboard
import qs.Modals.Common
import qs.Modals.Greeter
import qs.Modals.Settings
import qs.Modals.DankLauncherV2
@@ -24,12 +23,10 @@ import qs.Modules.DankBar
import qs.Modules.DankBar.Popouts
import qs.Modules.Frame
import qs.Modules.WorkspaceOverlays
import qs.Modules.Settings.DisplayConfig
import qs.Services
Item {
id: root
readonly property var log: Log.scoped("DMSShell")
property bool osdSurfacesLoaded: true
property int pendingOsdResumeReloads: 0
@@ -57,7 +54,7 @@ Item {
item.popoutService = PopoutService;
}
item.pluginId = pluginId;
log.info("Daemon plugin loaded:", pluginId);
console.info("Daemon plugin loaded:", pluginId);
}
}
}
@@ -96,7 +93,7 @@ Item {
}
onFadeCancelled: {
log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
console.log("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
}
}
@@ -136,7 +133,7 @@ Item {
}
onFadeCancelled: {
log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
console.log("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
}
}
@@ -165,22 +162,7 @@ Item {
}
}
property bool barSurfacesLoaded: true
function recreateBarSurfaces() {
if (barSurfacesLoaded)
barSurfacesLoaded = false;
barSurfaceReloadAction.schedule();
}
DeferredAction {
id: barSurfaceReloadAction
onTriggered: root.barSurfacesLoaded = true
}
property string _barLayoutStateJson: {
if (!barSurfacesLoaded)
return "[]";
const configs = SettingsData.barConfigs;
const mapped = configs.map(c => ({
id: c.id,
@@ -204,19 +186,6 @@ Item {
}
}
Connections {
target: SettingsData
function onFrameEnabledChanged() {
root.recreateBarSurfaces();
}
function onConnectedFrameModeActiveChanged() {
root.recreateBarSurfaces();
}
function onForceDankBarLayoutRefresh() {
root.recreateBarSurfaces();
}
}
Frame {}
Repeater {
@@ -232,7 +201,7 @@ Item {
id: barLoader
required property var modelData
property var barConfig: SettingsData.barConfigs.find(cfg => cfg.id === modelData.id) || null
active: root.barSurfacesLoaded && (barConfig?.enabled ?? false)
active: barConfig?.enabled ?? false
asynchronous: false
sourceComponent: DankBar {
@@ -305,8 +274,6 @@ Item {
dockRecreateDebounce.start();
// Force PolkitService singleton to initialize
PolkitService.polkitAvailable;
// Force DisplayConfigState singleton to initialize so auto-config runs at startup
DisplayConfigState.hasOutputBackend;
loginSoundTimer.start();
}
@@ -320,15 +287,11 @@ Item {
sourceComponent: Dock {
contextMenu: dockContextMenuLoader.item ? dockContextMenuLoader.item : null
trashContextMenu: dockTrashContextMenuLoader.item ? dockTrashContextMenuLoader.item : null
}
onLoaded: {
if (item) {
dockContextMenuLoader.active = true;
if (SettingsData.dockShowTrash) {
dockTrashContextMenuLoader.active = true;
}
}
}
@@ -365,6 +328,7 @@ Item {
sourceComponent: Component {
DankDashPopout {
id: dankDashPopout
onPopoutClosed: PopoutService.unloadDankDash()
}
}
}
@@ -379,43 +343,6 @@ 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 {
id: notificationCenterLoader
@@ -807,7 +734,7 @@ Item {
cmd += " " + escapedPath;
}
log.debug("FilePicker: Launching", cmd);
console.log("FilePicker: Launching", cmd);
Quickshell.execDetached({
command: ["sh", "-c", cmd]
@@ -839,10 +766,10 @@ Item {
}
function onAppPickerRequested(data) {
log.debug("App picker requested with data:", JSON.stringify(data));
console.log("DMSShell: App picker requested with data:", JSON.stringify(data));
if (!data || !data.target) {
log.warn("Invalid app picker request data");
console.warn("DMSShell: Invalid app picker request data");
return;
}
@@ -911,19 +838,10 @@ Item {
ProcessListModal {
id: processListModal
property bool wasShown: false
Component.onCompleted: {
PopoutService.processListModal = processListModal;
}
onVisibleChanged: {
if (visible) {
wasShown = true;
} else if (wasShown) {
PopoutService.unloadProcessListModal();
}
}
}
}
@@ -938,12 +856,7 @@ Item {
SystemUpdatePopout {
id: systemUpdatePopout
onPopoutClosed: {
if (systemUpdatePopout._reopenAfterUpgrade) {
return;
}
PopoutService.unloadSystemUpdate();
}
onPopoutClosed: PopoutService.unloadSystemUpdate()
Component.onCompleted: {
PopoutService.systemUpdatePopout = systemUpdatePopout;
@@ -1140,6 +1053,12 @@ Item {
}
}
Loader {
id: powerProfileWatcherLoader
active: SettingsData.osdPowerProfileEnabled
source: "Services/PowerProfileWatcher.qml"
}
LazyLoader {
id: hyprlandOverviewLoader
active: CompositorService.isHyprland

View File

@@ -9,7 +9,6 @@ import qs.Modules.Settings.DisplayConfig
Item {
id: root
readonly property var log: Log.scoped("DMSShellIPC")
required property var powerMenuModalLoader
required property var processListModalLoader
@@ -162,36 +161,37 @@ Item {
}
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 {
const bar = root.getPreferredBar("clockButtonRef");
if (!bar)
return "DASH_OPEN_FAILED";
const tabIndex = resolveTabIndex(tab);
const dash = root.dankDashPopoutLoader.item;
if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) {
dash.currentTabIndex = tabIndex;
if (dash.updateSurfacePosition)
dash.updateSurfacePosition();
return "DASH_OPEN_SUCCESS";
const onSameScreen = dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name;
if (!onSameScreen) {
bar.triggerWallpaperBrowser();
}
if (!bar.triggerDashTab(tabIndex))
if (!root.dankDashPopoutLoader.item)
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";
}
@@ -211,8 +211,23 @@ Item {
const bar = root.getPreferredBar("clockButtonRef");
if (bar) {
if (!bar.triggerDashTab(resolveTabIndex(tab)))
return "DASH_TOGGLE_FAILED";
bar.triggerWallpaperBrowser();
if (root.dankDashPopoutLoader.item) {
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_FAILED";
@@ -846,7 +861,7 @@ Item {
function set(key: string, value: string): string {
if (!(key in SettingsData)) {
log.warn("Cannot set property, not found:", key);
console.warn("Cannot set property, not found:", key);
return "SETTINGS_INVALID_KEY";
}
@@ -879,12 +894,12 @@ Item {
throw "Unsupported type";
}
log.warn("Setting:", key, value);
console.warn("Setting:", key, value);
SettingsData[key] = value;
SettingsData.saveSettings();
return "SETTINGS_SET_SUCCESS";
} catch (e) {
log.warn("Failed to set property:", key, "error:", e);
console.warn("Failed to set property:", key, "error:", e);
return "SETTINGS_SET_FAILURE";
}
}
@@ -1622,15 +1637,13 @@ Item {
for (const id in profiles) {
const p = profiles[id];
if (!p.name)
continue;
const flags = [];
if (id === activeId)
flags.push("active");
if (id === matchedId)
flags.push("matched");
const flagStr = flags.length > 0 ? " [" + flags.join(",") + "]" : "";
lines.push(p.name + flagStr + " -> " + JSON.stringify(Object.keys(p.outputs)));
lines.push(p.name + flagStr + " -> " + JSON.stringify(p.outputSet));
}
if (lines.length === 0)
@@ -1662,16 +1675,13 @@ Item {
return `PROFILE_SET_SUCCESS: ${profileName}`;
}
// ! TODO - auto profile switching is buggy on niri and other compositors
function toggleAuto(): string {
SettingsData.displayProfileAutoSelect = !SettingsData.displayProfileAutoSelect;
SettingsData.saveSettings();
if (SettingsData.displayProfileAutoSelect)
DisplayConfigState.applyAutoConfig();
return `Auto profile selection: ${SettingsData.displayProfileAutoSelect ? "enabled" : "disabled"}`;
return "ERROR: Auto profile selection is temporarily disabled due to compositor bugs";
}
function status(): string {
const auto = SettingsData.displayProfileAutoSelect ? "on" : "off";
const auto = "off"; // disabled for now
const activeId = SettingsData.getActiveDisplayProfile(CompositorService.compositor);
const matchedId = DisplayConfigState.matchedProfile;
const profiles = DisplayConfigState.validatedProfiles;

View File

@@ -1,4 +1,5 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Modals.Common
import qs.Widgets
@@ -6,7 +7,6 @@ import qs.Services
DankModal {
id: root
readonly property var log: Log.scoped("AppPickerModal")
property string title: I18n.tr("Select Application")
property string targetData: ""
@@ -30,52 +30,52 @@ DankModal {
onBackgroundClicked: close()
onDialogClosed: {
searchQuery = "";
selectedIndex = 0;
keyboardNavigationActive = false;
searchQuery = ""
selectedIndex = 0
keyboardNavigationActive = false
}
onOpened: {
searchQuery = "";
updateApplicationList();
selectedIndex = 0;
searchQuery = ""
updateApplicationList()
selectedIndex = 0
Qt.callLater(() => {
if (contentLoader.item && contentLoader.item.searchField) {
contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus();
contentLoader.item.searchField.text = ""
contentLoader.item.searchField.forceActiveFocus()
}
});
})
}
function updateApplicationList() {
applicationsModel.clear();
const apps = AppSearchService.applications;
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {};
let filteredApps = [];
applicationsModel.clear()
const apps = AppSearchService.applications
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {}
let filteredApps = []
for (const app of apps) {
if (!app || !app.categories)
continue;
let matchesCategory = categoryFilter.length === 0;
if (!app || !app.categories) continue
let matchesCategory = categoryFilter.length === 0
if (categoryFilter.length > 0) {
try {
for (const cat of app.categories) {
if (categoryFilter.includes(cat)) {
matchesCategory = true;
break;
matchesCategory = true
break
}
}
} catch (e) {
log.warn("AppPicker: Error iterating categories for", app.name, ":", e);
continue;
console.warn("AppPicker: Error iterating categories for", app.name, ":", e)
continue
}
}
if (matchesCategory) {
const name = app.name || "";
const lowerName = name.toLowerCase();
const lowerQuery = searchQuery.toLowerCase();
const name = app.name || ""
const lowerName = name.toLowerCase()
const lowerQuery = searchQuery.toLowerCase()
if (searchQuery === "" || lowerName.includes(lowerQuery)) {
filteredApps.push({
@@ -84,21 +84,21 @@ DankModal {
exec: app.exec || app.execString || "",
startupClass: app.startupWMClass || "",
appData: app
});
})
}
}
}
filteredApps.sort((a, b) => {
const aId = a.appData.id || a.appData.execString || a.appData.exec || "";
const bId = b.appData.id || b.appData.execString || b.appData.exec || "";
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0;
const bUsage = usageHistory[bId] ? usageHistory[bId].count : 0;
const aId = a.appData.id || a.appData.execString || a.appData.exec || ""
const bId = b.appData.id || b.appData.execString || b.appData.exec || ""
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0
const bUsage = usageHistory[bId] ? usageHistory[bId].count : 0
if (aUsage !== bUsage) {
return bUsage - aUsage;
return bUsage - aUsage
}
return (a.name || "").localeCompare(b.name || "");
});
return (a.name || "").localeCompare(b.name || "")
})
filteredApps.forEach(app => {
applicationsModel.append({
@@ -107,10 +107,10 @@ DankModal {
exec: app.exec,
startupClass: app.startupClass,
appId: app.appData.id || app.appData.execString || app.appData.exec || ""
});
});
})
})
log.debug("AppPicker: Found " + filteredApps.length + " applications");
console.log("AppPicker: Found " + filteredApps.length + " applications")
}
onSearchQueryChanged: updateApplicationList()
@@ -129,57 +129,56 @@ DankModal {
focus: true
Keys.onEscapePressed: event => {
root.close();
event.accepted = true;
root.close()
event.accepted = true
}
Keys.onPressed: event => {
if (applicationsModel.count === 0)
return;
if (applicationsModel.count === 0) return
// Toggle view mode with Tab key
if (event.key === Qt.Key_Tab) {
root.viewMode = root.viewMode === "grid" ? "list" : "grid";
event.accepted = true;
return;
root.viewMode = root.viewMode === "grid" ? "list" : "grid"
event.accepted = true
return
}
if (root.viewMode === "grid") {
if (event.key === Qt.Key_Left) {
root.keyboardNavigationActive = true;
root.selectedIndex = Math.max(0, root.selectedIndex - 1);
event.accepted = true;
root.keyboardNavigationActive = true
root.selectedIndex = Math.max(0, root.selectedIndex - 1)
event.accepted = true
} else if (event.key === Qt.Key_Right) {
root.keyboardNavigationActive = true;
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1);
event.accepted = true;
root.keyboardNavigationActive = true
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
root.keyboardNavigationActive = true;
root.selectedIndex = Math.max(0, root.selectedIndex - root.gridColumns);
event.accepted = true;
root.keyboardNavigationActive = true
root.selectedIndex = Math.max(0, root.selectedIndex - root.gridColumns)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.keyboardNavigationActive = true;
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + root.gridColumns);
event.accepted = true;
root.keyboardNavigationActive = true
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + root.gridColumns)
event.accepted = true
}
} else {
if (event.key === Qt.Key_Up) {
root.keyboardNavigationActive = true;
root.selectedIndex = Math.max(0, root.selectedIndex - 1);
event.accepted = true;
root.keyboardNavigationActive = true
root.selectedIndex = Math.max(0, root.selectedIndex - 1)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.keyboardNavigationActive = true;
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1);
event.accepted = true;
root.keyboardNavigationActive = true
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1)
event.accepted = true
}
}
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.selectedIndex >= 0 && root.selectedIndex < applicationsModel.count) {
const app = applicationsModel.get(root.selectedIndex);
launchApplication(app);
const app = applicationsModel.get(root.selectedIndex)
launchApplication(app)
}
event.accepted = true;
event.accepted = true
}
}
@@ -218,7 +217,7 @@ DankModal {
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"
onClicked: {
root.viewMode = "list";
root.viewMode = "list"
}
}
@@ -230,7 +229,7 @@ DankModal {
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"
onClicked: {
root.viewMode = "grid";
root.viewMode = "grid"
}
}
}
@@ -258,42 +257,42 @@ DankModal {
keyForwardTargets: [appContent]
onTextEdited: {
root.searchQuery = text;
root.searchQuery = text
}
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Escape) {
root.close();
event.accepted = true;
return;
root.close()
event.accepted = true
return
}
const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key);
const hasText = text.length > 0;
const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key)
const hasText = text.length > 0
if (isEnterKey && hasText) {
if (root.keyboardNavigationActive && applicationsModel.count > 0) {
const app = applicationsModel.get(root.selectedIndex);
launchApplication(app);
const app = applicationsModel.get(root.selectedIndex)
launchApplication(app)
} else if (applicationsModel.count > 0) {
const app = applicationsModel.get(0);
launchApplication(app);
const app = applicationsModel.get(0)
launchApplication(app)
}
event.accepted = true;
return;
event.accepted = true
return
}
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 isEmptyEnter = isEnterKey && !hasText;
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 isEmptyEnter = isEnterKey && !hasText
event.accepted = !(isNavigationKey || isEmptyEnter);
event.accepted = !(isNavigationKey || isEmptyEnter)
}
Connections {
function onShouldBeVisibleChanged() {
if (!root.shouldBeVisible) {
searchField.focus = false;
searchField.focus = false
}
}
@@ -304,12 +303,12 @@ DankModal {
Rectangle {
width: parent.width
height: {
let usedHeight = 40 + Theme.spacingS;
usedHeight += 52 + Theme.spacingS;
let usedHeight = 40 + Theme.spacingS
usedHeight += 52 + Theme.spacingS
if (root.showTargetData) {
usedHeight += 36 + Theme.spacingS;
usedHeight += 36 + Theme.spacingS
}
return parent.height - usedHeight;
return parent.height - usedHeight
}
radius: Theme.cornerRadius
color: "transparent"
@@ -321,14 +320,14 @@ DankModal {
property int itemSpacing: Theme.spacingS
function ensureVisible(index) {
if (index < 0 || index >= count)
return;
const itemY = index * (itemHeight + itemSpacing);
const itemBottom = itemY + itemHeight;
if (index < 0 || index >= count) return
const itemY = index * (itemHeight + itemSpacing)
const itemBottom = itemY + itemHeight
if (itemY < contentY) {
contentY = itemY;
contentY = itemY
} else if (itemBottom > contentY + height) {
contentY = itemBottom - height;
contentY = itemBottom - height
}
}
@@ -344,9 +343,9 @@ DankModal {
spacing: itemSpacing
onCurrentIndexChanged: {
root.selectedIndex = currentIndex;
root.selectedIndex = currentIndex
if (root.keyboardNavigationActive) {
ensureVisible(currentIndex);
ensureVisible(currentIndex)
}
}
@@ -361,11 +360,11 @@ DankModal {
hoverUpdatesSelection: true
onItemClicked: (idx, modelData) => {
launchApplication(modelData);
launchApplication(modelData)
}
onKeyboardNavigationReset: {
root.keyboardNavigationActive = false;
root.keyboardNavigationActive = false
}
}
}
@@ -374,14 +373,14 @@ DankModal {
id: appGrid
function ensureVisible(index) {
if (index < 0 || index >= count)
return;
const itemY = Math.floor(index / root.gridColumns) * cellHeight;
const itemBottom = itemY + cellHeight;
if (index < 0 || index >= count) return
const itemY = Math.floor(index / root.gridColumns) * cellHeight
const itemBottom = itemY + cellHeight
if (itemY < contentY) {
contentY = itemY;
contentY = itemY
} else if (itemBottom > contentY + height) {
contentY = itemBottom - height;
contentY = itemBottom - height
}
}
@@ -398,9 +397,9 @@ DankModal {
currentIndex: root.selectedIndex
onCurrentIndexChanged: {
root.selectedIndex = currentIndex;
root.selectedIndex = currentIndex
if (root.keyboardNavigationActive) {
ensureVisible(currentIndex);
ensureVisible(currentIndex)
}
}
@@ -414,11 +413,11 @@ DankModal {
hoverUpdatesSelection: true
onItemClicked: (idx, modelData) => {
launchApplication(modelData);
launchApplication(modelData)
}
onKeyboardNavigationReset: {
root.keyboardNavigationActive = false;
root.keyboardNavigationActive = false
}
}
}
@@ -450,22 +449,22 @@ DankModal {
}
function launchApplication(app) {
if (!app)
return;
root.applicationSelected(app, root.targetData);
if (!app) return
root.applicationSelected(app, root.targetData)
if (usageHistoryKey && app.appId) {
const usageHistory = SettingsData[usageHistoryKey] || {};
const currentCount = usageHistory[app.appId] ? usageHistory[app.appId].count : 0;
const usageHistory = SettingsData[usageHistoryKey] || {}
const currentCount = usageHistory[app.appId] ? usageHistory[app.appId].count : 0
usageHistory[app.appId] = {
count: currentCount + 1,
lastUsed: Date.now(),
name: app.name
};
SettingsData.set(usageHistoryKey, usageHistory);
}
SettingsData.set(usageHistoryKey, usageHistory)
}
root.close();
root.close()
}
}
}

View File

@@ -7,7 +7,6 @@ import qs.Widgets
DankModal {
id: root
readonly property var log: Log.scoped("BluetoothPairingModal")
layerNamespace: "dms:bluetooth-pairing"
@@ -25,7 +24,7 @@ DankModal {
property string passkeyInput: ""
function show(pairingData) {
log.debug("BluetoothPairingModal.show() called:", JSON.stringify(pairingData));
console.log("BluetoothPairingModal.show() called:", JSON.stringify(pairingData));
token = pairingData.token || "";
deviceName = pairingData.deviceName || "";
deviceAddress = pairingData.deviceAddr || "";
@@ -34,7 +33,7 @@ DankModal {
pinInput = "";
passkeyInput = "";
log.debug("Calling open()");
console.log("BluetoothPairingModal: Calling open()");
open();
Qt.callLater(() => {
if (contentLoader.item) {

View File

@@ -2,11 +2,9 @@ import QtQuick
import Quickshell
import qs.Common
import qs.Modals
import qs.Services
AppPickerModal {
id: root
readonly property var log: Log.scoped("BrowserPickerModal")
property string url: ""
@@ -19,44 +17,35 @@ AppPickerModal {
showTargetData: true
function shellEscape(str) {
return "'" + str.replace(/'/g, "'\\''") + "'";
return "'" + str.replace(/'/g, "'\\''") + "'"
}
onApplicationSelected: (app, url) => {
if (!app)
return;
let cmd = app.exec || "";
const escapedUrl = shellEscape(url);
if (!app) return
let hasField = false;
if (cmd.includes("%u")) {
cmd = cmd.replace("%u", escapedUrl);
hasField = true;
} else if (cmd.includes("%U")) {
cmd = cmd.replace("%U", escapedUrl);
hasField = true;
} else if (cmd.includes("%f")) {
cmd = cmd.replace("%f", escapedUrl);
hasField = true;
} else if (cmd.includes("%F")) {
cmd = cmd.replace("%F", escapedUrl);
hasField = true;
}
let cmd = app.exec || ""
const escapedUrl = shellEscape(url)
cmd = cmd.replace(/%[ikc]/g, "");
let hasField = false
if (cmd.includes("%u")) { cmd = cmd.replace("%u", escapedUrl); hasField = true }
else if (cmd.includes("%U")) { cmd = cmd.replace("%U", escapedUrl); hasField = true }
else if (cmd.includes("%f")) { cmd = cmd.replace("%f", escapedUrl); hasField = true }
else if (cmd.includes("%F")) { cmd = cmd.replace("%F", escapedUrl); hasField = true }
cmd = cmd.replace(/%[ikc]/g, "")
if (!hasField) {
cmd += " " + escapedUrl;
cmd += " " + escapedUrl
}
log.debug("BrowserPicker: Launching", cmd);
console.log("BrowserPicker: Launching", cmd)
Quickshell.execDetached({
command: ["sh", "-c", cmd]
});
})
}
onViewModeChanged: {
SettingsData.set("browserPickerViewMode", viewMode);
SettingsData.set("browserPickerViewMode", viewMode)
}
}

View File

@@ -26,7 +26,9 @@ Rectangle {
spacing: 2
StyledText {
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help")
text: keyboardHints.enterToPaste
? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled")
: I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter

View File

@@ -6,7 +6,6 @@ import qs.Widgets
Item {
id: thumbnail
readonly property var log: Log.scoped("ClipboardThumbnail")
required property var entry
required property string entryType
@@ -53,7 +52,7 @@ Item {
modal.activeImageLoads--;
}
if (response.error) {
log.warn("Failed to load image:", entry.id);
console.warn("ClipboardThumbnail: Failed to load image:", entry.id);
return;
}
const data = response.result?.data;

View File

@@ -4,7 +4,6 @@ import qs.Services
Item {
id: root
readonly property var log: Log.scoped("DankModal")
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
property string layerNamespace: "dms:modal"
@@ -84,31 +83,9 @@ Item {
impl.item.toggle();
}
readonly property var _desiredBackend: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp
property var _resolvedBackend: null
Component.onCompleted: _resolvedBackend = _desiredBackend
Connections {
target: SettingsData
function onConnectedFrameModeActiveChanged() {
root._maybeResolveBackend();
}
}
// Defer Loader source-component swap until impl is fully closed; avoids
// tearing down a modal mid-animation when frame mode is toggled.
function _maybeResolveBackend() {
if (_resolvedBackend === _desiredBackend)
return;
if (impl.item && (impl.item.shouldBeVisible || impl.item.isClosing))
return;
_resolvedBackend = _desiredBackend;
}
Loader {
id: impl
sourceComponent: root._resolvedBackend
sourceComponent: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp
onItemChanged: if (item)
root._wireBackend(item)
}
@@ -159,7 +136,20 @@ Item {
it.useOverlayLayer = Qt.binding(() => root.useOverlayLayer);
it.shouldBeVisible = root.shouldBeVisible;
it.shouldBeVisibleChanged.connect(function () {
if (root.shouldBeVisible !== it.shouldBeVisible)
root.shouldBeVisible = it.shouldBeVisible;
});
it.shouldHaveFocus = root.shouldHaveFocus;
it.shouldHaveFocusChanged.connect(function () {
if (root.shouldHaveFocus !== it.shouldHaveFocus)
root.shouldHaveFocus = it.shouldHaveFocus;
});
it.opened.connect(root.opened);
it.dialogClosed.connect(root.dialogClosed);
it.backgroundClicked.connect(root.backgroundClicked);
if (it.modalFocusScope)
_modalFocusScope.parent = it.modalFocusScope;
@@ -176,32 +166,4 @@ Item {
impl.item.shouldHaveFocus = root.shouldHaveFocus;
}
}
Connections {
target: impl.item
ignoreUnknownSignals: true
function onShouldBeVisibleChanged() {
if (impl.item && root.shouldBeVisible !== impl.item.shouldBeVisible)
root.shouldBeVisible = impl.item.shouldBeVisible;
}
function onShouldHaveFocusChanged() {
if (impl.item && root.shouldHaveFocus !== impl.item.shouldHaveFocus)
root.shouldHaveFocus = impl.item.shouldHaveFocus;
}
function onOpened() {
root.opened();
}
function onDialogClosed() {
root.dialogClosed();
root._maybeResolveBackend();
}
function onBackgroundClicked() {
root.backgroundClicked();
}
}
}

View File

@@ -1,5 +1,3 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
@@ -9,7 +7,6 @@ import qs.Widgets
Item {
id: root
readonly property var log: Log.scoped("DankModalConnected")
property var modalHandle: root
property string layerNamespace: "dms:modal"
@@ -38,7 +35,7 @@ Item {
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== ""
function _dockOccupiesSide(side) {
if (!SettingsData.showDock)
@@ -89,22 +86,16 @@ Item {
readonly property alias contentWindow: contentWindow
readonly property alias clickCatcher: clickCatcher
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
readonly property bool useBackground: false
readonly property bool useBackground: showBackground && SettingsData.modalDarkenBackground
readonly property bool useSingleWindow: CompositorService.isHyprland
signal opened
signal dialogClosed
signal backgroundClicked
// Coalesce per-channel dirty bits; one ConnectedModeState write per tick.
Timer {
id: _syncTimer
interval: 0
onTriggered: root._flushSync()
}
property bool animationsEnabled: true
// ─── Connected chrome sync ────────────────────────────────────────────────
property string _chromeClaimId: ""
property bool _fullSyncPending: false
@@ -148,37 +139,19 @@ Item {
ConnectedModeState.releaseDockRetract(_chromeClaimId);
}
property bool _animSyncQueued: false
property bool _bodySyncQueued: false
function _flushFullSync() {
_fullSyncPending = false;
_syncModalChromeState();
}
function _queueFullSync() {
if (_fullSyncPending)
return;
_fullSyncPending = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _queueAnimSync() {
_animSyncQueued = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _queueBodySync() {
_bodySyncQueued = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _flushSync() {
const fullDirty = _fullSyncPending;
const animDirty = _animSyncQueued;
const bodyDirty = _bodySyncQueued;
_fullSyncPending = false;
_animSyncQueued = false;
_bodySyncQueued = false;
if (fullDirty)
_syncModalChromeState();
if (animDirty)
_syncModalAnim();
if (bodyDirty)
_syncModalBody();
Qt.callLater(() => {
if (root && typeof root._flushFullSync === "function")
root._flushFullSync();
});
}
function _syncModalAnim() {
@@ -200,10 +173,10 @@ Item {
}
function _releaseModalChrome() {
if (!_chromeClaimId)
return;
ConnectedModeState.releaseDockRetract(_chromeClaimId);
_chromeClaimId = "";
if (_chromeClaimId) {
ConnectedModeState.releaseDockRetract(_chromeClaimId);
_chromeClaimId = "";
}
const screenName = _currentScreenName();
if (screenName)
ConnectedModeState.clearModalState(screenName);
@@ -212,10 +185,10 @@ Item {
onFrameOwnsConnectedChromeChanged: _syncModalChromeState()
onResolvedConnectedBarSideChanged: _queueFullSync()
onShouldBeVisibleChanged: _queueFullSync()
onAlignedXChanged: _queueBodySync()
onAlignedYChanged: _queueBodySync()
onAlignedWidthChanged: _queueBodySync()
onAlignedHeightChanged: _queueBodySync()
onAlignedXChanged: _syncModalBody()
onAlignedYChanged: _syncModalBody()
onAlignedWidthChanged: _syncModalBody()
onAlignedHeightChanged: _syncModalBody()
Component.onDestruction: _releaseModalChrome()
@@ -338,6 +311,8 @@ Item {
readonly property real shadowMotionPadding: {
if (Theme.isConnectedEffect)
return 0;
if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0 && Theme.isDirectionalEffect)
return 0; // Wayland native overlap mask
if (animationType === "slide")
return 30;
if (Theme.isDirectionalEffect)
@@ -481,8 +456,8 @@ Item {
readonly property real s: Math.min(1, modalContainer.scaleValue)
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr)
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr)
blurWidth: (root.shouldBeVisible && !root.frameOwnsConnectedChrome) ? modalContainer.width * s : 0
blurHeight: (root.shouldBeVisible && !root.frameOwnsConnectedChrome) ? modalContainer.height * s : 0
blurWidth: (root.shouldBeVisible && animatedContent.opacity > 0 && !root.frameOwnsConnectedChrome) ? modalContainer.width * s : 0
blurHeight: (root.shouldBeVisible && animatedContent.opacity > 0 && !root.frameOwnsConnectedChrome) ? modalContainer.height * s : 0
blurRadius: root.effectiveCornerRadius
}
@@ -492,10 +467,10 @@ Item {
return WlrLayershell.Overlay;
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
console.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
console.error("DankModal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
@@ -611,6 +586,8 @@ Item {
}
return 0;
}
if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect)
return 0;
if (slide && !directionalEffect && !depthEffect)
return 15;
if (directionalEffect) {
@@ -653,6 +630,8 @@ Item {
}
return 0;
}
if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect)
return 0;
if (slide && !directionalEffect && !depthEffect)
return -30;
if (directionalEffect) {
@@ -687,30 +666,43 @@ Item {
return root.animationOffset;
}
readonly property real computedScaleCollapsed: root.animationScaleCollapsed
property real animX: root.shouldBeVisible ? 0 : root.frozenMotionOffsetX
property real animY: root.shouldBeVisible ? 0 : root.frozenMotionOffsetY
// openProgress: 0 = closed (at frozenMotionOffset, scaleCollapsed), 1 = open (at 0, scale 1).
QtObject {
id: morph
property real openProgress: root.shouldBeVisible ? 1 : 0
Behavior on openProgress {
enabled: root.animationsEnabled
NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
onAnimXChanged: if (root.frameOwnsConnectedChrome)
root._syncModalAnim()
onAnimYChanged: if (root.frameOwnsConnectedChrome)
root._syncModalAnim()
readonly property real computedScaleCollapsed: (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) ? 0.0 : root.animationScaleCollapsed
property real scaleValue: root.shouldBeVisible ? 1.0 : computedScaleCollapsed
Behavior on animX {
enabled: root.animationsEnabled
NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
readonly property real animX: root.frozenMotionOffsetX * (1 - morph.openProgress)
readonly property real animY: root.frozenMotionOffsetY * (1 - morph.openProgress)
readonly property real scaleValue: computedScaleCollapsed + (1.0 - computedScaleCollapsed) * morph.openProgress
Behavior on animY {
enabled: root.animationsEnabled
NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
onAnimXChanged: if (root.frameOwnsConnectedChrome)
root._queueAnimSync()
onAnimYChanged: if (root.frameOwnsConnectedChrome)
root._queueAnimSync()
Behavior on scaleValue {
enabled: root.animationsEnabled
NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Item {
id: contentContainer
@@ -723,9 +715,6 @@ Item {
id: animatedContent
anchors.fill: parent
clip: false
property real publishedOpacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (root.shouldBeVisible ? 1 : 0)
opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (root.shouldBeVisible ? 1 : 0)
scale: modalContainer.scaleValue
transformOrigin: Item.Center
@@ -739,15 +728,6 @@ Item {
}
}
Behavior on publishedOpacity {
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
NumberAnimation {
duration: Math.round(Theme.variantDuration(animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
ElevationShadow {
id: modalShadowLayer
anchors.fill: parent

View File

@@ -1,5 +1,3 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
@@ -9,7 +7,6 @@ import qs.Widgets
Item {
id: root
readonly property var log: Log.scoped("DankModalStandalone")
property var modalHandle: root
property string layerNamespace: "dms:modal"
@@ -244,8 +241,8 @@ Item {
readonly property real s: Math.min(1, modalContainer.scaleValue)
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr)
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr)
blurWidth: shouldBeVisible ? modalContainer.width * s : 0
blurHeight: shouldBeVisible ? modalContainer.height * s : 0
blurWidth: (shouldBeVisible && animatedContent.opacity > 0) ? modalContainer.width * s : 0
blurHeight: (shouldBeVisible && animatedContent.opacity > 0) ? modalContainer.height * s : 0
blurRadius: root.cornerRadius
}
@@ -255,10 +252,10 @@ Item {
return WlrLayershell.Overlay;
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
console.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
console.error("DankModal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
@@ -321,8 +318,7 @@ Item {
Behavior on opacity {
enabled: root.animationsEnabled
NumberAnimation {
easing.type: Easing.BezierSpline
DankAnim {
duration: root.animationDuration
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
@@ -351,22 +347,45 @@ Item {
readonly property real offsetX: slide ? 15 : 0
readonly property real offsetY: slide ? -30 : root.animationOffset
// openProgress: 0 = closed (at offset, scaleCollapsed), 1 = open (at 0, scale 1).
QtObject {
id: morph
property real openProgress: root.shouldBeVisible ? 1 : 0
Behavior on openProgress {
enabled: root.animationsEnabled
DankAnim {
duration: root.animationDuration
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
property real animX: 0
property real animY: 0
property real scaleValue: root.animationScaleCollapsed
onOffsetXChanged: animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr)
onOffsetYChanged: animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr)
Connections {
target: root
function onShouldBeVisibleChanged() {
modalContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetX, root.dpr);
modalContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetY, root.dpr);
modalContainer.scaleValue = root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed;
}
}
readonly property real animX: modalContainer.offsetX * (1 - morph.openProgress)
readonly property real animY: modalContainer.offsetY * (1 - morph.openProgress)
readonly property real scaleValue: root.animationScaleCollapsed + (1.0 - root.animationScaleCollapsed) * morph.openProgress
Behavior on animX {
enabled: root.animationsEnabled
DankAnim {
duration: root.animationDuration
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Behavior on animY {
enabled: root.animationsEnabled
DankAnim {
duration: root.animationDuration
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Behavior on scaleValue {
enabled: root.animationsEnabled
DankAnim {
duration: root.animationDuration
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Item {
id: contentContainer
@@ -379,7 +398,6 @@ Item {
id: animatedContent
anchors.fill: parent
clip: false
opacity: root.shouldBeVisible ? 1 : 0
scale: modalContainer.scaleValue
x: Theme.snap(modalContainer.animX, root.dpr) + (parent.width - width) * (1 - modalContainer.scaleValue) * 0.5

View File

@@ -9,7 +9,6 @@ import qs.Widgets
DankModal {
id: root
readonly property var log: Log.scoped("DankColorPickerModal")
layerNamespace: "dms:color-picker"
@@ -51,17 +50,17 @@ DankModal {
function toggle() {
if (shouldBeVisible) {
hide();
hide();
} else {
show();
show();
}
}
function toggleInstant() {
if (shouldBeVisible) {
hideInstant();
hideInstant();
} else {
show();
show();
}
}
@@ -112,7 +111,7 @@ DankModal {
hideInstant();
Proc.runCommand("dms-color-pick", ["dms", "color", "pick", "--json"], (output, exitCode) => {
if (exitCode !== 0) {
log.warn("dms color pick exited with code:", exitCode);
console.warn("dms color pick exited with code:", exitCode);
root.show();
return;
}
@@ -121,11 +120,11 @@ DankModal {
if (result.hex) {
applyPickedColor(result.hex);
} else {
log.warn("Failed to parse dms color pick output: missing hex");
console.warn("Failed to parse dms color pick output: missing hex");
root.show();
}
} catch (e) {
log.warn("Failed to parse dms color pick JSON:", e);
console.warn("Failed to parse dms color pick JSON:", e);
root.show();
}
}, 0, Proc.noTimeout);
@@ -143,39 +142,39 @@ DankModal {
onBackgroundClicked: hide()
IpcHandler {
function open(): string {
root.show();
return "COLOR_PICKER_MODAL_OPEN_SUCCESS";
}
function open(): string {
root.show();
return "COLOR_PICKER_MODAL_OPEN_SUCCESS";
}
function openColor(color: string): string {
root.selectedColor = Qt.color(color);
root.currentColor = Qt.color(color);
root.updateFromColor(Qt.color(color));
return open();
}
function openColor(color: string): string {
root.selectedColor = Qt.color(color);
root.currentColor = Qt.color(color);
root.updateFromColor(Qt.color(color));
return open();
}
function close(): string {
root.hide();
return "COLOR_PICKER_MODAL_CLOSE_SUCCESS";
}
function close(): string {
root.hide();
return "COLOR_PICKER_MODAL_CLOSE_SUCCESS";
}
function closeInstant(): string {
root.hideInstant();
return "COLOR_PICKER_MODAL_CLOSE_INSTANT_SUCCESS";
}
function closeInstant(): string {
root.hideInstant();
return "COLOR_PICKER_MODAL_CLOSE_INSTANT_SUCCESS";
}
function toggle(): string {
root.toggle();
return "COLOR_PICKER_MODAL_TOGGLE_SUCCESS";
}
function toggle(): string {
root.toggle();
return "COLOR_PICKER_MODAL_TOGGLE_SUCCESS";
}
function toggleInstant(): string {
root.toggleInstant();
return "COLOR_PICKER_MODAL_TOGGLE_INSTANT_SUCCESS";
}
function toggleInstant(): string {
root.toggleInstant();
return "COLOR_PICKER_MODAL_TOGGLE_INSTANT_SUCCESS";
}
target: "color-picker"
target: "color-picker"
}
content: Component {

View File

@@ -1881,7 +1881,7 @@ Item {
function openTerminal(path) {
if (!path)
return;
var terminal = SessionData.resolveTerminal() || "xterm";
var terminal = Quickshell.env("TERMINAL") || "xterm";
Quickshell.execDetached({
command: [terminal],
workingDirectory: path

View File

@@ -1,10 +1,8 @@
import QtQuick
import qs.Common
import qs.Services
Item {
id: root
readonly property var log: Log.scoped("DankLauncherV2Modal")
readonly property bool spotlightOpen: impl.item ? impl.item.spotlightOpen : false
readonly property bool isClosing: impl.item ? impl.item.isClosing : false
@@ -22,7 +20,6 @@ Item {
readonly property real modalY: impl.item ? impl.item.modalY : 0
readonly property bool frameOwnsConnectedChrome: impl.item ? (impl.item.frameOwnsConnectedChrome ?? false) : false
readonly property string resolvedConnectedBarSide: impl.item ? (impl.item.resolvedConnectedBarSide ?? "") : ""
readonly property bool launcherArcExtenderActive: impl.item ? (impl.item.launcherArcExtenderActive ?? false) : false
signal dialogClosed
@@ -61,31 +58,9 @@ Item {
impl.item.toggleWithMode(mode);
}
readonly property var _desiredBackend: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp
property var _resolvedBackend: null
Component.onCompleted: _resolvedBackend = _desiredBackend
Connections {
target: SettingsData
function onConnectedFrameModeActiveChanged() {
root._maybeResolveBackend();
}
}
// Defer Loader source-component swap until impl is fully closed; avoids
// tearing down the launcher mid-animation when frame mode is toggled.
function _maybeResolveBackend() {
if (_resolvedBackend === _desiredBackend)
return;
if (impl.item && (impl.item.spotlightOpen || impl.item.isClosing))
return;
_resolvedBackend = _desiredBackend;
}
Loader {
id: impl
sourceComponent: root._resolvedBackend
sourceComponent: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp
onItemChanged: if (item)
root._wireBackend(item)
}
@@ -104,15 +79,6 @@ Item {
if (!it)
return;
it.modalHandle = root;
}
Connections {
target: impl.item
ignoreUnknownSignals: true
function onDialogClosed() {
root.dialogClosed();
root._maybeResolveBackend();
}
it.dialogClosed.connect(root.dialogClosed);
}
}

View File

@@ -1,5 +1,3 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
@@ -10,7 +8,6 @@ import qs.Widgets
Item {
id: root
readonly property var log: Log.scoped("DankLauncherV2ModalConnected")
property var modalHandle: root
@@ -75,7 +72,6 @@ Item {
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== ""
readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
function _dockOccupiesSide(side) {
if (!SettingsData.showDock)
@@ -113,13 +109,10 @@ Item {
{
const insetL = _frameEdgeInset("left");
const insetR = _frameEdgeInset("right");
const insetT = _frameEdgeInset("top");
const insetB = _frameEdgeInset("bottom");
const usable = Math.max(0, screenWidth - insetL - insetR);
const usableH = Math.max(0, screenHeight - insetT - insetB);
return {
"x": insetL + Math.max(0, (usable - modalWidth) / 2),
"y": launcherArcExtenderActive ? insetT + Math.max(0, (usableH - modalHeight) / 2) : (resolvedConnectedBarSide === "top" ? insetT : screenHeight - modalHeight - insetB)
"y": resolvedConnectedBarSide === "top" ? _frameEdgeInset("top") : screenHeight - modalHeight - _frameEdgeInset("bottom")
};
}
case "left":
@@ -177,34 +170,15 @@ Item {
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
readonly property real alignedX: Theme.snap(modalX, dpr)
readonly property real alignedY: Theme.snap(modalY, dpr)
readonly property real _connectedChromeX: alignedX
readonly property real _connectedChromeY: {
if (!launcherArcExtenderActive)
return alignedY;
return resolvedConnectedBarSide === "top" ? Theme.snap(_frameEdgeInset("top"), dpr) : alignedY;
}
readonly property real _connectedChromeWidth: alignedWidth
readonly property real _connectedChromeHeight: {
if (!launcherArcExtenderActive)
return alignedHeight;
if (resolvedConnectedBarSide === "top")
return Theme.snap(Math.max(alignedHeight, alignedY + alignedHeight - _frameEdgeInset("top")), dpr);
if (resolvedConnectedBarSide === "bottom")
return Theme.snap(Math.max(alignedHeight, screenHeight - _frameEdgeInset("bottom") - alignedY), dpr);
return alignedHeight;
}
readonly property real contentSurfaceHeight: launcherArcExtenderActive ? _connectedChromeHeight : alignedHeight
// For directional/depth: window extends from screen top (content slides within)
// For standard: small window tightly around the modal + shadow padding
readonly property bool _needsExtendedWindow: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) || Theme.isDepthEffect
// Content window geometry
readonly property real _cwMarginLeft: Theme.snap(alignedX - shadowPad, dpr)
readonly property real _cwMarginTop: launcherArcExtenderActive ? _connectedChromeY : (_needsExtendedWindow ? 0 : Theme.snap(alignedY - shadowPad, dpr))
readonly property real _cwMarginTop: _needsExtendedWindow ? 0 : Theme.snap(alignedY - shadowPad, dpr)
readonly property real _cwWidth: alignedWidth + shadowPad * 2
readonly property real _cwHeight: {
if (launcherArcExtenderActive)
return _connectedChromeHeight;
if (Theme.isDirectionalEffect && !Theme.isConnectedEffect)
return screenHeight + shadowPad;
if (Theme.isDepthEffect)
@@ -213,17 +187,11 @@ Item {
}
// Where the content container sits inside the content window
readonly property real _ccX: shadowPad
readonly property real _ccY: launcherArcExtenderActive ? 0 : (_needsExtendedWindow ? alignedY : shadowPad)
readonly property real _ccY: _needsExtendedWindow ? alignedY : shadowPad
signal dialogClosed
// Coalesce per-channel dirty bits; one ConnectedModeState write per tick.
Timer {
id: _syncTimer
interval: 0
onTriggered: root._flushSync()
}
// ─── Connected chrome sync ────────────────────────────────────────────────
property string _chromeClaimId: ""
property bool _fullSyncPending: false
@@ -242,10 +210,10 @@ Item {
ConnectedModeState.setModalState(screenName, {
"visible": spotlightOpen || contentWindow.visible,
"barSide": resolvedConnectedBarSide,
"bodyX": _connectedChromeX,
"bodyY": _connectedChromeY,
"bodyW": _connectedChromeWidth,
"bodyH": _connectedChromeHeight,
"bodyX": alignedX,
"bodyY": alignedY,
"bodyW": alignedWidth,
"bodyH": alignedHeight,
"animX": contentContainer ? contentContainer.animX : 0,
"animY": contentContainer ? contentContainer.animY : 0,
"omitStartConnector": false,
@@ -267,37 +235,19 @@ Item {
ConnectedModeState.releaseDockRetract(_chromeClaimId);
}
property bool _animSyncQueued: false
property bool _bodySyncQueued: false
function _flushFullSync() {
_fullSyncPending = false;
_syncModalChromeState();
}
function _queueFullSync() {
if (_fullSyncPending)
return;
_fullSyncPending = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _queueAnimSync() {
_animSyncQueued = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _queueBodySync() {
_bodySyncQueued = true;
if (!_syncTimer.running)
_syncTimer.restart();
}
function _flushSync() {
const fullDirty = _fullSyncPending;
const animDirty = _animSyncQueued;
const bodyDirty = _bodySyncQueued;
_fullSyncPending = false;
_animSyncQueued = false;
_bodySyncQueued = false;
if (fullDirty)
_syncModalChromeState();
if (animDirty)
_syncModalAnim();
if (bodyDirty)
_syncModalBody();
Qt.callLater(() => {
if (root && typeof root._flushFullSync === "function")
root._flushFullSync();
});
}
function _syncModalAnim() {
@@ -315,7 +265,7 @@ Item {
const screenName = _currentScreenName();
if (!screenName)
return;
ConnectedModeState.setModalBody(screenName, _connectedChromeX, _connectedChromeY, _connectedChromeWidth, _connectedChromeHeight);
ConnectedModeState.setModalBody(screenName, alignedX, alignedY, alignedWidth, alignedHeight);
}
function _releaseModalChrome() {
@@ -329,13 +279,12 @@ Item {
}
onFrameOwnsConnectedChromeChanged: _syncModalChromeState()
onLauncherArcExtenderActiveChanged: _queueFullSync()
onResolvedConnectedBarSideChanged: _queueFullSync()
onSpotlightOpenChanged: _queueFullSync()
onAlignedXChanged: _queueBodySync()
onAlignedYChanged: _queueBodySync()
onAlignedWidthChanged: _queueBodySync()
onAlignedHeightChanged: _queueBodySync()
onAlignedXChanged: _syncModalBody()
onAlignedYChanged: _syncModalBody()
onAlignedWidthChanged: _syncModalBody()
onAlignedHeightChanged: _syncModalBody()
Component.onDestruction: _releaseModalChrome()
@@ -579,26 +528,22 @@ Item {
}
}
// ── Background window: fullscreen, handles darkening + click-to-dismiss ──
PanelWindow {
id: backgroundWindow
visible: false
color: "transparent"
readonly property real _topMargin: contentContainer.dockTop ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 0 ? Theme.px(42, root.dpr) : 0)
readonly property real _bottomMargin: contentContainer.dockBottom ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 1 ? Theme.px(42, root.dpr) : 0)
readonly property real _leftMargin: contentContainer.dockLeft ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 2 ? Theme.px(42, root.dpr) : 0)
readonly property real _rightMargin: contentContainer.dockRight ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 3 ? Theme.px(42, root.dpr) : 0)
WlrLayershell.namespace: "dms:spotlight:bg"
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.margins {
top: backgroundWindow._topMargin
bottom: backgroundWindow._bottomMargin
left: backgroundWindow._leftMargin
right: backgroundWindow._rightMargin
top: contentContainer.dockTop ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 0 ? Theme.px(42, root.dpr) : 0)
bottom: contentContainer.dockBottom ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 1 ? Theme.px(42, root.dpr) : 0)
left: contentContainer.dockLeft ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 2 ? Theme.px(42, root.dpr) : 0)
right: contentContainer.dockRight ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 3 ? Theme.px(42, root.dpr) : 0)
}
anchors {
@@ -610,11 +555,6 @@ Item {
mask: Region {
item: (spotlightOpen || isClosing) ? bgFullScreenMask : null
Region {
item: bgContentHole
intersection: Intersection.Subtract
}
}
Item {
@@ -622,21 +562,20 @@ Item {
anchors.fill: parent
}
Item {
id: bgContentHole
visible: false
x: root._cwMarginLeft + contentContainer.x - backgroundWindow._leftMargin
y: root._cwMarginTop + contentContainer.y - backgroundWindow._topMargin
width: root.alignedWidth
height: root.contentSurfaceHeight
}
Rectangle {
id: backgroundDarken
anchors.fill: parent
color: "black"
opacity: 0
visible: false
opacity: launcherMotionVisible && SettingsData.modalDarkenBackground ? 0.5 : 0
visible: launcherMotionVisible || opacity > 0
Behavior on opacity {
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
DankAnim {
duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve
}
}
}
MouseArea {
@@ -646,6 +585,7 @@ Item {
}
}
// ── Content window: SMALL, positioned with margins — only renders the modal area ──
PanelWindow {
id: contentWindow
visible: false
@@ -657,8 +597,8 @@ Item {
readonly property real s: Math.min(1, contentContainer.scaleValue)
blurX: root._ccX + root.alignedWidth * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr)
blurY: root._ccY + root.alignedHeight * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr)
blurWidth: (root.spotlightOpen || root.isClosing) && !root.frameOwnsConnectedChrome ? root.alignedWidth * s : 0
blurHeight: (root.spotlightOpen || root.isClosing) && !root.frameOwnsConnectedChrome ? root.alignedHeight * s : 0
blurWidth: (root.spotlightOpen || root.isClosing) && contentWrapper.opacity > 0 && !root.frameOwnsConnectedChrome ? root.alignedWidth * s : 0
blurHeight: (root.spotlightOpen || root.isClosing) && contentWrapper.opacity > 0 && !root.frameOwnsConnectedChrome ? root.alignedHeight * s : 0
blurRadius: root.cornerRadius
}
@@ -666,10 +606,10 @@ Item {
WlrLayershell.layer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
console.error("DankLauncherV2Modal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
console.error("DankLauncherV2Modal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
@@ -700,10 +640,10 @@ Item {
Item {
id: contentInputMask
visible: false
x: contentContainer.x
y: contentContainer.y
x: contentContainer.x + contentWrapper.x
y: contentContainer.y + contentWrapper.y
width: root.alignedWidth
height: root.contentSurfaceHeight
height: root.alignedHeight
}
Item {
@@ -714,7 +654,7 @@ Item {
x: root._ccX
y: root._ccY
width: root.alignedWidth
height: root.contentSurfaceHeight
height: root.alignedHeight
readonly property int dockEdge: typeof SettingsData !== "undefined" ? SettingsData.dockPosition : 1
readonly property bool dockTop: dockEdge === 0
@@ -727,7 +667,7 @@ Item {
readonly property bool directionalEffect: Theme.isDirectionalEffect
readonly property bool depthEffect: Theme.isDepthEffect
readonly property real _connectedTravelX: Math.max(Theme.effectAnimOffset, root.alignedWidth + Theme.spacingL)
readonly property real _connectedTravelY: root.launcherArcExtenderActive ? root._connectedChromeHeight : Math.max(Theme.effectAnimOffset, root.alignedHeight + Theme.spacingL)
readonly property real _connectedTravelY: Math.max(Theme.effectAnimOffset, root.alignedHeight + Theme.spacingL)
readonly property real collapsedMotionX: {
if (root.frameOwnsConnectedChrome) {
switch (root.resolvedConnectedBarSide) {
@@ -770,31 +710,43 @@ Item {
return -Math.max((root.shadowPad || 0) + Theme.effectAnimOffset, 40);
}
// openProgress: 0 = closed (at frozenMotion, scaleCollapsed), 1 = open (at 0, scale 1).
QtObject {
id: morph
property real openProgress: root._motionActive ? 1 : 0
Behavior on openProgress {
enabled: root.animationsEnabled
DankAnim {
duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive)
easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve
}
// Declarative bindings — snap applied at render layer (contentWrapper x/y)
property real animX: root._motionActive ? 0 : root._frozenMotionX
property real animY: root._motionActive ? 0 : root._frozenMotionY
property real scaleValue: root._motionActive ? 1.0 : (Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 ? Theme.effectScaleCollapsed : (Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed))
onAnimXChanged: if (root.frameOwnsConnectedChrome)
root._syncModalAnim()
onAnimYChanged: if (root.frameOwnsConnectedChrome)
root._syncModalAnim()
Behavior on animX {
enabled: root.animationsEnabled
DankAnim {
duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive)
easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve
}
}
readonly property real animX: root._frozenMotionX * (1 - morph.openProgress)
readonly property real animY: root._frozenMotionY * (1 - morph.openProgress)
readonly property real scaleValue: Theme.effectScaleCollapsed + (1.0 - Theme.effectScaleCollapsed) * morph.openProgress
Behavior on animY {
enabled: root.animationsEnabled
DankAnim {
duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive)
easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve
}
}
onAnimXChanged: if (root.frameOwnsConnectedChrome)
root._queueAnimSync()
onAnimYChanged: if (root.frameOwnsConnectedChrome)
root._queueAnimSync()
Behavior on scaleValue {
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2))
DankAnim {
duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive)
easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve
}
}
Item {
id: directionalClipMask
readonly property bool shouldClip: Theme.isDirectionalEffect
readonly property bool shouldClip: Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0
readonly property real clipOversize: 2000
clip: shouldClip
@@ -817,7 +769,7 @@ Item {
id: launcherShadowLayer
width: parent.width
height: parent.height
opacity: contentWrapper.publishedOpacity
opacity: contentWrapper.opacity
scale: contentWrapper.scale
x: contentWrapper.x
y: contentWrapper.y
@@ -835,44 +787,20 @@ Item {
id: contentWrapper
width: parent.width
height: parent.height
property bool _renderActive: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) || launcherMotionVisible
property real publishedOpacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (launcherMotionVisible ? 1 : 0)
opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (launcherMotionVisible ? 1 : 0)
visible: _renderActive
visible: opacity > 0
scale: contentContainer.scaleValue
x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
y: Theme.snap(contentContainer.animY + (parent.height - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
Behavior on opacity {
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
NumberAnimation {
easing.type: Easing.BezierSpline
DankAnim {
duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve
}
}
Behavior on publishedOpacity {
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
NumberAnimation {
easing.type: Easing.BezierSpline
duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve
onRunningChanged: if (!running && contentWrapper.publishedOpacity === 0)
contentWrapper._renderActive = false
}
}
Connections {
target: root
function onLauncherMotionVisibleChanged() {
if (root.launcherMotionVisible)
contentWrapper._renderActive = true;
}
}
MouseArea {
anchors.fill: parent
onPressed: mouse => mouse.accepted = true

View File

@@ -8,7 +8,6 @@ import qs.Widgets
Item {
id: root
readonly property var log: Log.scoped("DankLauncherV2ModalStandalone")
property var modalHandle: root
@@ -62,20 +61,6 @@ Item {
readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100)
readonly property real modalX: (screenWidth - modalWidth) / 2
readonly property real modalY: (screenHeight - modalHeight) / 2
readonly property var shadowLevel: Theme.elevationLevel3
readonly property real shadowFallbackOffset: 6
readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
readonly property real shadowPad: Theme.snap(shadowRenderPadding, dpr)
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
readonly property real alignedX: Theme.snap(modalX, dpr)
readonly property real alignedY: Theme.snap(modalY, dpr)
readonly property real windowX: Math.max(0, Theme.snap(alignedX - shadowPad, dpr))
readonly property real windowY: Math.max(0, Theme.snap(alignedY - shadowPad, dpr))
readonly property real contentX: Theme.snap(alignedX - windowX, dpr)
readonly property real contentY: Theme.snap(alignedY - windowY, dpr)
readonly property real windowWidth: alignedWidth + contentX + shadowPad
readonly property real windowHeight: alignedHeight + contentY + shadowPad
readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
readonly property real cornerRadius: Theme.cornerRadius
@@ -293,16 +278,37 @@ Item {
}
PanelWindow {
id: clickCatcher
screen: launcherWindow.screen
visible: spotlightOpen
id: launcherWindow
visible: spotlightOpen || isClosing
color: "transparent"
updatesEnabled: false
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "dms:spotlight:clickcatcher"
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WindowBlur {
targetWindow: launcherWindow
readonly property real s: Math.min(1, modalContainer.scale)
blurX: root.modalX + root.modalWidth * (1 - s) * 0.5
blurY: root.modalY + root.modalHeight * (1 - s) * 0.5
blurWidth: (contentVisible && modalContainer.opacity > 0) ? root.modalWidth * s : 0
blurHeight: (contentVisible && modalContainer.opacity > 0) ? root.modalHeight * s : 0
blurRadius: root.cornerRadius
}
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
console.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
console.error("DankModal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors {
top: true
@@ -312,154 +318,75 @@ Item {
}
mask: Region {
item: outsideClickMask
Region {
item: outsideClickHole
intersection: Intersection.Subtract
}
item: spotlightOpen ? fullScreenMask : null
}
Item {
id: outsideClickMask
visible: false
id: fullScreenMask
anchors.fill: parent
}
Rectangle {
id: outsideClickHole
visible: false
color: "transparent"
x: root.alignedX
y: root.alignedY
width: root.alignedWidth
height: root.alignedHeight
id: backgroundDarken
anchors.fill: parent
color: "black"
opacity: contentVisible && SettingsData.modalDarkenBackground ? 0.5 : 0
visible: contentVisible || opacity > 0
Behavior on opacity {
DankAnim {
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
}
}
}
MouseArea {
anchors.fill: parent
enabled: spotlightOpen
onClicked: root.hide()
}
}
onClicked: mouse => {
var contentX = modalContainer.x;
var contentY = modalContainer.y;
var contentW = modalContainer.width;
var contentH = modalContainer.height;
PanelWindow {
id: launcherWindow
visible: spotlightOpen || isClosing
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WindowBlur {
targetWindow: launcherWindow
readonly property real s: Math.min(1, modalContainer.publishedScale)
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5
blurWidth: contentVisible ? modalContainer.width * s : 0
blurHeight: contentVisible ? modalContainer.height * s : 0
blurRadius: root.cornerRadius
}
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
if (mouse.x < contentX || mouse.x > contentX + contentW || mouse.y < contentY || mouse.y > contentY + contentH) {
root.hide();
}
}
}
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors {
top: true
left: true
}
WlrLayershell.margins {
left: root.windowX
top: root.windowY
right: 0
bottom: 0
}
implicitWidth: root.windowWidth
implicitHeight: root.windowHeight
mask: Region {
item: launcherInputMask
}
Rectangle {
id: launcherInputMask
visible: false
color: "transparent"
x: modalContainer.x
y: modalContainer.y
width: modalContainer.width
height: modalContainer.height
}
Item {
id: modalContainer
x: root.contentX
y: root.contentY
width: root.alignedWidth
height: root.alignedHeight
visible: _renderActive
property bool _renderActive: contentVisible
property real publishedScale: contentVisible ? 1 : 0.96
x: root.modalX
y: root.modalY
width: root.modalWidth
height: root.modalHeight
visible: contentVisible || opacity > 0
opacity: contentVisible ? 1 : 0
scale: contentVisible ? 1 : 0.96
transformOrigin: Item.Center
Behavior on opacity {
NumberAnimation {
easing.type: Easing.BezierSpline
DankAnim {
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
onRunningChanged: if (!running && !root.contentVisible)
modalContainer._renderActive = false
}
}
Behavior on scale {
NumberAnimation {
easing.type: Easing.BezierSpline
DankAnim {
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
}
}
Behavior on publishedScale {
NumberAnimation {
easing.type: Easing.BezierSpline
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
}
}
Connections {
target: root
function onContentVisibleChanged() {
if (root.contentVisible)
modalContainer._renderActive = true;
}
}
ElevationShadow {
id: launcherShadowLayer
anchors.fill: parent
level: root.shadowLevel
fallbackOffset: root.shadowFallbackOffset
level: Theme.elevationLevel3
fallbackOffset: 6
targetColor: root.backgroundColor
borderColor: root.borderColor
borderWidth: root.borderWidth

View File

@@ -274,16 +274,14 @@ FocusScope {
Item {
id: footerBar
readonly property bool _connectedBottomEmerge: (root.parentModal?.frameOwnsConnectedChrome ?? false) && (root.parentModal?.resolvedConnectedBarSide === "bottom")
readonly property bool _connectedArcAtFooter: _connectedBottomEmerge && !(root.parentModal?.launcherArcExtenderActive ?? false)
readonly property bool showFooter: SettingsData.dankLauncherV2Size !== "micro" && SettingsData.dankLauncherV2ShowFooter
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: root.parentModal?.borderWidth ?? 1
anchors.rightMargin: root.parentModal?.borderWidth ?? 1
anchors.bottomMargin: _connectedBottomEmerge ? Theme.spacingM : (root.parentModal?.borderWidth ?? 1)
anchors.bottomMargin: root.parentModal?.borderWidth ?? 1
readonly property bool showFooter: SettingsData.dankLauncherV2Size !== "micro" && SettingsData.dankLauncherV2ShowFooter
readonly property bool _connectedArcAtFooter: (root.parentModal?.frameOwnsConnectedChrome ?? false) && (root.parentModal?.resolvedConnectedBarSide === "bottom")
height: showFooter ? (_connectedArcAtFooter ? 76 : 36) : 0
visible: showFooter
clip: true

View File

@@ -158,13 +158,6 @@ FocusScope {
selectedFileIsDir = isDir;
}
function openItemContextMenu(sender, localX, localY, path, name, isDir) {
if (!sender)
return;
const pos = sender.mapToItem(root, localX, localY);
itemContextMenu.showAt(root, pos.x, pos.y, path, name, isDir);
}
function navigateUp() {
const path = currentPath;
if (path === homeDir)
@@ -766,9 +759,6 @@ FocusScope {
onItemSelected: (index, path, name, isDir) => {
setSelectedFileData(path, name, isDir);
}
onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => {
root.openItemContextMenu(sender, localX, localY, path, name, isDir);
}
Connections {
function onKeyboardSelectionRequestedChanged() {
@@ -827,9 +817,6 @@ FocusScope {
onItemSelected: (index, path, name, isDir) => {
setSelectedFileData(path, name, isDir);
}
onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => {
root.openItemContextMenu(sender, localX, localY, path, name, isDir);
}
Connections {
function onKeyboardSelectionRequestedChanged() {
@@ -930,9 +917,4 @@ FocusScope {
}
}
}
FileBrowserItemContextMenu {
id: itemContextMenu
parentFocusItem: root
}
}

View File

@@ -19,7 +19,6 @@ StyledRect {
signal itemClicked(int index, string path, string name, bool isDir)
signal itemSelected(int index, string path, string name, bool isDir)
signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir)
function getFileExtension(fileName) {
const parts = fileName.split('.');
@@ -108,11 +107,11 @@ StyledRect {
const size = _thumbnailPx;
const fp = delegateRoot.filePath;
Paths.mkdir(thumbDir);
Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) {
Proc.runCommand(null, ["test", "-f", thumbPath], function(output, exitCode) {
if (exitCode === 0) {
_videoThumb = thumbPath;
} else {
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", String(size), "-f"], function (output, exitCode) {
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", String(size), "-f"], function(output, exitCode) {
if (exitCode === 0)
_videoThumb = thumbPath;
});
@@ -247,16 +246,8 @@ StyledRect {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
switch (mouse.button) {
case Qt.LeftButton:
itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
break;
case Qt.RightButton:
itemContextMenuRequested(delegateRoot, mouse.x, mouse.y, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
break;
}
onClicked: {
itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
}
}
}

View File

@@ -1,153 +0,0 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
Popup {
id: root
property string filePath: ""
property string fileName: ""
property bool fileIsDir: false
property var parentFocusItem: null
signal trashed
signal menuClosed
readonly property var menuItems: [
{
text: I18n.tr("Move to Trash"),
icon: "delete",
action: trashItem,
enabled: filePath.length > 0,
dangerous: true
},
{
text: I18n.tr("Copy Path"),
icon: "content_copy",
action: copyPath,
enabled: filePath.length > 0
}
]
function showAt(parentItem, localX, localY, path, name, isDir) {
if (!parentItem)
return;
parent = parentItem;
filePath = path || "";
fileName = name || "";
fileIsDir = !!isDir;
x = Math.max(0, Math.min(parentItem.width - width, localX));
y = Math.max(0, Math.min(parentItem.height - height, localY));
open();
}
function trashItem() {
if (!filePath)
return;
TrashService.trashPath(filePath, ok => {
if (ok)
root.trashed();
});
close();
}
function copyPath() {
if (!filePath)
return;
Quickshell.execDetached(["dms", "cl", "copy", filePath]);
close();
}
width: 220
height: menuColumn.implicitHeight + Theme.spacingS * 2
padding: 0
modal: false
closePolicy: Popup.CloseOnEscape
onClosed: {
closePolicy = Popup.CloseOnEscape;
menuClosed();
if (parentFocusItem)
Qt.callLater(() => parentFocusItem.forceActiveFocus());
}
onOpened: outsideClickTimer.start()
Timer {
id: outsideClickTimer
interval: 100
onTriggered: root.closePolicy = Popup.CloseOnEscape | Popup.CloseOnPressOutside
}
background: Rectangle {
color: "transparent"
}
contentItem: Rectangle {
color: Theme.floatingSurface
radius: Theme.cornerRadius
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: BlurService.enabled ? BlurService.borderWidth : 1
Column {
id: menuColumn
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: 1
Repeater {
model: root.menuItems
Rectangle {
width: parent.width
height: 32
radius: Theme.cornerRadius
opacity: modelData.enabled ? 1 : 0.5
color: {
if (!modelData.enabled || !area.containsMouse)
return "transparent";
if (modelData.dangerous)
return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12);
return BlurService.hoverColor(Theme.widgetBaseHoverColor);
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: modelData.icon
size: 16
color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
text: modelData.text
font.pixelSize: Theme.fontSizeSmall
color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText
elide: Text.ElideRight
}
}
MouseArea {
id: area
anchors.fill: parent
hoverEnabled: true
enabled: modelData.enabled
cursorShape: Qt.PointingHandCursor
onClicked: modelData.action()
}
}
}
}
}
}

View File

@@ -18,7 +18,6 @@ StyledRect {
signal itemClicked(int index, string path, string name, bool isDir)
signal itemSelected(int index, string path, string name, bool isDir)
signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir)
function getFileExtension(fileName) {
const parts = fileName.split('.');
@@ -103,11 +102,11 @@ StyledRect {
const thumbPath = videoThumbnailPath;
const fp = listDelegateRoot.filePath;
Paths.mkdir(_xdgCacheHome + "/thumbnails/normal");
Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) {
Proc.runCommand(null, ["test", "-f", thumbPath], function(output, exitCode) {
if (exitCode === 0) {
_videoThumb = thumbPath;
} else {
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", "128", "-f"], function (output, exitCode) {
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", "128", "-f"], function(output, exitCode) {
if (exitCode === 0)
_videoThumb = thumbPath;
});
@@ -252,16 +251,8 @@ StyledRect {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
switch (mouse.button) {
case Qt.LeftButton:
itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
break;
case Qt.RightButton:
itemContextMenuRequested(listDelegateRoot, mouse.x, mouse.y, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
break;
}
onClicked: {
itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
}
}
}

View File

@@ -1,4 +1,5 @@
import QtQuick
import QtCore
import Quickshell.Io
import qs.Common
import qs.Widgets
@@ -21,9 +22,9 @@ Rectangle {
onShowFileInfoChanged: {
if (showFileInfo && currentFileName && currentPath) {
const fullPath = currentPath + "/" + currentFileName;
fileStatProcess.selectedFilePath = fullPath;
fileStatProcess.running = true;
const fullPath = currentPath + "/" + currentFileName
fileStatProcess.selectedFilePath = fullPath
fileStatProcess.running = true
}
}
@@ -37,14 +38,14 @@ Rectangle {
stdout: StdioCollector {
onStreamFinished: {
if (text && text.trim()) {
const parts = text.trim().split('|');
const parts = text.trim().split('|')
if (parts.length >= 4) {
fileStatProcess.fileStats = {
"modifiedTime": parts[0],
"permissions": parts[1],
"size": parseInt(parts[2]) || 0,
"fullPath": parts[3]
};
}
}
}
}
@@ -59,31 +60,31 @@ Rectangle {
onCurrentFileNameChanged: {
if (showFileInfo && currentFileName && currentPath) {
const fullPath = currentPath + "/" + currentFileName;
const fullPath = currentPath + "/" + currentFileName
if (fullPath !== fileStatProcess.selectedFilePath) {
fileStatProcess.selectedFilePath = fullPath;
fileStatProcess.running = true;
fileStatProcess.selectedFilePath = fullPath
fileStatProcess.running = true
}
}
}
function updateFileInfo(filePath, fileName, isDirectory) {
if (filePath && filePath !== fileStatProcess.selectedFilePath) {
fileStatProcess.selectedFilePath = filePath;
currentFileName = fileName || "";
currentFileIsDir = isDirectory || false;
fileStatProcess.selectedFilePath = filePath
currentFileName = fileName || ""
currentFileIsDir = isDirectory || false
let ext = "";
let ext = ""
if (!isDirectory && fileName) {
const lastDot = fileName.lastIndexOf('.');
const lastDot = fileName.lastIndexOf('.')
if (lastDot > 0) {
ext = fileName.substring(lastDot + 1).toLowerCase();
ext = fileName.substring(lastDot + 1).toLowerCase()
}
}
currentFileExtension = ext;
currentFileExtension = ext
if (showFileInfo) {
fileStatProcess.running = true;
fileStatProcess.running = true
}
}
}
@@ -99,10 +100,10 @@ Rectangle {
"permissions": "",
"extension": "",
"position": "N/A"
};
}
}
const hasValidFile = currentFileName !== "";
const hasValidFile = currentFileName !== ""
return {
"exists": hasValidFile,
"name": hasValidFile ? currentFileName : "Loading...",
@@ -112,7 +113,7 @@ Rectangle {
"permissions": fileStatProcess.fileStats ? fileStatProcess.fileStats.permissions : "Loading...",
"extension": currentFileExtension,
"position": sourceFolderModel ? ((selectedIndex + 1) + " of " + sourceFolderModel.count) : "N/A"
};
}
}
Column {
@@ -208,23 +209,23 @@ Rectangle {
function formatFileSize(bytes) {
if (bytes === 0 || !bytes) {
return "0 B";
return "0 B"
}
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
function formatDateTime(dateTimeString) {
if (!dateTimeString) {
return "Unknown";
return "Unknown"
}
const parts = dateTimeString.split(' ');
const parts = dateTimeString.split(' ')
if (parts.length >= 2) {
return parts[0] + " " + parts[1].split('.')[0];
return parts[0] + " " + parts[1].split('.')[0]
}
return dateTimeString;
return dateTimeString
}
Behavior on opacity {

View File

@@ -1,12 +1,10 @@
import QtQuick
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
readonly property var log: Log.scoped("GreeterDoctorPage")
property bool isRunning: false
property bool hasRun: false
@@ -61,22 +59,26 @@ Item {
border.color: Theme.primary
opacity: 0
OpacityAnimator on opacity {
SequentialAnimation on opacity {
running: root.isRunning
loops: Animation.Infinite
from: 0.8
to: 0
duration: 1500
easing.type: Easing.OutQuad
NumberAnimation {
from: 0.8
to: 0
duration: 1500
easing.type: Easing.OutQuad
}
}
ScaleAnimator on scale {
SequentialAnimation on scale {
running: root.isRunning
loops: Animation.Infinite
from: 0.5
to: 1.5
duration: 1500
easing.type: Easing.OutQuad
NumberAnimation {
from: 0.5
to: 1.5
duration: 1500
easing.type: Easing.OutQuad
}
}
}
@@ -91,22 +93,26 @@ Item {
border.color: Theme.secondary
opacity: 0
OpacityAnimator on opacity {
SequentialAnimation on opacity {
running: root.isRunning
loops: Animation.Infinite
from: 0.8
to: 0
duration: 1500
easing.type: Easing.OutQuad
NumberAnimation {
from: 0.8
to: 0
duration: 1500
easing.type: Easing.OutQuad
}
}
ScaleAnimator on scale {
SequentialAnimation on scale {
running: root.isRunning
loops: Animation.Infinite
from: 0.3
to: 1.3
duration: 1500
easing.type: Easing.OutQuad
NumberAnimation {
from: 0.3
to: 1.3
duration: 1500
easing.type: Easing.OutQuad
}
}
}
@@ -222,7 +228,9 @@ Item {
text: {
if (root.errorCount === 0)
return I18n.tr("All checks passed", "greeter doctor page success");
return root.errorCount === 1 ? I18n.tr("%1 issue found", "greeter doctor page error count").arg(root.errorCount) : I18n.tr("%1 issues found", "greeter doctor page error count").arg(root.errorCount);
return root.errorCount === 1
? I18n.tr("%1 issue found", "greeter doctor page error count").arg(root.errorCount)
: I18n.tr("%1 issues found", "greeter doctor page error count").arg(root.errorCount);
}
font.pixelSize: Theme.fontSizeMedium
color: root.errorCount > 0 ? Theme.error : Theme.surfaceVariantText
@@ -404,7 +412,7 @@ Item {
else
root.selectedFilter = "ok";
} catch (e) {
log.error("Failed to parse doctor output:", e);
console.error("GreeterDoctorPage: Failed to parse doctor output:", e);
}
}
}

View File

@@ -7,7 +7,6 @@ import qs.Widgets
FloatingWindow {
id: root
readonly property var log: Log.scoped("GreeterModal")
property bool disablePopupTransparency: true
property int currentPage: 0
@@ -106,7 +105,7 @@ FloatingWindow {
root.cheatsheetData = JSON.parse(trimmed);
root.cheatsheetLoaded = true;
} catch (e) {
log.warn("Greeter: Failed to parse cheatsheet:", e);
console.warn("Greeter: Failed to parse cheatsheet:", e);
}
}
}

View File

@@ -259,24 +259,20 @@ DankModal {
cancelHold();
close();
}
onShouldBeVisibleChanged: {
if (!shouldBeVisible)
return;
onOpened: () => {
holdAction = "";
holdActionIndex = -1;
holdProgress = 0;
showHoldHint = false;
updateVisibleActions();
const defaultIndex = getDefaultActionIndex();
selectedIndex = defaultIndex;
if (SettingsData.powerMenuGridLayout) {
selectedRow = Math.floor(defaultIndex / gridColumns);
selectedCol = defaultIndex % gridColumns;
selectedIndex = defaultIndex;
} else {
selectedIndex = defaultIndex;
}
}
onShouldHaveFocusChanged: {
if (!shouldHaveFocus)
return;
Qt.callLater(() => modalFocusScope.forceActiveFocus());
}
onDialogClosed: () => {

View File

@@ -9,7 +9,6 @@ import qs.Widgets
FloatingWindow {
id: processListModal
readonly property var log: Log.scoped("ProcessListModal")
property bool disablePopupTransparency: true
property int currentTab: 0
@@ -23,7 +22,7 @@ FloatingWindow {
function show() {
if (!DgopService.dgopAvailable) {
log.warn("dgop is not available");
console.warn("ProcessListModal: dgop is not available");
return;
}
visible = true;
@@ -37,7 +36,7 @@ FloatingWindow {
function toggle() {
if (!DgopService.dgopAvailable) {
log.warn("dgop is not available");
console.warn("ProcessListModal: dgop is not available");
return;
}
visible = !visible;
@@ -45,7 +44,7 @@ FloatingWindow {
function focusOrToggle() {
if (!DgopService.dgopAvailable) {
log.warn("dgop is not available");
console.warn("ProcessListModal: dgop is not available");
return;
}
if (visible) {

View File

@@ -112,9 +112,7 @@ FocusScope {
focus: active
sourceComponent: Component {
DockTab {
parentModal: root.parentModal
}
DockTab {}
}
onActiveChanged: {
@@ -220,9 +218,7 @@ FocusScope {
visible: active
focus: active
sourceComponent: ThemeColorsTab {
parentModal: root.parentModal
}
sourceComponent: ThemeColorsTab {}
onActiveChanged: {
if (active && item)

View File

@@ -170,8 +170,7 @@ Rectangle {
"id": "updater",
"text": I18n.tr("System Updater"),
"icon": "refresh",
"tabIndex": 20,
"updaterOnly": true
"tabIndex": 20
},
{
"id": "desktop_widgets",
@@ -347,8 +346,6 @@ Rectangle {
return false;
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
return false;
if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable)
return false;
return true;
}

View File

@@ -7,7 +7,6 @@ import qs.Widgets
FloatingWindow {
id: root
readonly property var log: Log.scoped("WorkspaceRenameModal")
property bool disablePopupTransparency: true
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
@@ -40,7 +39,7 @@ FloatingWindow {
} else if (CompositorService.isHyprland) {
HyprlandService.renameWorkspace(name);
} else {
log.warn("rename not supported for this compositor");
console.warn("WorkspaceRenameModal: rename not supported for this compositor");
}
}

View File

@@ -1,355 +0,0 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
id: root
Ref {
service: TailscaleService
}
ccWidgetIcon: "device_hub"
ccWidgetPrimaryText: I18n.tr("Tailscale", "Tailscale mesh VPN widget title")
ccWidgetSecondaryText: {
if (!TailscaleService.available)
return I18n.tr("Not available", "Tailscale service not available");
if (!TailscaleService.connected)
return I18n.tr("Disconnected", "Tailscale disconnected status");
const count = TailscaleService.onlinePeerCount;
return I18n.tr("%1 online", "Number of online Tailscale peers").arg(count);
}
ccWidgetIsActive: TailscaleService.connected
onCcWidgetToggled: {}
ccDetailContent: Component {
Rectangle {
id: detailRoot
property string searchQuery: ""
property int filterIndex: 0 // 0=My Online, 1=All Online, 2=All
property string expandedHostname: ""
implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Column {
id: detailColumn
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
// Not available state
Column {
visible: !TailscaleService.available
width: parent.width
spacing: Theme.spacingS
Item {
width: parent.width
height: 80
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "vpn_key_off"
size: 36
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: I18n.tr("Tailscale not available", "Warning when Tailscale service is not running")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}
// Connected content
Item {
visible: TailscaleService.available
width: parent.width
height: parent.height - (parent.visibleChildren[0] === this ? 0 : y)
clip: true
Column {
id: headerColumn
width: parent.width
spacing: Theme.spacingS
// Search bar + refresh button
RowLayout {
width: parent.width
spacing: Theme.spacingS
DankTextField {
Layout.fillWidth: true
placeholderText: I18n.tr("Search devices...", "Tailscale device search placeholder")
leftIconName: "search"
showClearButton: true
text: detailRoot.searchQuery
onTextEdited: detailRoot.searchQuery = text
}
DankActionButton {
iconName: "sync"
buttonSize: 28
iconSize: 16
iconColor: Theme.surfaceVariantText
tooltipText: I18n.tr("Refresh", "Refresh Tailscale device status")
onClicked: TailscaleService.refresh(null)
}
}
// Filter chips
DankFilterChips {
width: parent.width
currentIndex: detailRoot.filterIndex
showCounts: true
chipHeight: 26
model: [
{
"label": I18n.tr("My Online", "Tailscale filter: my online devices"),
"count": TailscaleService.myOnlinePeers.length
},
{
"label": I18n.tr("Online", "Tailscale filter: all online devices"),
"count": TailscaleService.onlinePeers.length
},
{
"label": I18n.tr("All", "Tailscale filter: all devices"),
"count": TailscaleService.allPeersList.length
}
]
onSelectionChanged: index => {
detailRoot.filterIndex = index;
}
}
}
// Scrollable peer list — fills remaining space below header
DankFlickable {
anchors.top: headerColumn.bottom
anchors.topMargin: Theme.spacingS
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
contentHeight: peerListColumn.implicitHeight
clip: true
Column {
id: peerListColumn
width: parent.width
spacing: Theme.spacingXS
property var filteredPeers: {
let base;
switch (detailRoot.filterIndex) {
case 0:
base = TailscaleService.myOnlinePeers;
break;
case 1:
base = TailscaleService.onlinePeers;
break;
case 2:
base = TailscaleService.allPeersList;
break;
default:
base = [];
}
if (detailRoot.searchQuery.length > 0)
return TailscaleService.searchPeers(detailRoot.searchQuery, base);
return base;
}
// Empty state
Item {
width: parent.width
height: 60
visible: peerListColumn.filteredPeers.length === 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: "devices"
size: 28
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: detailRoot.searchQuery.length > 0 ? I18n.tr("No matching devices", "No Tailscale devices match search") : I18n.tr("No peers found", "No Tailscale peers found")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
// Peer cards
Repeater {
model: peerListColumn.filteredPeers
delegate: Rectangle {
required property var modelData
required property int index
width: peerListColumn.width
height: peerCardColumn.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "") ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.surfaceContainerHighest
property bool isSelf: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "")
property bool isExpanded: detailRoot.expandedHostname === modelData.hostname
Column {
id: peerCardColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingS
spacing: 2
RowLayout {
width: parent.width
spacing: Theme.spacingS
Rectangle {
width: 8
height: 8
radius: 4
color: modelData.online ? "#4caf50" : Theme.surfaceVariantText
Layout.alignment: Qt.AlignVCenter
}
StyledText {
text: modelData.hostname || ""
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Bold
color: Theme.surfaceText
Layout.fillWidth: true
elide: Text.ElideRight
}
StyledText {
visible: isSelf
text: I18n.tr("This device", "Label for the user's own device in Tailscale")
font.pixelSize: 10
color: Theme.primary
font.weight: Font.Medium
}
}
RowLayout {
width: parent.width
spacing: Theme.spacingXS
StyledText {
text: modelData.tailscaleIp || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
Layout.fillWidth: true
}
DankActionButton {
iconName: "content_copy"
buttonSize: 20
iconSize: 11
iconColor: Theme.surfaceVariantText
tooltipText: I18n.tr("Copy", "Copy to clipboard")
onClicked: Quickshell.execDetached(["dms", "cl", "copy", modelData.tailscaleIp])
}
}
StyledText {
text: {
const parts = [];
if (modelData.os)
parts.push(modelData.os);
if (modelData.online) {
parts.push(modelData.relay ? I18n.tr("relay: %1", "Tailscale relay server name").arg(modelData.relay) : I18n.tr("direct", "Tailscale direct connection"));
} else if (modelData.lastSeen) {
parts.push(I18n.tr("last seen %1", "Tailscale peer last seen time").arg(modelData.lastSeen));
}
return parts.join(" \u2022 ");
}
font.pixelSize: 10
color: Theme.surfaceVariantText
width: parent.width
elide: Text.ElideRight
}
// Expanded: DNS name + copy, tags, owner
Column {
visible: isExpanded
width: parent.width
spacing: 2
topPadding: 4
RowLayout {
width: parent.width
spacing: Theme.spacingXS
visible: (modelData.dnsName || "").length > 0
StyledText {
text: modelData.dnsName || ""
font.pixelSize: 10
color: Theme.surfaceVariantText
Layout.fillWidth: true
elide: Text.ElideRight
}
DankActionButton {
iconName: "content_copy"
buttonSize: 20
iconSize: 11
iconColor: Theme.surfaceVariantText
onClicked: Quickshell.execDetached(["dms", "cl", "copy", modelData.dnsName])
}
}
StyledText {
visible: (modelData.tags || []).length > 0
text: I18n.tr("Tags: %1", "Tailscale device tags").arg((modelData.tags || []).join(", "))
font.pixelSize: 10
color: Theme.surfaceVariantText
}
StyledText {
visible: (modelData.owner || "").length > 0
text: I18n.tr("Owner: %1", "Tailscale device owner").arg(modelData.owner || "")
font.pixelSize: 10
color: Theme.surfaceVariantText
}
}
}
MouseArea {
z: -1
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: detailRoot.expandedHostname = (detailRoot.expandedHostname === modelData.hostname) ? "" : modelData.hostname
}
}
}
}
}
}
}
}
}
}

View File

@@ -22,7 +22,6 @@ Item {
case section === "wifi":
case section === "bluetooth":
case section === "builtin_vpn":
case section === "builtin_tailscale":
return Math.min(350, maxAvailableHeight);
case section.startsWith("brightnessSlider_"):
return Math.min(400, maxAvailableHeight);
@@ -129,12 +128,6 @@ Item {
}
builtinInstance = widgetModel.cupsBuiltinInstance;
}
if (builtinId === "builtin_tailscale") {
if (widgetModel?.tailscaleLoader) {
widgetModel.tailscaleLoader.active = true;
}
builtinInstance = widgetModel.tailscaleBuiltinInstance;
}
if (!builtinInstance || !builtinInstance.ccDetailContent) {
return;

View File

@@ -7,7 +7,6 @@ import "../utils/layout.js" as LayoutUtils
Column {
id: root
readonly property var log: Log.scoped("DragDropGrid")
property bool editMode: false
property string expandedSection: ""
@@ -266,7 +265,7 @@ Column {
}
Behavior on height {
enabled: true
enabled: SettingsData.connectedFrameModeActive
NumberAnimation {
duration: Theme.variantDuration(Theme.popoutAnimationDuration, detailHost.active)
easing.type: Easing.BezierSpline
@@ -918,12 +917,6 @@ Column {
}
builtinInstance = Qt.binding(() => root.model?.cupsBuiltinInstance);
}
if (id === "builtin_tailscale") {
if (root.model?.tailscaleLoader) {
root.model.tailscaleLoader.active = true;
}
builtinInstance = Qt.binding(() => root.model?.tailscaleBuiltinInstance);
}
}
sourceComponent: {
@@ -1061,7 +1054,7 @@ Column {
return true;
}
} catch (e) {
log.warn("stale plugin component for", pluginId, "- reloading");
console.warn("DragDropGrid: stale plugin component for", pluginId, "- reloading");
PluginService.reloadPlugin(pluginId);
}
return false;

View File

@@ -23,15 +23,11 @@ Item {
signal toggleWidgetSize(int index)
width: {
const widgetWidth = widgetData?.width || 50;
if (widgetWidth <= 25)
return gridCellWidth;
else if (widgetWidth <= 50)
return gridCellWidth * 2;
else if (widgetWidth <= 75)
return gridCellWidth * 3;
else
return gridCellWidth * 4;
const widgetWidth = widgetData?.width || 50
if (widgetWidth <= 25) return gridCellWidth
else if (widgetWidth <= 50) return gridCellWidth * 2
else if (widgetWidth <= 75) return gridCellWidth * 3
else return gridCellWidth * 4
}
height: isSlider ? 16 : gridCellHeight
@@ -46,14 +42,10 @@ Item {
z: dragArea.drag.active ? 10000 : 1
Behavior on border.width {
NumberAnimation {
duration: 150
}
NumberAnimation { duration: 150 }
}
Behavior on opacity {
NumberAnimation {
duration: 150
}
NumberAnimation { duration: 150 }
}
}
@@ -66,17 +58,14 @@ Item {
property int globalWidgetIndex: root.widgetIndex
property int widgetWidth: root.widgetData?.width || 50
MouseArea {
id: editModeBlocker
anchors.fill: parent
enabled: root.editMode
acceptedButtons: Qt.AllButtons
onPressed: function (mouse) {
mouse.accepted = true;
}
onWheel: function (wheel) {
wheel.accepted = true;
}
onPressed: function(mouse) { mouse.accepted = true }
onWheel: function(wheel) { wheel.accepted = true }
z: 100
}
}
@@ -90,19 +79,19 @@ Item {
drag.axis: Drag.XAndYAxis
drag.smoothed: true
onPressed: function (mouse) {
onPressed: function(mouse) {
if (editMode) {
cursorShape = Qt.ClosedHandCursor;
cursorShape = Qt.ClosedHandCursor
if (root.gridLayout && root.gridLayout.moveToTop) {
root.gridLayout.moveToTop(root);
root.gridLayout.moveToTop(root)
}
}
}
onReleased: function (mouse) {
onReleased: function(mouse) {
if (editMode) {
cursorShape = Qt.OpenHandCursor;
root.snapToGrid();
cursorShape = Qt.OpenHandCursor
root.snapToGrid()
}
}
}
@@ -112,11 +101,9 @@ Item {
Drag.hotSpot.y: height / 2
function swapIndices(i, j) {
if (i === j)
return;
if (i === j) return;
const arr = SettingsData.controlCenterWidgets;
if (!arr || i < 0 || j < 0 || i >= arr.length || j >= arr.length)
return;
if (!arr || i < 0 || j < 0 || i >= arr.length || j >= arr.length) return;
const copy = arr.slice();
const tmp = copy[i];
@@ -127,41 +114,37 @@ Item {
}
function snapToGrid() {
if (!editMode || !gridLayout)
return;
const globalPos = root.mapToItem(gridLayout, 0, 0);
const cellWidth = gridLayout.width / gridColumns;
const cellHeight = gridCellHeight + Theme.spacingS;
if (!editMode || !gridLayout) return
const centerX = globalPos.x + (root.width / 2);
const centerY = globalPos.y + (root.height / 2);
const globalPos = root.mapToItem(gridLayout, 0, 0)
const cellWidth = gridLayout.width / gridColumns
const cellHeight = gridCellHeight + Theme.spacingS
let targetCol = Math.max(0, Math.floor(centerX / cellWidth));
let targetRow = Math.max(0, Math.floor(centerY / cellHeight));
const centerX = globalPos.x + (root.width / 2)
const centerY = globalPos.y + (root.height / 2)
targetCol = Math.min(targetCol, gridColumns - 1);
let targetCol = Math.max(0, Math.floor(centerX / cellWidth))
let targetRow = Math.max(0, Math.floor(centerY / cellHeight))
const newIndex = findBestInsertionIndex(targetRow, targetCol);
targetCol = Math.min(targetCol, gridColumns - 1)
const newIndex = findBestInsertionIndex(targetRow, targetCol)
if (newIndex !== widgetIndex && newIndex >= 0 && newIndex < (SettingsData.controlCenterWidgets?.length || 0)) {
swapIndices(widgetIndex, newIndex);
swapIndices(widgetIndex, newIndex)
}
}
function findBestInsertionIndex(targetRow, targetCol) {
const widgets = SettingsData.controlCenterWidgets || [];
const n = widgets.length;
if (!n || widgetIndex < 0 || widgetIndex >= n)
return -1;
if (!n || widgetIndex < 0 || widgetIndex >= n) return -1;
function spanFor(width) {
const w = width ?? 50;
if (w <= 25)
return 1;
if (w <= 50)
return 2;
if (w <= 75)
return 3;
if (w <= 25) return 1;
if (w <= 50) return 2;
if (w <= 75) return 3;
return 4;
}
@@ -186,13 +169,7 @@ Item {
if (i === widgetIndex) {
draggedOrigKey = centerKey;
} else {
pos.push({
index: i,
row,
startCol,
span,
centerKey
});
pos.push({ index: i, row, startCol, span, centerKey });
}
col += span;
@@ -202,8 +179,7 @@ Item {
}
}
if (pos.length === 0)
return -1;
if (pos.length === 0) return -1;
const centerColCoord = targetCol + 0.5;
const targetKey = targetRow * cols + centerColCoord;
@@ -216,20 +192,15 @@ Item {
}
let lo = 0, hi = pos.length - 1;
if (targetKey <= pos[0].centerKey)
return pos[0].index;
if (targetKey >= pos[hi].centerKey)
return pos[hi].index;
if (targetKey <= pos[0].centerKey) return pos[0].index;
if (targetKey >= pos[hi].centerKey) return pos[hi].index;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const mk = pos[mid].centerKey;
if (targetKey < mk)
hi = mid - 1;
else if (targetKey > mk)
lo = mid + 1;
else
return pos[mid].index;
if (targetKey < mk) hi = mid - 1;
else if (targetKey > mk) lo = mid + 1;
else return pos[mid].index;
}
const movingUp = (draggedOrigKey != null) ? (targetKey < draggedOrigKey) : false;
return (movingUp ? pos[lo].index : pos[hi].index);
@@ -269,11 +240,11 @@ Item {
currentSize: root.widgetData?.width || 50
isSlider: root.isSlider
widgetIndex: root.widgetIndex
onSizeChanged: newSize => {
var widgets = SettingsData.controlCenterWidgets.slice();
onSizeChanged: (newSize) => {
var widgets = SettingsData.controlCenterWidgets.slice()
if (widgetIndex >= 0 && widgetIndex < widgets.length) {
widgets[widgetIndex].width = newSize;
SettingsData.set("controlCenterWidgets", widgets);
widgets[widgetIndex].width = newSize
SettingsData.set("controlCenterWidgets", widgets)
}
}
}
@@ -299,9 +270,7 @@ Item {
}
Behavior on opacity {
NumberAnimation {
duration: 150
}
NumberAnimation { duration: 150 }
}
}
@@ -314,9 +283,7 @@ Item {
z: -1
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
}
ColorAnimation { duration: Theme.shortDuration }
}
}
}

View File

@@ -86,7 +86,14 @@ DankPopout {
}
popupWidth: 550
popupHeight: targetPopupHeight
popupHeight: {
if (SettingsData.connectedFrameModeActive)
return targetPopupHeight;
const screenHeight = (triggerScreen?.height ?? 1080);
const maxHeight = screenHeight - 100;
const contentHeight = contentLoader.item && contentLoader.item.implicitHeight > 0 ? contentLoader.item.implicitHeight + 20 : 400;
return Math.min(maxHeight, contentHeight);
}
triggerWidth: 80
positioning: ""
screen: triggerScreen
@@ -291,9 +298,6 @@ DankPopout {
const allWidgets = widgetModel.baseWidgetDefinitions.concat(widgetModel.getPluginWidgets());
return allWidgets.filter(w => w.allowMultiple || !existingIds.includes(w.id));
}
onAddWidget: widgetId => widgetModel.addWidget(widgetId)
onResetToDefault: () => widgetModel.resetToDefault()
onClearAll: () => widgetModel.clearAll()
}
}
}

Some files were not shown because too many files have changed in this diff Show More