mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-22 11:14:01 -04:00
Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
686eb1bfd6 | ||
|
|
4a87e8227d | ||
|
|
c0b26caf2b | ||
|
|
c6c40dbced | ||
|
|
02ddd010a0 | ||
|
|
6eb9df87cf | ||
|
|
0adbdfdde1 | ||
|
|
fb002d1977 | ||
|
|
8b7c9085ca | ||
|
|
8b34bbf71a | ||
|
|
fd468a6b78 | ||
|
|
7c04b61bcc | ||
|
|
bc4916a132 | ||
|
|
58a356bb05 | ||
|
|
3061e1301d | ||
|
|
e333da2013 | ||
|
|
307d05d76c | ||
|
|
10dbe73335 | ||
|
|
b6fee3a98f | ||
|
|
6166d51c7c | ||
|
|
d06fcccff9 | ||
|
|
d9cfeded47 | ||
|
|
c4ced3ac9d | ||
|
|
ce2ec80d80 | ||
|
|
a340eba493 | ||
|
|
2db7646fe3 | ||
|
|
097de493bb | ||
|
|
a7db0354ae | ||
|
|
2d2502cc67 | ||
|
|
402a8515ee | ||
|
|
4cf0f0c898 | ||
|
|
fd0564a3ec | ||
|
|
07f8675a7b | ||
|
|
cab2a1a3bc | ||
|
|
41dd91fe75 | ||
|
|
c074b220ee | ||
|
|
243d716703 | ||
|
|
fde2181c3b | ||
|
|
a5ffd18596 | ||
|
|
e0aa1f0177 | ||
|
|
e73b9c3ce1 | ||
|
|
69955286a7 | ||
|
|
d3a02f6342 | ||
|
|
86234e5b09 | ||
|
|
938fabe27d | ||
|
|
1b34aa0ce1 | ||
|
|
76ed5d68fa | ||
|
|
b69496dbcd | ||
|
|
ef179f9c92 | ||
|
|
b451d4d8e0 | ||
|
|
12f7675950 | ||
|
|
06cd88e990 | ||
|
|
aa4b99def4 | ||
|
|
4015f634c4 | ||
|
|
b2d1ec6690 | ||
|
|
1efc10dc7e | ||
|
|
94710cf643 | ||
|
|
3711cb11fa | ||
|
|
40a0eb6841 | ||
|
|
0a643028b3 | ||
|
|
0189eb0dbb | ||
|
|
ed26bdebdc | ||
|
|
2e2c74e355 | ||
|
|
c1a3be60c3 | ||
|
|
f8260513b1 | ||
|
|
ed1855946f | ||
|
|
c0bc1d8885 | ||
|
|
3f09d7b1c8 | ||
|
|
710f20d085 | ||
|
|
6261e580aa | ||
|
|
42912ebe74 | ||
|
|
e8a755b85d | ||
|
|
6713f05e02 | ||
|
|
5d39702136 | ||
|
|
166b82df1b | ||
|
|
bca1466162 | ||
|
|
75b8932b20 | ||
|
|
a1b51b191f | ||
|
|
5defc26929 | ||
|
|
71e8a85cd1 | ||
|
|
95b2750e5f | ||
|
|
7f1a423174 | ||
|
|
33e599100c | ||
|
|
cb85620777 | ||
|
|
faf25e98a5 | ||
|
|
d7b264701e | ||
|
|
c929a8952b | ||
|
|
82089c588a | ||
|
|
19a56d1835 | ||
|
|
8371fa8ef5 | ||
|
|
acca7fae68 | ||
|
|
a478c27fd0 | ||
|
|
094dcdd035 | ||
|
|
344c1e610c | ||
|
|
fdf8722818 | ||
|
|
f2114c1a01 | ||
|
|
6ed79d06df | ||
|
|
8ff677d2eb | ||
|
|
700f43a194 | ||
|
|
53a5582858 |
@@ -520,37 +520,37 @@ jobs:
|
||||
# Pin the satellite repos to this release's version. Each dispatch target
|
||||
# decides for itself whether to tag: X.Y.0 always tags, X.Y.Z skips when the
|
||||
# repo has no commits of its own since its last tag.
|
||||
fanout:
|
||||
name: Pin ${{ matrix.repo }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release]
|
||||
if: ${{ !contains(inputs.tag, '-') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
repo:
|
||||
- dgop
|
||||
- dankgo
|
||||
- dank-qml-common
|
||||
- dankcalendar
|
||||
- danksearch
|
||||
- dank-greeter
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: ${{ matrix.repo }}
|
||||
|
||||
- name: Dispatch pin-release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
REPO: ${{ github.repository_owner }}/${{ matrix.repo }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
|
||||
echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
|
||||
# fanout:
|
||||
# name: Pin ${{ matrix.repo }}
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [release]
|
||||
# if: ${{ !contains(inputs.tag, '-') }}
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# repo:
|
||||
# - dgop
|
||||
# - dankgo
|
||||
# - dank-qml-common
|
||||
# - dankcalendar
|
||||
# - danksearch
|
||||
# - dank-greeter
|
||||
# steps:
|
||||
# - name: Create GitHub App token
|
||||
# id: app_token
|
||||
# uses: actions/create-github-app-token@v3
|
||||
# with:
|
||||
# app-id: ${{ secrets.APP_ID }}
|
||||
# private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
# owner: ${{ github.repository_owner }}
|
||||
# repositories: ${{ matrix.repo }}
|
||||
#
|
||||
# - name: Dispatch pin-release
|
||||
# env:
|
||||
# GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
# REPO: ${{ github.repository_owner }}/${{ matrix.repo }}
|
||||
# TAG: ${{ inputs.tag }}
|
||||
# run: |
|
||||
# set -euo pipefail
|
||||
# gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
|
||||
# echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
[submodule "dank-qml-common"]
|
||||
path = dank-qml-common
|
||||
url = https://github.com/AvengeMedia/dank-qml-common.git
|
||||
branch = master
|
||||
branch = stable-1.6
|
||||
|
||||
@@ -79,11 +79,11 @@ func runDank16(cmd *cobra.Command, args []string) {
|
||||
primaryLight = primaryColor
|
||||
}
|
||||
variantOpts := dank16.VariantOptions{
|
||||
PrimaryDark: primaryDark,
|
||||
PrimaryLight: primaryLight,
|
||||
Background: background,
|
||||
UseDPS: contrastAlgo == "dps",
|
||||
IsLightMode: isLight,
|
||||
PrimaryDark: primaryDark,
|
||||
PrimaryLight: primaryLight,
|
||||
BackgroundDark: background,
|
||||
UseDPS: contrastAlgo == "dps",
|
||||
IsLightMode: isLight,
|
||||
}
|
||||
variantColors := dank16.GenerateVariantPalette(variantOpts)
|
||||
fmt.Print(dank16.GenerateVariantJSON(variantColors))
|
||||
|
||||
@@ -95,6 +95,8 @@ var (
|
||||
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
||||
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
||||
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
|
||||
scrollVersionRegex = regexp.MustCompile(`scroll version (\d+\.\d+)`)
|
||||
aqueousVersionRegex = regexp.MustCompile(`(?i)aqueous v?(\d+\.\d+(?:\.\d+)?)`)
|
||||
)
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
@@ -739,6 +741,8 @@ func checkWindowManagers() []checkResult {
|
||||
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
||||
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
||||
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
|
||||
{"Scroll", "scroll", "--version", scrollVersionRegex, []string{"scroll"}},
|
||||
{"Aqueous", "aqueous", "-version", aqueousVersionRegex, []string{"aqueous"}},
|
||||
}
|
||||
|
||||
var results []checkResult
|
||||
@@ -771,7 +775,7 @@ func checkWindowManagers() []checkResult {
|
||||
results = append(results, checkResult{
|
||||
catCompositor, "Compositor", statusError,
|
||||
"No supported Wayland compositor found",
|
||||
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
|
||||
"Install Hyprland, niri, Sway, River, Wayfire, labwc, mangowc, miracle-wm, Scroll, or Aqueous",
|
||||
doctorDocsURL + "#compositor-checks",
|
||||
})
|
||||
}
|
||||
@@ -780,11 +784,28 @@ func checkWindowManagers() []checkResult {
|
||||
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
||||
}
|
||||
|
||||
if os.Getenv("AQUEOUS_SOCKET") != "" {
|
||||
results = append(results, checkAqueousConfigHelper())
|
||||
}
|
||||
|
||||
results = append(results, checkCompositorBlurSupport())
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func checkAqueousConfigHelper() checkResult {
|
||||
url := doctorDocsURL + "#compositor-checks"
|
||||
path, err := exec.LookPath("aqueous-config")
|
||||
if err != nil {
|
||||
return checkResult{catCompositor, "aqueous-config", statusWarn, "Not found", "Keybind and display settings edits from the shell need the aqueous-config helper", url}
|
||||
}
|
||||
details := "Keybind and display settings edits from the shell"
|
||||
if doctorVerbose {
|
||||
details = path
|
||||
}
|
||||
return checkResult{catCompositor, "aqueous-config", statusOK, "Installed", details, url}
|
||||
}
|
||||
|
||||
func checkCompositorBlurSupport() checkResult {
|
||||
supported, err := blur.ProbeSupport()
|
||||
if err != nil {
|
||||
@@ -817,6 +838,8 @@ func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
|
||||
|
||||
func detectRunningWM() string {
|
||||
switch {
|
||||
case os.Getenv("AQUEOUS_SOCKET") != "":
|
||||
return "Aqueous"
|
||||
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
|
||||
return "Hyprland"
|
||||
case os.Getenv("NIRI_SOCKET") != "":
|
||||
@@ -911,7 +934,7 @@ ShellRoot {
|
||||
{"Polkit", "Authentication prompts"},
|
||||
{"IdleMonitor", "Idle detection"},
|
||||
{"IdleInhibitor", "Prevent idle/sleep"},
|
||||
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
|
||||
{"ShortcutInhibitor", "Allow shortcut management (niri, Aqueous)"},
|
||||
{"BackgroundBlur", "Background blur API support in Quickshell"},
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ var (
|
||||
ssJSON bool
|
||||
ssGeometry bool
|
||||
ssAllowMulti bool
|
||||
ssHUD string
|
||||
)
|
||||
|
||||
type screenshotMetadata struct {
|
||||
@@ -181,6 +182,7 @@ func init() {
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
|
||||
screenshotCmd.PersistentFlags().BoolVarP(&ssGeometry, "geometry", "g", false, "Print selected region geometry (X,Y WxH) to stdout without capturing an image")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssAllowMulti, "allow-multiple", false, "Open a selector even when another one is already open")
|
||||
screenshotCmd.PersistentFlags().StringVar(&ssHUD, "hud", "auto", "HUD overlay scale in region selector (auto, off/0, or integer scale 1-4)")
|
||||
|
||||
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
|
||||
|
||||
@@ -212,6 +214,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
config.Stdout = ssStdout
|
||||
config.Geometry = ssGeometry
|
||||
config.AllowMultiple = ssAllowMulti
|
||||
config.HUD = ssHUD
|
||||
|
||||
if ssGeometry {
|
||||
config.Clipboard = false
|
||||
|
||||
@@ -58,7 +58,7 @@ func init() {
|
||||
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().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates")
|
||||
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
|
||||
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||
|
||||
|
||||
+8
-9
@@ -3,7 +3,7 @@ module github.com/AvengeMedia/DankMaterialShell/core
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577
|
||||
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287
|
||||
github.com/Nadim147c/material/v3 v3.1.1
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
@@ -18,7 +18,7 @@ require (
|
||||
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/stretchr/testify v1.12.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.4
|
||||
@@ -71,23 +71,23 @@ require (
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
|
||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e
|
||||
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea
|
||||
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/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.4
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
@@ -98,13 +98,12 @@ require (
|
||||
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 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/text v0.40.0
|
||||
golang.org/x/text v0.41.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
+22
-24
@@ -1,9 +1,9 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e h1:AUCBM5EF+hxCG55uJl30waRQHhR5+Wij9u8AfoTkQvs=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
|
||||
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577 h1:z7daEy56wLH6R7PGS3MKIVoDHNdVoDptY/f2i61EDCQ=
|
||||
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577/go.mod h1:2kt1u9YgHwi2npqB5ago8kwZdFrHlPGXv3PM/vwRHfY=
|
||||
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea h1:A2IuXIzhTQwvBZiS8pxU5RmWSszMd16Oh11gUlJ9OeU=
|
||||
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
|
||||
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287 h1:wzQqP16zxfUcfFltJelKg6pTdRyiLY2l2NJLT0Par+Q=
|
||||
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287/go.mod h1:lwzO5Y4BO5z0I7cLMp5A7sw/THliwGz1TNv6xoUehY4=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Nadim147c/material/v3 v3.1.1 h1:3ZuTPWBJWUpzzWW1rtqe28RMZXhA4f0/mg/Jk3JG94Y=
|
||||
@@ -38,8 +38,8 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF
|
||||
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/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
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=
|
||||
@@ -61,8 +61,6 @@ github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daT
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ=
|
||||
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c=
|
||||
github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
|
||||
@@ -85,8 +83,8 @@ github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
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-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ=
|
||||
@@ -166,8 +164,6 @@ github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H
|
||||
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/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/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@@ -192,8 +188,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
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-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU=
|
||||
@@ -204,8 +200,8 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
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/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
|
||||
@@ -219,18 +215,20 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
|
||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
|
||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
||||
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
@@ -243,10 +241,10 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
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=
|
||||
|
||||
@@ -661,9 +661,12 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
||||
if existingData != "" {
|
||||
existingConfig = existingData
|
||||
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
|
||||
|
||||
result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
|
||||
if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil {
|
||||
}
|
||||
// A legacy hyprland.conf is only backed up once hyprland.lua is in place; Hyprland
|
||||
// watches the live config and regenerates a stub one if it disappears first.
|
||||
if existingData != "" && !strings.EqualFold(filepath.Ext(existingPath), ".conf") {
|
||||
result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, filepath.Base(existingPath)), []byte(existingData))
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||
return result, result.Error
|
||||
}
|
||||
@@ -711,11 +714,15 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
||||
}
|
||||
}
|
||||
|
||||
movedLegacy, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||
movedLegacy, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
|
||||
return result, result.Error
|
||||
}
|
||||
if mainConfBackup != "" && result.BackupPath == "" {
|
||||
result.BackupPath = mainConfBackup
|
||||
cd.log(fmt.Sprintf("Backed up existing config to %s", mainConfBackup))
|
||||
}
|
||||
if movedLegacy > 0 {
|
||||
if result.BackupPath == "" {
|
||||
result.BackupPath = backupDir
|
||||
@@ -737,42 +744,53 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func backupHyprlandConfigFile(src, dst string, data []byte, removeSource bool) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
func uniqueBackupPath(dst string) string {
|
||||
if _, err := os.Lstat(dst); err != nil {
|
||||
return dst
|
||||
}
|
||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if removeSource {
|
||||
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
for i := 1; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s.%d", dst, i)
|
||||
if _, err := os.Lstat(candidate); err != nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return fmt.Sprintf("%s.%d", dst, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, error) {
|
||||
legacyPaths := []string{filepath.Join(configDir, "hyprland.conf")}
|
||||
func backupHyprlandConfigFile(dst string, data []byte) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst = uniqueBackupPath(dst)
|
||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, string, error) {
|
||||
mainConf := filepath.Join(configDir, "hyprland.conf")
|
||||
legacyPaths := []string{mainConf}
|
||||
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, "", err
|
||||
}
|
||||
legacyPaths = append(legacyPaths, dmsConfPaths...)
|
||||
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, "", err
|
||||
}
|
||||
legacyPaths = append(legacyPaths, backupPaths...)
|
||||
|
||||
moved := 0
|
||||
mainConfBackup := ""
|
||||
for _, src := range legacyPaths {
|
||||
info, err := os.Lstat(src)
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return moved, err
|
||||
return moved, mainConfBackup, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
continue
|
||||
@@ -782,21 +800,28 @@ func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, er
|
||||
if err != nil {
|
||||
rel = filepath.Base(src)
|
||||
}
|
||||
dst := filepath.Join(backupDir, rel)
|
||||
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
||||
return moved, err
|
||||
dst, err := moveHyprlandConfigFile(src, filepath.Join(backupDir, rel))
|
||||
if err != nil {
|
||||
return moved, mainConfBackup, err
|
||||
}
|
||||
if src == mainConf {
|
||||
mainConfBackup = dst
|
||||
}
|
||||
moved++
|
||||
}
|
||||
|
||||
return moved, nil
|
||||
return moved, mainConfBackup, nil
|
||||
}
|
||||
|
||||
func moveHyprlandConfigFile(src, dst string) error {
|
||||
func moveHyprlandConfigFile(src, dst string) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
return os.Rename(src, dst)
|
||||
dst = uniqueBackupPath(dst)
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
|
||||
|
||||
@@ -557,7 +557,7 @@ func TestKittyConfigStructure(t *testing.T) {
|
||||
func TestKittyThemeConfigStructure(t *testing.T) {
|
||||
assert.Contains(t, KittyThemeConfig, "foreground #e0e2e8")
|
||||
assert.Contains(t, KittyThemeConfig, "background #101418")
|
||||
assert.Contains(t, KittyThemeConfig, "cursor #e0e2e8")
|
||||
assert.Contains(t, KittyThemeConfig, "cursor #9dcbfb")
|
||||
assert.Contains(t, KittyThemeConfig, "color0 #101418")
|
||||
assert.Contains(t, KittyThemeConfig, "color15 #ffffff")
|
||||
}
|
||||
@@ -890,3 +890,72 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackupLegacyHyprlandConfFilesKeepsExistingBackups(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
configDir := filepath.Join(td, ".config", "hypr")
|
||||
dmsDir := filepath.Join(configDir, "dms")
|
||||
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
||||
|
||||
userConfig := "# the config the user actually wrote\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
|
||||
backupDir := filepath.Join(configDir, hyprlandBackupDirName, "2026-01-02_03-04-05")
|
||||
require.NoError(t, os.MkdirAll(backupDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(backupDir, "hyprland.conf"), []byte(userConfig), 0o644))
|
||||
|
||||
autogenerated := "autogenerated = 1\n$mainMod = SUPER\n"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "hyprland.conf"), []byte(autogenerated), 0o644))
|
||||
|
||||
moved, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, moved)
|
||||
assert.Equal(t, filepath.Join(backupDir, "hyprland.conf.1"), mainConfBackup)
|
||||
|
||||
kept, err := os.ReadFile(filepath.Join(backupDir, "hyprland.conf"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userConfig, string(kept))
|
||||
|
||||
stray, err := os.ReadFile(mainConfBackup)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, autogenerated, string(stray))
|
||||
}
|
||||
|
||||
func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) {
|
||||
backupDir := t.TempDir()
|
||||
dst := filepath.Join(backupDir, "hyprland.lua")
|
||||
require.NoError(t, os.WriteFile(dst, []byte("-- first\n"), 0o644))
|
||||
|
||||
written, err := backupHyprlandConfigFile(dst, []byte("-- second\n"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dst+".1", written)
|
||||
|
||||
first, err := os.ReadFile(dst)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "-- first\n", string(first))
|
||||
}
|
||||
|
||||
func TestDeployHyprlandConfigWritesLuaBeforeRemovingLegacyConf(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
t.Setenv("HOME", td)
|
||||
configDir := filepath.Join(td, ".config", "hypr")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
||||
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||
userConfig := "# user config\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
|
||||
require.NoError(t, os.WriteFile(confPath, []byte(userConfig), 0o644))
|
||||
|
||||
logChan := make(chan string, 100)
|
||||
cd := NewConfigDeployer(logChan)
|
||||
go func() {
|
||||
for range logChan {
|
||||
}
|
||||
}()
|
||||
defer close(logChan)
|
||||
|
||||
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NoFileExists(t, confPath)
|
||||
backup, err := os.ReadFile(result.BackupPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userConfig, string(backup))
|
||||
assert.Equal(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"), result.BackupPath)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
cursor #e0e2e8
|
||||
cursor_text_color #c2c7cf
|
||||
cursor #9dcbfb
|
||||
cursor_text_color #003354
|
||||
|
||||
foreground #e0e2e8
|
||||
background #101418
|
||||
|
||||
@@ -179,8 +179,8 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
|
||||
if err != nil {
|
||||
rel = filepath.Base(src)
|
||||
}
|
||||
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
|
||||
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
||||
dst, err := moveHyprlandConfigFile(src, filepath.Join(configDir, hyprlandBackupDirName, ts, rel))
|
||||
if err != nil {
|
||||
if logFn != nil {
|
||||
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
|
||||
}
|
||||
|
||||
@@ -3,21 +3,32 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Mirrors Hyprland's shipped target. Before=graphical-session.target would cycle with the add-wants implicit After=dms.service.
|
||||
const hyprlandSessionTargetUnit = `[Unit]
|
||||
Description=Hyprland Session Target
|
||||
BindsTo=graphical-session.target
|
||||
Wants=graphical-session-pre.target
|
||||
After=graphical-session-pre.target
|
||||
PropagatesStopTo=graphical-session.target
|
||||
`
|
||||
|
||||
var staleHyprlandSessionTargetUnits = []string{
|
||||
`[Unit]
|
||||
Description=Hyprland Session Target
|
||||
Requires=graphical-session.target
|
||||
After=graphical-session.target
|
||||
`,
|
||||
`[Unit]
|
||||
Description=Hyprland Session Target
|
||||
BindsTo=graphical-session.target
|
||||
Before=graphical-session.target
|
||||
Wants=graphical-session-pre.target
|
||||
After=graphical-session-pre.target
|
||||
`
|
||||
|
||||
const legacyHyprlandSessionTargetUnit = `[Unit]
|
||||
Description=Hyprland Session Target
|
||||
Requires=graphical-session.target
|
||||
After=graphical-session.target
|
||||
`
|
||||
`,
|
||||
}
|
||||
|
||||
// Only a missing or dankinstall-written unit is replaced; hand-written units are left alone.
|
||||
func EnsureHyprlandSessionTarget() (string, error) {
|
||||
@@ -30,10 +41,7 @@ func EnsureHyprlandSessionTarget() (string, error) {
|
||||
existing, err := os.ReadFile(targetPath)
|
||||
if err == nil {
|
||||
content := string(existing)
|
||||
if content == hyprlandSessionTargetUnit {
|
||||
return targetPath, nil
|
||||
}
|
||||
if content != legacyHyprlandSessionTargetUnit {
|
||||
if content == hyprlandSessionTargetUnit || !slices.Contains(staleHyprlandSessionTargetUnits, content) {
|
||||
return targetPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,23 +31,25 @@ func TestEnsureHyprlandSessionTargetWritesMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureHyprlandSessionTargetUpgradesLegacy(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
path := targetPathFor(t, home)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(legacyHyprlandSessionTargetUnit), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func TestEnsureHyprlandSessionTargetUpgradesStale(t *testing.T) {
|
||||
for _, stale := range staleHyprlandSessionTargetUnits {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
path := targetPathFor(t, home)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(stale), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := EnsureHyprlandSessionTarget(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if string(data) != hyprlandSessionTargetUnit {
|
||||
t.Fatalf("legacy unit not upgraded:\n%s", data)
|
||||
if _, err := EnsureHyprlandSessionTarget(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if string(data) != hyprlandSessionTargetUnit {
|
||||
t.Fatalf("stale unit not upgraded:\n%s", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -455,6 +455,7 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
|
||||
type PaletteOptions struct {
|
||||
IsLight bool
|
||||
Background string
|
||||
Container string
|
||||
UseDPS bool
|
||||
}
|
||||
|
||||
@@ -520,8 +521,18 @@ func DeriveContainer(primary string, isLight bool) string {
|
||||
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
|
||||
}
|
||||
|
||||
// DeriveContainer only approximates the Material tone pairing, so a caller that
|
||||
// has the real primary_container should pass it: color5 and color6 are the
|
||||
// primary/container pair and their contrast with each other is the invariant.
|
||||
func resolveContainer(primaryColor string, opts PaletteOptions) string {
|
||||
if opts.Container != "" {
|
||||
return opts.Container
|
||||
}
|
||||
return DeriveContainer(primaryColor, opts.IsLight)
|
||||
}
|
||||
|
||||
func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
baseColor := DeriveContainer(primaryColor, opts.IsLight)
|
||||
baseColor := resolveContainer(primaryColor, opts)
|
||||
|
||||
rgb := HexToRGB(baseColor)
|
||||
hsv := RGBToHSV(rgb)
|
||||
@@ -531,13 +542,20 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
|
||||
var palette Palette
|
||||
|
||||
var normalTextTarget, secondaryTarget float64
|
||||
if opts.UseDPS {
|
||||
// Delta Phi Star is not symmetric across polarity: on a light background the
|
||||
// same Lc buys far less real contrast, so the light targets are the numbers
|
||||
// that land on the same measured WCAG ratios the dark ones already deliver.
|
||||
var normalTextTarget, accentTarget float64
|
||||
switch {
|
||||
case opts.UseDPS && opts.IsLight:
|
||||
normalTextTarget = 70.0
|
||||
accentTarget = 58.0
|
||||
case opts.UseDPS:
|
||||
normalTextTarget = 40.0
|
||||
secondaryTarget = 35.0
|
||||
} else {
|
||||
accentTarget = 24.5
|
||||
default:
|
||||
normalTextTarget = 4.5
|
||||
secondaryTarget = 3.0
|
||||
accentTarget = 2.1
|
||||
}
|
||||
|
||||
var bgColor string
|
||||
@@ -557,8 +575,6 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
greenH := blendHue(0.33, ph.H, 0.10)
|
||||
yellowH := blendHue(0.14, ph.H, 0.04)
|
||||
|
||||
accentTarget := secondaryTarget * 0.7
|
||||
|
||||
if opts.IsLight {
|
||||
redS := math.Min(baseSat*1.2, 1.0)
|
||||
redV := baseVal * 0.95
|
||||
@@ -570,15 +586,13 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
|
||||
yellowS := math.Min(baseSat*1.5, 1.0)
|
||||
yellowV := math.Min(baseVal*1.2, 1.0)
|
||||
palette.Color3 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, accentTarget, opts))
|
||||
palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, opts))
|
||||
|
||||
blueS := math.Min(ph.S*1.05, 1.0)
|
||||
blueV := math.Min(ph.V*1.05, 1.0)
|
||||
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
|
||||
|
||||
// Color5 matches primary_container exactly (light container in light mode)
|
||||
container5 := DeriveContainer(primaryColor, true)
|
||||
palette.Color5 = NewColorInfo(container5)
|
||||
palette.Color5 = NewColorInfo(baseColor)
|
||||
|
||||
palette.Color6 = NewColorInfo(primaryColor)
|
||||
|
||||
@@ -604,8 +618,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
brightBlueV := math.Min(ph.V*1.15, 1.0)
|
||||
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
|
||||
|
||||
lightContainer := DeriveContainer(primaryColor, true)
|
||||
palette.Color13 = NewColorInfo(lightContainer)
|
||||
color13S := ph.S * 0.7
|
||||
color13V := math.Min(ph.V*1.3, 1.0)
|
||||
palette.Color13 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color13S, V: color13V})), bgColor, accentTarget, opts))
|
||||
|
||||
brightCyanS := ph.S * 0.5
|
||||
brightCyanV := math.Min(ph.V*1.3, 1.0)
|
||||
@@ -632,9 +647,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
blueV := ph.V * 0.95
|
||||
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
|
||||
|
||||
// Color5 matches primary_container exactly (dark container in dark mode)
|
||||
darkContainer := DeriveContainer(primaryColor, false)
|
||||
palette.Color5 = NewColorInfo(darkContainer)
|
||||
palette.Color5 = NewColorInfo(baseColor)
|
||||
|
||||
palette.Color6 = NewColorInfo(primaryColor)
|
||||
|
||||
@@ -681,11 +694,14 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
}
|
||||
|
||||
type VariantOptions struct {
|
||||
PrimaryDark string
|
||||
PrimaryLight string
|
||||
Background string
|
||||
UseDPS bool
|
||||
IsLightMode bool
|
||||
PrimaryDark string
|
||||
PrimaryLight string
|
||||
BackgroundDark string
|
||||
BackgroundLight string
|
||||
ContainerDark string
|
||||
ContainerLight string
|
||||
UseDPS bool
|
||||
IsLightMode bool
|
||||
}
|
||||
|
||||
func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
|
||||
@@ -702,8 +718,8 @@ func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
|
||||
}
|
||||
|
||||
func GenerateVariantPalette(opts VariantOptions) VariantPalette {
|
||||
darkOpts := PaletteOptions{IsLight: false, Background: opts.Background, UseDPS: opts.UseDPS}
|
||||
lightOpts := PaletteOptions{IsLight: true, Background: opts.Background, UseDPS: opts.UseDPS}
|
||||
darkOpts := PaletteOptions{IsLight: false, Background: opts.BackgroundDark, Container: opts.ContainerDark, UseDPS: opts.UseDPS}
|
||||
lightOpts := PaletteOptions{IsLight: true, Background: opts.BackgroundLight, Container: opts.ContainerLight, UseDPS: opts.UseDPS}
|
||||
|
||||
dark := GeneratePalette(opts.PrimaryDark, darkOpts)
|
||||
light := GeneratePalette(opts.PrimaryLight, lightOpts)
|
||||
|
||||
@@ -701,7 +701,8 @@ func TestGeneratePaletteWithDPS(t *testing.T) {
|
||||
|
||||
bgColor := result.Color0.Hex
|
||||
for i := 1; i < 8; i++ {
|
||||
// Skip Color5 (container) and Color6 (exact primary) - intentionally not contrast-adjusted
|
||||
// Color5 is the container and Color6 the exact primary: they pair
|
||||
// with each other, not with the background
|
||||
if i == 5 || i == 6 {
|
||||
continue
|
||||
}
|
||||
@@ -862,3 +863,112 @@ func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
|
||||
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePaletteLightYellowMeetsNormalTextContrast(t *testing.T) {
|
||||
primaries := []string{"#63568f", "#7d5800", "#116d2b", "#1c5070", "#8b3f68"}
|
||||
|
||||
for _, primary := range primaries {
|
||||
t.Run(primary, func(t *testing.T) {
|
||||
palette := GeneratePalette(primary, PaletteOptions{IsLight: true, Background: "#f5fafc", UseDPS: true})
|
||||
|
||||
ratio := ContrastRatio(palette.Color3.Hex, palette.Color0.Hex)
|
||||
if ratio < 4.5 {
|
||||
t.Errorf("light Color3 %s vs bg %s ratio %.2f, expected >= 4.5 (yellow must clear the normal-text target)",
|
||||
palette.Color3.Hex, palette.Color0.Hex, ratio)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateVariantPaletteUsesPerVariantBackground(t *testing.T) {
|
||||
const darkBg = "#0f1417"
|
||||
const lightBg = "#f5fafc"
|
||||
|
||||
variants := GenerateVariantPalette(VariantOptions{
|
||||
PrimaryDark: "#8ecff2",
|
||||
PrimaryLight: "#1c5070",
|
||||
BackgroundDark: darkBg,
|
||||
BackgroundLight: lightBg,
|
||||
UseDPS: true,
|
||||
})
|
||||
|
||||
if variants.Color0.Dark.Hex != darkBg {
|
||||
t.Errorf("dark Color0 = %s, expected the dark background %s", variants.Color0.Dark.Hex, darkBg)
|
||||
}
|
||||
if variants.Color0.Light.Hex != lightBg {
|
||||
t.Errorf("light Color0 = %s, expected the light background %s", variants.Color0.Light.Hex, lightBg)
|
||||
}
|
||||
|
||||
lightDim := variants.Color8.Light.Hex
|
||||
if lstar := getLstar(lightDim); lstar < 60.0 {
|
||||
t.Errorf("light Color8 %s has L* %.2f, expected >= 60 (derived from the dark surface it collapses to near-black)",
|
||||
lightDim, lstar)
|
||||
}
|
||||
if ratio := ContrastRatio(lightDim, lightBg); ratio > 3.0 {
|
||||
t.Errorf("light Color8 %s vs bg %s ratio %.2f, expected <= 3.0 (bright black stays dim)", lightDim, lightBg, ratio)
|
||||
}
|
||||
|
||||
darkDim := variants.Color8.Dark.Hex
|
||||
if ratio := ContrastRatio(darkDim, darkBg); ratio > 3.0 {
|
||||
t.Errorf("dark Color8 %s vs bg %s ratio %.2f, expected <= 3.0", darkDim, darkBg, ratio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePaletteColor5PairsWithColor6(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
primary string
|
||||
container string
|
||||
bg string
|
||||
isLight bool
|
||||
}{
|
||||
{"violet dark", "#cdbdff", "#4b3e76", "#141318", false},
|
||||
{"violet light", "#63568f", "#e8deff", "#fdf7ff", true},
|
||||
{"green dark", "#9ed49d", "#1f5027", "#101510", false},
|
||||
{"green light", "#38693c", "#b9f0b8", "#f7fbf2", true},
|
||||
{"amber light", "#805610", "#ffddb3", "#fff8f4", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
palette := GeneratePalette(tt.primary, PaletteOptions{
|
||||
IsLight: tt.isLight,
|
||||
Background: tt.bg,
|
||||
Container: tt.container,
|
||||
UseDPS: true,
|
||||
})
|
||||
|
||||
if palette.Color5.Hex != tt.container {
|
||||
t.Errorf("Color5 = %s, expected the supplied container %s", palette.Color5.Hex, tt.container)
|
||||
}
|
||||
if palette.Color6.Hex != tt.primary {
|
||||
t.Errorf("Color6 = %s, expected the primary %s", palette.Color6.Hex, tt.primary)
|
||||
}
|
||||
|
||||
pair := ContrastRatio(palette.Color5.Hex, palette.Color6.Hex)
|
||||
if pair < 4.5 {
|
||||
t.Errorf("Color5 %s vs Color6 %s ratio %.2f, expected >= 4.5 (the container/primary pair carries the prompt themes that use ANSI 5 on 6)",
|
||||
palette.Color5.Hex, palette.Color6.Hex, pair)
|
||||
}
|
||||
|
||||
if palette.Color5.Hex == palette.Color13.Hex {
|
||||
t.Errorf("Color5 and Color13 are both %s, expected a distinct bright variant", palette.Color5.Hex)
|
||||
}
|
||||
if sep := ContrastRatio(palette.Color5.Hex, palette.Color13.Hex); sep < 2.0 {
|
||||
t.Errorf("Color5 %s vs Color13 %s ratio %.2f, expected >= 2.0 (normal and bright must be tellable apart)",
|
||||
palette.Color5.Hex, palette.Color13.Hex, sep)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePaletteFallsBackToDerivedContainer(t *testing.T) {
|
||||
const primary = "#cdbdff"
|
||||
|
||||
palette := GeneratePalette(primary, PaletteOptions{Background: "#141318", UseDPS: true})
|
||||
|
||||
want := DeriveContainer(primary, false)
|
||||
if palette.Color5.Hex != want {
|
||||
t.Errorf("Color5 = %s with no container supplied, expected the derived %s", palette.Color5.Hex, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
|
||||
oldColors, _ := os.ReadFile(opts.ColorsOutput())
|
||||
|
||||
var primaryDark, primaryLight, surface string
|
||||
var primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string
|
||||
var dank16JSON string
|
||||
var importArgs []string
|
||||
var sourceImage string
|
||||
@@ -373,7 +373,10 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
log.Info("Using stock/custom theme colors with matugen base")
|
||||
primaryDark = extractNestedColor(opts.StockColors, "primary", "dark")
|
||||
primaryLight = extractNestedColor(opts.StockColors, "primary", "light")
|
||||
surface = extractNestedColor(opts.StockColors, "surface", "dark")
|
||||
surfaceDark = extractNestedColor(opts.StockColors, "surface", "dark")
|
||||
surfaceLight = extractNestedColor(opts.StockColors, "surface", "light")
|
||||
containerDark = extractNestedColor(opts.StockColors, "primary_container", "dark")
|
||||
containerLight = extractNestedColor(opts.StockColors, "primary_container", "light")
|
||||
|
||||
if primaryDark == "" {
|
||||
return false, fmt.Errorf("failed to extract primary dark from stock colors")
|
||||
@@ -382,7 +385,7 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
primaryLight = primaryDark
|
||||
}
|
||||
|
||||
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
|
||||
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
|
||||
importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON)
|
||||
importArgs = []string{"--import-json-string", importData}
|
||||
|
||||
@@ -403,7 +406,10 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
|
||||
primaryDark = extractMatugenColor(matJSON, "primary", "dark")
|
||||
primaryLight = extractMatugenColor(matJSON, "primary", "light")
|
||||
surface = extractMatugenColor(matJSON, "surface", "dark")
|
||||
surfaceDark = extractMatugenColor(matJSON, "surface", "dark")
|
||||
surfaceLight = extractMatugenColor(matJSON, "surface", "light")
|
||||
containerDark = extractMatugenColor(matJSON, "primary_container", "dark")
|
||||
containerLight = extractMatugenColor(matJSON, "primary_container", "light")
|
||||
|
||||
if primaryDark == "" {
|
||||
return false, fmt.Errorf("failed to extract primary color")
|
||||
@@ -412,7 +418,7 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
primaryLight = primaryDark
|
||||
}
|
||||
|
||||
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
|
||||
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
|
||||
importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage)}
|
||||
|
||||
log.Infof("Running matugen %s with dank16 injection", opts.Kind)
|
||||
@@ -1125,13 +1131,16 @@ func extractNestedColor(jsonStr, colorName, variant string) string {
|
||||
return color
|
||||
}
|
||||
|
||||
func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string {
|
||||
func generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string, mode ColorMode) string {
|
||||
variantOpts := dank16.VariantOptions{
|
||||
PrimaryDark: primaryDark,
|
||||
PrimaryLight: primaryLight,
|
||||
Background: surface,
|
||||
UseDPS: true,
|
||||
IsLightMode: mode == ColorModeLight,
|
||||
PrimaryDark: primaryDark,
|
||||
PrimaryLight: primaryLight,
|
||||
BackgroundDark: surfaceDark,
|
||||
BackgroundLight: surfaceLight,
|
||||
ContainerDark: containerDark,
|
||||
ContainerLight: containerLight,
|
||||
UseDPS: true,
|
||||
IsLightMode: mode == ColorModeLight,
|
||||
}
|
||||
variantColors := dank16.GenerateVariantPalette(variantOpts)
|
||||
return dank16.GenerateVariantJSON(variantColors)
|
||||
|
||||
@@ -3,6 +3,7 @@ package qmlchecks
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -55,3 +56,51 @@ func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
|
||||
t.Fatalf("DMS-managed policy must generate the sanitized user PAM stack on Nix-store installs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockScreenFprintProfileMatchesPamQml(t *testing.T) {
|
||||
profile, err := os.ReadFile("../../../quickshell/assets/pam/fprint")
|
||||
if err != nil {
|
||||
t.Fatalf("read fprint PAM profile: %v", err)
|
||||
}
|
||||
|
||||
qml, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml")
|
||||
if err != nil {
|
||||
t.Fatalf("read lock screen PAM QML: %v", err)
|
||||
}
|
||||
|
||||
// Pam.qml tells a pam_fprintd timeout expiry apart from a device fault by
|
||||
// how long the session ran, so a profile change that is not mirrored in
|
||||
// sessionTimeoutMs makes real faults look like planned renewals.
|
||||
profileTimeout := regexp.MustCompile(`pam_fprintd\.so[^\n]*\btimeout=(\d+)`).FindSubmatch(profile)
|
||||
if profileTimeout == nil {
|
||||
t.Fatalf("fprint profile must set an explicit pam_fprintd timeout")
|
||||
}
|
||||
qmlTimeout := regexp.MustCompile(`sessionTimeoutMs:\s*(\d+)`).FindSubmatch(qml)
|
||||
if qmlTimeout == nil {
|
||||
t.Fatalf("Pam.qml must declare sessionTimeoutMs")
|
||||
}
|
||||
|
||||
seconds, err := strconv.Atoi(string(profileTimeout[1]))
|
||||
if err != nil {
|
||||
t.Fatalf("parse profile timeout: %v", err)
|
||||
}
|
||||
millis, err := strconv.Atoi(string(qmlTimeout[1]))
|
||||
if err != nil {
|
||||
t.Fatalf("parse sessionTimeoutMs: %v", err)
|
||||
}
|
||||
if seconds*1000 != millis {
|
||||
t.Fatalf("sessionTimeoutMs is %dms but the fprint profile sets timeout=%d", millis, seconds)
|
||||
}
|
||||
|
||||
// fprintd 1.94.0 and older ignore this option when it is longer than two
|
||||
// digits and silently fall back to 30 seconds.
|
||||
if len(profileTimeout[1]) > 2 {
|
||||
t.Fatalf("pam_fprintd timeout=%s is too long for fprintd <= 1.94.0, which would ignore it", profileTimeout[1])
|
||||
}
|
||||
|
||||
// One finished session must equal one wrong finger, otherwise
|
||||
// SettingsData.maxFprintTries silently means a multiple of the scans it names.
|
||||
if !regexp.MustCompile(`pam_fprintd\.so[^\n]*\bmax-tries=1\b`).Match(profile) {
|
||||
t.Fatalf("fprint profile must use max-tries=1 so the shell counts wrong scans one for one")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +85,10 @@ type OutputSurface struct {
|
||||
slots [3]*RenderSlot
|
||||
slotsReady bool
|
||||
|
||||
shown *overlay
|
||||
framePending bool
|
||||
redrawQueued bool
|
||||
shown *overlay
|
||||
framePending bool
|
||||
redrawQueued bool
|
||||
committedCursor bool
|
||||
}
|
||||
|
||||
type PreCapture struct {
|
||||
@@ -108,6 +109,7 @@ type RegionSelector struct {
|
||||
shm *client.Shm
|
||||
seat *client.Seat
|
||||
pointer *client.Pointer
|
||||
touch *client.Touch
|
||||
keyboard *client.Keyboard
|
||||
keymap *keymap.Keymap
|
||||
layerShell *wlr_layer_shell.ZwlrLayerShellV1
|
||||
@@ -138,6 +140,8 @@ type RegionSelector struct {
|
||||
selection SelectionState
|
||||
pointerX float64
|
||||
pointerY float64
|
||||
hasTouchPoint bool
|
||||
touchPointId int32
|
||||
preSelect Region
|
||||
showCapturedCursor bool
|
||||
shiftHeld bool
|
||||
@@ -969,11 +973,12 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
||||
case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase, handles, shift):
|
||||
slot.shm.CopyFrom(srcBuf)
|
||||
r.dimBackground(slot.shm)
|
||||
scale := 1
|
||||
effectiveScale := 1.0
|
||||
if os.output != nil {
|
||||
scale = scaleFactor(os.output.effectiveScale())
|
||||
effectiveScale = os.output.effectiveScale()
|
||||
}
|
||||
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat, scale)
|
||||
hudScale := r.hudScale(effectiveScale)
|
||||
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat, hudScale)
|
||||
slot.backgroundInitialized = true
|
||||
slot.backgroundSource = srcBuf
|
||||
slot.backgroundDragging = r.selection.dragging
|
||||
@@ -985,7 +990,10 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
||||
case r.compositorVersion >= 4:
|
||||
fullDamage = false
|
||||
damage = overlayDamage(os.shown, cur)
|
||||
if len(damage) == 0 {
|
||||
if os.committedCursor != r.showCapturedCursor {
|
||||
fullDamage = true
|
||||
damage = nil
|
||||
} else if len(damage) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1029,6 +1037,7 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
||||
})
|
||||
}
|
||||
_ = os.wlSurface.Commit()
|
||||
os.committedCursor = r.showCapturedCursor
|
||||
|
||||
// Mark this slot as busy until compositor releases it
|
||||
slot.busy = true
|
||||
@@ -1103,6 +1112,9 @@ func (r *RegionSelector) cleanup() {
|
||||
if r.pointer != nil {
|
||||
r.pointer.Release()
|
||||
}
|
||||
if r.touch != nil {
|
||||
_ = r.touch.Release()
|
||||
}
|
||||
if r.keyboard != nil {
|
||||
r.keyboard.Release()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ func (r *RegionSelector) setupInput() {
|
||||
r.setupPointerHandlers()
|
||||
}
|
||||
}
|
||||
if e.Capabilities&uint32(client.SeatCapabilityTouch) != 0 && r.touch == nil {
|
||||
if touch, err := r.seat.GetTouch(); err == nil {
|
||||
r.touch = touch
|
||||
r.setupTouchHandlers()
|
||||
}
|
||||
}
|
||||
if e.Capabilities&uint32(client.SeatCapabilityKeyboard) != 0 && r.keyboard == nil {
|
||||
if keyboard, err := r.seat.GetKeyboard(); err == nil {
|
||||
r.keyboard = keyboard
|
||||
@@ -146,6 +152,143 @@ func (r *RegionSelector) setupPointerHandlers() {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *RegionSelector) setupTouchHandlers() {
|
||||
r.touch.SetDownHandler(func(e client.TouchDownEvent) {
|
||||
r.handleTouchDown(e.Surface, e.Id, e.X, e.Y)
|
||||
})
|
||||
|
||||
r.touch.SetMotionHandler(func(e client.TouchMotionEvent) {
|
||||
r.handleTouchMotion(e.Id, e.X, e.Y)
|
||||
})
|
||||
|
||||
r.touch.SetUpHandler(func(e client.TouchUpEvent) {
|
||||
r.handleTouchUp(e.Id)
|
||||
})
|
||||
|
||||
r.touch.SetCancelHandler(func(e client.TouchCancelEvent) {
|
||||
r.handleTouchCancel()
|
||||
})
|
||||
}
|
||||
|
||||
func (r *RegionSelector) handleTouchDown(surface *client.Surface, touchId int32, x, y float64) {
|
||||
if r.hasTouchPoint {
|
||||
return
|
||||
}
|
||||
|
||||
r.activeSurface = nil
|
||||
for _, os := range r.surfaces {
|
||||
if os.wlSurface != nil && surface != nil && os.wlSurface.ID() == surface.ID() {
|
||||
r.activeSurface = os
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if r.activeSurface == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.hasTouchPoint = true
|
||||
r.touchPointId = touchId
|
||||
|
||||
if r.phase == phaseScroll {
|
||||
if r.activeSurface != r.selection.surface {
|
||||
return
|
||||
}
|
||||
switch r.scrollBarHit(x, y) {
|
||||
case "done", "preview":
|
||||
r.finishScroll()
|
||||
case "cancel":
|
||||
r.cancelled = true
|
||||
r.running = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
touchX := x + float64(r.activeSurface.output.x)
|
||||
touchY := y + float64(r.activeSurface.output.y)
|
||||
|
||||
if r.ctrlHeld && r.selection.hasSelection {
|
||||
if handle := r.resizeHandleAt(touchX, touchY); handle != handleNone {
|
||||
if r.beginSelectionResize(handle, touchX, touchY) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.beginSelectionMove(touchX, touchY) {
|
||||
r.selection.dragging = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
r.preSelect = Region{}
|
||||
r.movingSelection = false
|
||||
r.resizingHandle = handleNone
|
||||
r.selection.hasSelection = true
|
||||
r.selection.dragging = true
|
||||
r.selection.surface = r.activeSurface
|
||||
r.selection.anchorX = touchX
|
||||
r.selection.anchorY = touchY
|
||||
r.selection.currentX = r.selection.anchorX
|
||||
r.selection.currentY = r.selection.anchorY
|
||||
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RegionSelector) handleTouchMotion(touchId int32, x, y float64) {
|
||||
if !r.hasTouchPoint || touchId != r.touchPointId || r.activeSurface == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if r.phase == phaseScroll {
|
||||
return
|
||||
}
|
||||
|
||||
if !r.selection.dragging {
|
||||
return
|
||||
}
|
||||
|
||||
r.updateSelectionCurrent(r.activeSurface, x, y)
|
||||
}
|
||||
|
||||
func (r *RegionSelector) handleTouchUp(touchId int32) {
|
||||
if !r.hasTouchPoint || touchId != r.touchPointId {
|
||||
return
|
||||
}
|
||||
|
||||
r.hasTouchPoint = false
|
||||
r.selection.dragging = false
|
||||
r.movingSelection = false
|
||||
r.resizingHandle = handleNone
|
||||
|
||||
if r.phase == phaseScroll {
|
||||
return
|
||||
}
|
||||
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
|
||||
if r.screenshoter != nil && r.screenshoter.config.NoConfirm && r.selection.hasSelection {
|
||||
r.finishSelection()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RegionSelector) handleTouchCancel() {
|
||||
if !r.hasTouchPoint {
|
||||
return
|
||||
}
|
||||
|
||||
r.hasTouchPoint = false
|
||||
r.selection.dragging = false
|
||||
r.movingSelection = false
|
||||
r.resizingHandle = handleNone
|
||||
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RegionSelector) updateSelectionCurrent(os *OutputSurface, surfaceX, surfaceY float64) {
|
||||
if os == nil || os.output == nil || !r.selection.dragging {
|
||||
return
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -630,6 +632,32 @@ func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, form
|
||||
style.TextR, style.TextG, style.TextB, format)
|
||||
}
|
||||
|
||||
const (
|
||||
minHUDScale = 1
|
||||
maxHUDScale = 4
|
||||
)
|
||||
|
||||
func (r *RegionSelector) hudScale(effectiveScale float64) int {
|
||||
hudOpt := "auto"
|
||||
if r.screenshoter != nil && r.screenshoter.config.HUD != "" {
|
||||
hudOpt = strings.ToLower(strings.TrimSpace(r.screenshoter.config.HUD))
|
||||
}
|
||||
switch hudOpt {
|
||||
case "off", "none", "false", "0":
|
||||
return 0
|
||||
case "", "auto":
|
||||
return min(scaleFactor(effectiveScale), maxHUDScale)
|
||||
default:
|
||||
if val, err := strconv.ParseFloat(hudOpt, 64); err == nil {
|
||||
if val <= 0 {
|
||||
return 0
|
||||
}
|
||||
return min(max(scaleFactor(val), minHUDScale), maxHUDScale)
|
||||
}
|
||||
return min(scaleFactor(effectiveScale), maxHUDScale)
|
||||
}
|
||||
}
|
||||
|
||||
type hudItem struct {
|
||||
key, desc string
|
||||
}
|
||||
@@ -657,6 +685,9 @@ func (r *RegionSelector) hudItems() []hudItem {
|
||||
}
|
||||
|
||||
func (r *RegionSelector) hudDimensions(bufW, bufH, scale int) (hudX, hudY, hudW, hudH int) {
|
||||
if scale <= 0 {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
scale = scaleFactor(float64(scale))
|
||||
charH := fontCharH * scale
|
||||
padding, itemSpacing := 12*scale, 24*scale
|
||||
@@ -680,7 +711,7 @@ func (r *RegionSelector) hudDimensions(bufW, bufH, scale int) (hudX, hudY, hudW,
|
||||
}
|
||||
|
||||
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32, scale int) {
|
||||
if r.selection.dragging {
|
||||
if r.selection.dragging || scale <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,12 @@ func TestHUDDimensionsContainText(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// When scale <= 0, hudDimensions should return zero
|
||||
hx, hy, hw, hh := r.hudDimensions(bufW, bufH, 0)
|
||||
if hx != 0 || hy != 0 || hw != 0 || hh != 0 {
|
||||
t.Fatalf("expected 0 dimensions for scale 0, got %d, %d, %d, %d", hx, hy, hw, hh)
|
||||
}
|
||||
|
||||
for _, scale := range []int{1, 2} {
|
||||
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
|
||||
data := make([]byte, bufH*stride)
|
||||
@@ -260,6 +266,47 @@ func TestHUDDimensionsContainText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHUDScale(t *testing.T) {
|
||||
tests := []struct {
|
||||
hudOpt string
|
||||
effectiveScale float64
|
||||
expectedScale int
|
||||
}{
|
||||
{"auto", 1.0, 1},
|
||||
{"auto", 1.8, 2},
|
||||
{"auto", 2.4, 2},
|
||||
{"", 1.0, 1},
|
||||
{"", 2.0, 2},
|
||||
{"off", 1.0, 0},
|
||||
{"OFF", 2.0, 0},
|
||||
{"none", 1.5, 0},
|
||||
{"false", 1.0, 0},
|
||||
{"0", 2.0, 0},
|
||||
{"0.0", 1.0, 0},
|
||||
{"-1", 1.0, 0},
|
||||
{"1", 1.0, 1},
|
||||
{"1.5", 1.0, 2},
|
||||
{"2", 1.0, 2},
|
||||
{"3", 1.0, 3},
|
||||
{"4", 1.0, 4},
|
||||
{"1000", 1.0, 4},
|
||||
{"0.2", 1.0, 1},
|
||||
{"invalid", 1.7, 2},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
r := &RegionSelector{
|
||||
screenshoter: &Screenshoter{
|
||||
config: Config{HUD: tc.hudOpt},
|
||||
},
|
||||
}
|
||||
got := r.hudScale(tc.effectiveScale)
|
||||
if got != tc.expectedScale {
|
||||
t.Errorf("hudScale(%q, %v) = %d, expected %d", tc.hudOpt, tc.effectiveScale, got, tc.expectedScale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayDeltaHandlesClampedMonitorEdge(t *testing.T) {
|
||||
prev := &overlay{
|
||||
interior: dirtyRect{100, 100, 1920, 1080},
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
)
|
||||
|
||||
func newTestSurface(id uint32) *client.Surface {
|
||||
s := &client.Surface{}
|
||||
s.SetID(id)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestTouchInputSequence(t *testing.T) {
|
||||
surface := newTestSurface(100)
|
||||
|
||||
os := &OutputSurface{
|
||||
output: &WaylandOutput{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
logicalW: 1920,
|
||||
logicalH: 1080,
|
||||
wlSurface: surface,
|
||||
}
|
||||
|
||||
r := &RegionSelector{
|
||||
running: true,
|
||||
surfaces: []*OutputSurface{os},
|
||||
}
|
||||
|
||||
// 1. Touch Down
|
||||
r.handleTouchDown(surface, 1, 100, 200)
|
||||
|
||||
if !r.hasTouchPoint || r.touchPointId != 1 {
|
||||
t.Fatalf("expected hasTouchPoint=true, touchPointId=1, got hasTouchPoint=%v, id=%d", r.hasTouchPoint, r.touchPointId)
|
||||
}
|
||||
if !r.selection.dragging || !r.selection.hasSelection {
|
||||
t.Fatalf("expected dragging=true, hasSelection=true")
|
||||
}
|
||||
if r.selection.anchorX != 100 || r.selection.anchorY != 200 {
|
||||
t.Fatalf("expected anchor (100, 200), got (%.1f, %.1f)", r.selection.anchorX, r.selection.anchorY)
|
||||
}
|
||||
|
||||
// 2. Ignore secondary touch point while primary is active
|
||||
surface2 := newTestSurface(200)
|
||||
r.handleTouchDown(surface2, 2, 500, 600)
|
||||
if r.touchPointId != 1 {
|
||||
t.Fatalf("expected touchPointId to stay 1, got %d", r.touchPointId)
|
||||
}
|
||||
|
||||
// 3. Touch Motion with matching id
|
||||
r.handleTouchMotion(1, 400, 500)
|
||||
if r.selection.currentX != 400 || r.selection.currentY != 500 {
|
||||
t.Fatalf("expected current (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
|
||||
}
|
||||
|
||||
// 4. Touch Motion with mismatched id should be ignored
|
||||
r.handleTouchMotion(2, 900, 900)
|
||||
if r.selection.currentX != 400 || r.selection.currentY != 500 {
|
||||
t.Fatalf("expected current to remain (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
|
||||
}
|
||||
|
||||
// 5. Touch Up with mismatched id should not release
|
||||
r.handleTouchUp(2)
|
||||
if !r.hasTouchPoint || !r.selection.dragging {
|
||||
t.Fatalf("expected touch point to remain active on mismatched touch up")
|
||||
}
|
||||
|
||||
// 6. Touch Up with matching id
|
||||
r.handleTouchUp(1)
|
||||
if r.hasTouchPoint || r.selection.dragging {
|
||||
t.Fatalf("expected hasTouchPoint=false, dragging=false after touch up")
|
||||
}
|
||||
if !r.selection.hasSelection {
|
||||
t.Fatalf("expected selection to be preserved after touch up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchCancel(t *testing.T) {
|
||||
surface := newTestSurface(100)
|
||||
|
||||
os := &OutputSurface{
|
||||
output: &WaylandOutput{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
logicalW: 1920,
|
||||
logicalH: 1080,
|
||||
wlSurface: surface,
|
||||
}
|
||||
|
||||
r := &RegionSelector{
|
||||
running: true,
|
||||
surfaces: []*OutputSurface{os},
|
||||
}
|
||||
|
||||
r.handleTouchDown(surface, 5, 50, 60)
|
||||
if !r.hasTouchPoint || !r.selection.dragging {
|
||||
t.Fatalf("expected active touch point")
|
||||
}
|
||||
|
||||
r.handleTouchCancel()
|
||||
if r.hasTouchPoint || r.selection.dragging {
|
||||
t.Fatalf("expected touch point and dragging to be cleared on cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchUpScrollPhase(t *testing.T) {
|
||||
surface := newTestSurface(100)
|
||||
|
||||
os := &OutputSurface{
|
||||
output: &WaylandOutput{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
logicalW: 1920,
|
||||
logicalH: 1080,
|
||||
wlSurface: surface,
|
||||
}
|
||||
|
||||
sc := &Screenshoter{
|
||||
config: Config{NoConfirm: true},
|
||||
}
|
||||
|
||||
r := &RegionSelector{
|
||||
screenshoter: sc,
|
||||
phase: phaseScroll,
|
||||
running: true,
|
||||
surfaces: []*OutputSurface{os},
|
||||
selection: SelectionState{
|
||||
hasSelection: true,
|
||||
surface: os,
|
||||
},
|
||||
}
|
||||
|
||||
r.hasTouchPoint = true
|
||||
r.touchPointId = 3
|
||||
r.selection.dragging = true
|
||||
|
||||
r.handleTouchUp(3)
|
||||
|
||||
if r.hasTouchPoint || r.selection.dragging {
|
||||
t.Fatalf("expected touch point and dragging cleared")
|
||||
}
|
||||
|
||||
// In scroll phase with NoConfirm, handleTouchUp must not call finishSelection / change running state
|
||||
if !r.running {
|
||||
t.Fatalf("expected running to stay true, did not expect finishSelection side-effects")
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ type Config struct {
|
||||
Geometry bool
|
||||
AllowMultiple bool
|
||||
IntervalMs int
|
||||
HUD string
|
||||
// SelectorHook runs as the interactive selector starts (true) and ends (false).
|
||||
SelectorHook func(begin bool)
|
||||
}
|
||||
@@ -90,5 +91,6 @@ func DefaultConfig() Config {
|
||||
Clipboard: true,
|
||||
SaveFile: true,
|
||||
Notify: true,
|
||||
HUD: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ const (
|
||||
propertiesIface = "org.freedesktop.DBus.Properties"
|
||||
)
|
||||
|
||||
// Sender-scoped: the system bus is shared process-wide, so unscoped rules wake
|
||||
// every other subsystem's signal channel too.
|
||||
var bluezMatchRules = [][]dbus.MatchOption{
|
||||
{dbus.WithMatchInterface(propertiesIface), dbus.WithMatchMember("PropertiesChanged")},
|
||||
{dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")},
|
||||
{dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesRemoved")},
|
||||
{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(propertiesIface), dbus.WithMatchMember("PropertiesChanged")},
|
||||
{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")},
|
||||
{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesRemoved")},
|
||||
}
|
||||
|
||||
func NewManager() (*Manager, error) {
|
||||
|
||||
@@ -19,7 +19,7 @@ func WaitForAdapter() error {
|
||||
conn.Signal(signals)
|
||||
defer conn.RemoveSignal(signals)
|
||||
|
||||
adapterAdded := []dbus.MatchOption{dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")}
|
||||
adapterAdded := []dbus.MatchOption{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")}
|
||||
if err := conn.AddMatchSignal(adapterAdded...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package brightness
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
@@ -20,13 +21,40 @@ const (
|
||||
DDCCI_VCP_SET = 0x03
|
||||
VCP_BRIGHTNESS = 0x10
|
||||
DDC_SOURCE_ADDR = 0x51
|
||||
EDID_ADDR = 0x50
|
||||
)
|
||||
|
||||
// ddcutil's DEFAULT_FLOCK_POLL_MILLISEC and DEFAULT_FLOCK_MAX_WAIT_MILLISEC (src/base/parms.h).
|
||||
const (
|
||||
ddcBusLockPoll = 100 * time.Millisecond
|
||||
ddcBusLockWait = 3 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
errDDCDisabled = errors.New("disabled via DMS_NO_DDC")
|
||||
errInvalidEDID = errors.New("invalid edid")
|
||||
errDisplayAsleep = errors.New("display asleep")
|
||||
errNoMonitor = errors.New("no monitor on bus")
|
||||
errBusReused = errors.New("bus belongs to another adapter")
|
||||
)
|
||||
|
||||
var ddcRereadDelays = []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second}
|
||||
|
||||
func NewDDCBackend() (*DDCBackend, error) {
|
||||
return newDDCBackend(nil)
|
||||
}
|
||||
|
||||
func newDDCBackend(onReread func()) (*DDCBackend, error) {
|
||||
if os.Getenv("DMS_NO_DDC") != "" {
|
||||
return nil, errDDCDisabled
|
||||
}
|
||||
|
||||
b := &DDCBackend{
|
||||
scanInterval: 30 * time.Second,
|
||||
debounceTimers: make(map[string]*time.Timer),
|
||||
debouncePending: make(map[string]ddcPendingSet),
|
||||
onReread: onReread,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
|
||||
if err := b.scanI2CDevices(); err != nil {
|
||||
@@ -53,6 +81,7 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
|
||||
}
|
||||
|
||||
activeBuses := make(map[int]bool)
|
||||
connectors := drmConnectorsByBus()
|
||||
|
||||
for i := range 32 {
|
||||
busPath := fmt.Sprintf("/dev/i2c-%d", i)
|
||||
@@ -70,18 +99,36 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
|
||||
|
||||
// Don't re-probe identified monitors: DDC traffic during a wake
|
||||
// sequence can disturb some monitors' own brightness handling.
|
||||
if _, ok := b.devices.Load(id); ok {
|
||||
if known, ok := b.devices.Load(id); ok {
|
||||
if known.adapter == getI2CDeviceSysfsName(i) {
|
||||
continue
|
||||
}
|
||||
b.devices.Delete(id)
|
||||
log.Debugf("removed DDC device %s (bus number reused by another adapter)", id)
|
||||
}
|
||||
|
||||
verdict := ddcBusVerdictFor(i, connectors)
|
||||
if verdict == ddcBusSkip {
|
||||
log.Debugf("i2c-%d: no monitor per DRM sysfs, not probing", i)
|
||||
continue
|
||||
}
|
||||
if ddcDisplayAsleep(i, connectors) {
|
||||
log.Debugf("i2c-%d: display asleep, not probing", i)
|
||||
continue
|
||||
}
|
||||
|
||||
dev, err := b.probeDDCDevice(i)
|
||||
dev, err := b.probeDDCDevice(i, verdict == ddcBusNeedsEDIDRead)
|
||||
if err != nil || dev == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dev.id = id
|
||||
unread := dev.max == 0
|
||||
b.devices.Store(id, dev)
|
||||
log.Debugf("found DDC device on i2c-%d", i)
|
||||
if unread && b.onReread != nil {
|
||||
go b.rereadUntilKnown(dev)
|
||||
}
|
||||
}
|
||||
|
||||
b.devices.Range(func(id string, dev *ddcDevice) bool {
|
||||
@@ -97,53 +144,119 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DDCBackend) probeDDCDevice(bus int) (*ddcDevice, error) {
|
||||
busPath := fmt.Sprintf("/dev/i2c-%d", bus)
|
||||
|
||||
fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
|
||||
func (b *DDCBackend) probeDDCDevice(bus int, readEDID bool) (*ddcDevice, error) {
|
||||
fd, err := openDDCBus(bus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 {
|
||||
return nil, errno
|
||||
}
|
||||
|
||||
dummy := make([]byte, 32)
|
||||
syscall.Read(fd, dummy) //nolint:errcheck
|
||||
|
||||
writebuf := []byte{DDC_SOURCE_ADDR, 0x80}
|
||||
writebuf = append(writebuf, ddcciChecksum(writebuf))
|
||||
n, err := syscall.Write(fd, writebuf)
|
||||
if err == nil && n == len(writebuf) {
|
||||
name := b.getDDCName(bus)
|
||||
dev := &ddcDevice{
|
||||
bus: bus,
|
||||
addr: DDCCI_ADDR,
|
||||
name: name,
|
||||
if readEDID {
|
||||
edid, err := readBusEDID(fd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isLaptopEDID(edid) {
|
||||
return nil, errors.New("laptop panel")
|
||||
}
|
||||
b.readInitialBrightness(fd, dev)
|
||||
return dev, nil
|
||||
}
|
||||
|
||||
readbuf := make([]byte, 4)
|
||||
n, err = syscall.Read(fd, readbuf)
|
||||
if err != nil || n == 0 {
|
||||
if err := setI2CAddr(fd, DDCCI_ADDR); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !detectX37(fd) {
|
||||
return nil, fmt.Errorf("x37 unresponsive")
|
||||
}
|
||||
|
||||
name := b.getDDCName(bus)
|
||||
|
||||
dev := &ddcDevice{
|
||||
bus: bus,
|
||||
addr: DDCCI_ADDR,
|
||||
name: name,
|
||||
bus: bus,
|
||||
addr: DDCCI_ADDR,
|
||||
name: b.getDDCName(bus),
|
||||
adapter: getI2CDeviceSysfsName(bus),
|
||||
}
|
||||
b.readInitialBrightness(fd, dev)
|
||||
return dev, nil
|
||||
}
|
||||
|
||||
// Based on ddcutil's i2c_open_bus() cross-instance lock (src/base/flock.c): ddcutil, other dms
|
||||
// processes and this one take turns on a bus.
|
||||
func openDDCBus(bus int) (int, error) {
|
||||
// Without O_CLOEXEC a process spawned mid-probe inherits the fd and the flock with it.
|
||||
fd, err := syscall.Open(fmt.Sprintf("/dev/i2c-%d", bus), syscall.O_RDWR|syscall.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
deadline := time.Now().Add(ddcBusLockWait)
|
||||
for {
|
||||
err = unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB)
|
||||
if err == nil {
|
||||
return fd, nil
|
||||
}
|
||||
if !errors.Is(err, unix.EWOULDBLOCK) || time.Now().After(deadline) {
|
||||
syscall.Close(fd)
|
||||
return -1, fmt.Errorf("lock i2c-%d: %w", bus, err)
|
||||
}
|
||||
time.Sleep(ddcBusLockPoll)
|
||||
}
|
||||
}
|
||||
|
||||
func setI2CAddr(fd, addr int) error {
|
||||
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(addr)); errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Based on ddcutil's i2c_detect_x37() (src/i2c/i2c_x37.c).
|
||||
func detectX37(fd int) bool {
|
||||
if n, err := syscall.Read(fd, make([]byte, 1)); err == nil && n == 1 {
|
||||
return true
|
||||
}
|
||||
_, err := syscall.Write(fd, nil)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Based on ddcutil's i2c_get_raw_edid_by_fd() with the fileio reader (src/i2c/i2c_edid.c).
|
||||
func readBusEDID(fd int) ([]byte, error) {
|
||||
if err := setI2CAddr(fd, EDID_ADDR); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var err error
|
||||
consecutiveEIO := 0
|
||||
for _, size := range []int{128, 128, 256, 256} {
|
||||
edid := make([]byte, size)
|
||||
if err = readEDIDOnce(fd, edid); err == nil {
|
||||
return edid[:128], nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, syscall.ENXIO), errors.Is(err, syscall.EOPNOTSUPP), errors.Is(err, syscall.ETIMEDOUT):
|
||||
return nil, err
|
||||
case errors.Is(err, syscall.EIO):
|
||||
consecutiveEIO++
|
||||
if consecutiveEIO >= 2 {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
consecutiveEIO = 0
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func readEDIDOnce(fd int, edid []byte) error {
|
||||
if _, err := syscall.Write(fd, []byte{0x00}); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := syscall.Read(fd, edid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n < 128 || !isValidEDID(edid[:128]) {
|
||||
return errInvalidEDID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DDCBackend) getDDCName(bus int) string {
|
||||
sysfsPath := fmt.Sprintf("/sys/class/i2c-adapter/i2c-%d/name", bus)
|
||||
data, err := os.ReadFile(sysfsPath)
|
||||
@@ -176,6 +289,63 @@ func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded and spaced: extra DDC traffic can upset some monitors (#2049).
|
||||
func (b *DDCBackend) rereadUntilKnown(dev *ddcDevice) {
|
||||
for _, delay := range ddcRereadDelays {
|
||||
select {
|
||||
case <-b.stop:
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
read, retry := b.rereadBrightness(dev)
|
||||
if read {
|
||||
b.onReread()
|
||||
return
|
||||
}
|
||||
if !retry {
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Debugf("giving up on reading brightness for %s", dev.id)
|
||||
}
|
||||
|
||||
func (b *DDCBackend) rereadBrightness(dev *ddcDevice) (read, retry bool) {
|
||||
b.ioMutex.Lock()
|
||||
defer b.ioMutex.Unlock()
|
||||
|
||||
if current, ok := b.devices.Load(dev.id); !ok || current != dev || dev.max > 0 {
|
||||
return false, false
|
||||
}
|
||||
switch err := b.checkBusAlive(dev); {
|
||||
case errors.Is(err, errBusReused):
|
||||
return false, false
|
||||
case err != nil:
|
||||
return false, true
|
||||
}
|
||||
|
||||
fd, err := openDDCBus(dev.bus)
|
||||
if err != nil {
|
||||
log.Debugf("brightness reread skipped for %s: %v", dev.id, err)
|
||||
return false, true
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
if err := setI2CAddr(fd, dev.addr); err != nil {
|
||||
return false, false
|
||||
}
|
||||
|
||||
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
|
||||
if err != nil {
|
||||
log.Debugf("brightness reread failed for %s: %v", dev.id, err)
|
||||
return false, true
|
||||
}
|
||||
|
||||
dev.max = cap.max
|
||||
dev.lastBrightness = cap.current
|
||||
log.Debugf("read %s brightness %d/%d", dev.id, cap.current, cap.max)
|
||||
return true, false
|
||||
}
|
||||
|
||||
func (b *DDCBackend) GetDevices() ([]Device, error) {
|
||||
if err := b.scanI2CDevices(); err != nil {
|
||||
log.Debugf("DDC scan error: %v", err)
|
||||
@@ -183,6 +353,8 @@ func (b *DDCBackend) GetDevices() ([]Device, error) {
|
||||
|
||||
devices := make([]Device, 0)
|
||||
|
||||
b.ioMutex.Lock()
|
||||
defer b.ioMutex.Unlock()
|
||||
b.devices.Range(func(id string, dev *ddcDevice) bool {
|
||||
devices = append(devices, Device{
|
||||
Class: ClassDDC,
|
||||
@@ -190,7 +362,7 @@ func (b *DDCBackend) GetDevices() ([]Device, error) {
|
||||
Name: dev.name,
|
||||
Current: dev.lastBrightness,
|
||||
Max: dev.max,
|
||||
CurrentPercent: dev.lastBrightness,
|
||||
CurrentPercent: ddcPercentFromRaw(dev.lastBrightness, dev.max),
|
||||
Backend: "ddc",
|
||||
})
|
||||
return true
|
||||
@@ -274,6 +446,9 @@ func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) er
|
||||
return fmt.Errorf("device not found: %s", id)
|
||||
}
|
||||
|
||||
b.ioMutex.Lock()
|
||||
defer b.ioMutex.Unlock()
|
||||
|
||||
busPath := fmt.Sprintf("/dev/i2c-%d", dev.bus)
|
||||
|
||||
if _, err := os.Stat(busPath); os.IsNotExist(err) {
|
||||
@@ -282,52 +457,85 @@ func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) er
|
||||
return fmt.Errorf("device disconnected: %s", id)
|
||||
}
|
||||
|
||||
fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
|
||||
if err != nil {
|
||||
switch err := b.checkBusAlive(dev); {
|
||||
case errors.Is(err, errBusReused):
|
||||
b.devices.Delete(id)
|
||||
log.Debugf("removed DDC device %s (bus number reused by another adapter)", id)
|
||||
return err
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
|
||||
fd, err := openDDCBus(dev.bus)
|
||||
switch {
|
||||
case errors.Is(err, unix.EWOULDBLOCK):
|
||||
return err
|
||||
case err != nil:
|
||||
b.devices.Delete(id)
|
||||
log.Debugf("removed DDC device %s (open failed: %v)", id, err)
|
||||
return fmt.Errorf("open i2c device: %w", err)
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(dev.addr)); errno != 0 {
|
||||
return fmt.Errorf("set i2c slave addr: %w", errno)
|
||||
if err := setI2CAddr(fd, dev.addr); err != nil {
|
||||
return fmt.Errorf("set i2c slave addr: %w", err)
|
||||
}
|
||||
|
||||
max := dev.max
|
||||
if max == 0 {
|
||||
maxValue := dev.max
|
||||
if maxValue == 0 {
|
||||
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current capability: %w", err)
|
||||
}
|
||||
max = cap.max
|
||||
dev.max = max
|
||||
b.devices.Store(id, dev)
|
||||
maxValue = cap.max
|
||||
}
|
||||
if maxValue <= 0 {
|
||||
return fmt.Errorf("%s reported max brightness %d", id, maxValue)
|
||||
}
|
||||
|
||||
if err := b.setVCPFeature(fd, VCP_BRIGHTNESS, value); err != nil {
|
||||
raw := ddcRawFromPercent(value, maxValue)
|
||||
if err := b.setVCPFeature(fd, VCP_BRIGHTNESS, raw); err != nil {
|
||||
return fmt.Errorf("set vcp feature: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("set %s to %d/%d", id, value, max)
|
||||
log.Debugf("set %s to %d/%d", id, raw, maxValue)
|
||||
|
||||
dev.max = max
|
||||
dev.lastBrightness = value
|
||||
dev.max = maxValue
|
||||
dev.lastBrightness = raw
|
||||
b.devices.Store(id, dev)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
|
||||
for range 3 {
|
||||
dummy := make([]byte, 32)
|
||||
n, _ := syscall.Read(fd, dummy)
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
// Based on ddcutil's i2c_check_open_bus_alive() (src/i2c/i2c_bus_core.c), sysfs side only. Known
|
||||
// devices are keyed by bus number, which the kernel hands to the next adapter once one goes away.
|
||||
func (b *DDCBackend) checkBusAlive(dev *ddcDevice) error {
|
||||
if isIgnorableI2CBus(dev.bus) || getI2CDeviceSysfsName(dev.bus) != dev.adapter {
|
||||
return errBusReused
|
||||
}
|
||||
connectors := drmConnectorsByBus()
|
||||
if ddcBusVerdictFor(dev.bus, connectors) == ddcBusSkip {
|
||||
return errNoMonitor
|
||||
}
|
||||
if ddcDisplayAsleep(dev.bus, connectors) {
|
||||
return errDisplayAsleep
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ddcRawFromPercent(percent, maxValue int) int {
|
||||
percent = min(max(percent, 0), 100)
|
||||
return int(math.Round(float64(percent*maxValue) / 100))
|
||||
}
|
||||
|
||||
func ddcPercentFromRaw(raw, maxValue int) int {
|
||||
if maxValue <= 0 {
|
||||
return 0
|
||||
}
|
||||
return min(int(math.Round(float64(raw*100)/float64(maxValue))), 100)
|
||||
}
|
||||
|
||||
func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
|
||||
data := []byte{
|
||||
DDCCI_VCP_GET,
|
||||
vcp,
|
||||
@@ -347,25 +555,6 @@ func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
pollFds := []unix.PollFd{
|
||||
{
|
||||
Fd: int32(fd),
|
||||
Events: unix.POLLIN,
|
||||
},
|
||||
}
|
||||
|
||||
pollTimeout := 200
|
||||
pollResult, err := unix.Poll(pollFds, pollTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll i2c: %w", err)
|
||||
}
|
||||
if pollResult == 0 {
|
||||
return nil, fmt.Errorf("poll timeout after %dms", pollTimeout)
|
||||
}
|
||||
if pollFds[0].Revents&unix.POLLIN == 0 {
|
||||
return nil, fmt.Errorf("poll returned but POLLIN not set")
|
||||
}
|
||||
|
||||
response := make([]byte, 12)
|
||||
n, err = syscall.Read(fd, response)
|
||||
if err != nil || n < 8 {
|
||||
@@ -520,4 +709,8 @@ func (b *DDCBackend) WaitPending() {
|
||||
}
|
||||
|
||||
func (b *DDCBackend) Close() {
|
||||
if b.stop == nil {
|
||||
return
|
||||
}
|
||||
b.closeOnce.Do(func() { close(b.stop) })
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -11,9 +14,12 @@ import (
|
||||
)
|
||||
|
||||
// isIgnorableI2CBus checks if an I2C bus should be skipped during DDC probing.
|
||||
// Based on ddcutil's sysfs_is_ignorable_i2c_device() (sysfs_base.c:1441)
|
||||
// Based on ddcutil's sysfs_is_ignorable_i2c_device() (src/sysfs/sysfs_simple.c)
|
||||
func isIgnorableI2CBus(busno int) bool {
|
||||
name := getI2CDeviceSysfsName(busno)
|
||||
if name == "DPMST" {
|
||||
return false
|
||||
}
|
||||
driver := getI2CSysfsDriver(busno)
|
||||
|
||||
if name != "" && isIgnorableI2CDeviceName(name, driver) {
|
||||
@@ -23,19 +29,20 @@ func isIgnorableI2CBus(busno int) bool {
|
||||
|
||||
// Only probe display adapters (0x03xxxx) and docking stations (0x0axxxx)
|
||||
class := getI2CDeviceSysfsClass(busno)
|
||||
if class != 0 {
|
||||
classHigh := class & 0xFFFF0000
|
||||
ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
|
||||
if ignorable {
|
||||
log.Debugf("i2c-%d: ignoring class 0x%08x", busno, class)
|
||||
}
|
||||
return ignorable
|
||||
if class == 0 {
|
||||
// No PCI class says nothing about a platform adapter, but a real adapter always has a name.
|
||||
return name == ""
|
||||
}
|
||||
|
||||
return false
|
||||
classHigh := class & 0xFFFF0000
|
||||
ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
|
||||
if ignorable {
|
||||
log.Debugf("i2c-%d: ignoring class 0x%08x", busno, class)
|
||||
}
|
||||
return ignorable
|
||||
}
|
||||
|
||||
// Based on ddcutil's ignorable_i2c_device_sysfs_name() (sysfs_base.c:1408)
|
||||
// Based on ddcutil's ignorable_i2c_device_sysfs_name() (src/sysfs/sysfs_simple.c)
|
||||
func isIgnorableI2CDeviceName(name, driver string) bool {
|
||||
ignorablePrefixes := []string{
|
||||
"SMBus",
|
||||
@@ -44,7 +51,8 @@ func isIgnorableI2CDeviceName(name, driver string) bool {
|
||||
"smu",
|
||||
"mac-io",
|
||||
"u4",
|
||||
"AMDGPU SMU", // AMD Navi2+ - probing hangs GPU
|
||||
"AMDGPU SMU", // AMD Navi2+ - probing hangs GPU
|
||||
"AMDGPU DM i2c OEM bus", // RGB controllers, not displays
|
||||
}
|
||||
|
||||
for _, prefix := range ignorablePrefixes {
|
||||
@@ -71,65 +79,194 @@ func getI2CDeviceSysfsName(busno int) string {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// Based on ddcutil's get_i2c_device_sysfs_class() (sysfs_base.c:1380)
|
||||
// Based on ddcutil's get_i2c_device_sysfs_class() (src/sysfs/sysfs_simple.c)
|
||||
func getI2CDeviceSysfsClass(busno int) uint32 {
|
||||
classPath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/class", busno)
|
||||
data, err := os.ReadFile(classPath)
|
||||
if err != nil {
|
||||
classPath = fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/device/device/class", busno)
|
||||
data, err = os.ReadFile(classPath)
|
||||
paths := []string{
|
||||
fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno),
|
||||
fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device", busno),
|
||||
fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/i2c-dev/i2c-%d/device", busno, busno),
|
||||
}
|
||||
for _, path := range paths {
|
||||
adapter := findI2CAdapter(path)
|
||||
if adapter == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(adapter, "class"))
|
||||
if err != nil {
|
||||
return 0
|
||||
continue
|
||||
}
|
||||
class, err := strconv.ParseUint(strings.TrimPrefix(strings.TrimSpace(string(data)), "0x"), 16, 32)
|
||||
if err == nil && class != 0 {
|
||||
return uint32(class)
|
||||
}
|
||||
}
|
||||
|
||||
classStr := strings.TrimSpace(string(data))
|
||||
classStr = strings.TrimPrefix(classStr, "0x")
|
||||
|
||||
class, err := strconv.ParseUint(classStr, 16, 32)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return uint32(class)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Based on ddcutil's get_i2c_sysfs_driver_by_busno() (sysfs_base.c:1284)
|
||||
// Based on ddcutil's get_driver_for_busno() (src/sysfs/sysfs_simple.c)
|
||||
func getI2CSysfsDriver(busno int) string {
|
||||
devicePath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno)
|
||||
adapterPath, err := findI2CAdapter(devicePath)
|
||||
adapter := findI2CAdapter(fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno))
|
||||
if adapter == "" {
|
||||
return ""
|
||||
}
|
||||
module, err := filepath.EvalSymlinks(filepath.Join(adapter, "driver", "module"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(module)
|
||||
}
|
||||
|
||||
driverLink := filepath.Join(adapterPath, "driver")
|
||||
target, err := os.Readlink(driverLink)
|
||||
// Based on ddcutil's sysfs_find_adapter() (src/sysfs/sysfs_simple.c): the nearest
|
||||
// ancestor under /sys/devices carrying a class attribute.
|
||||
func findI2CAdapter(path string) string {
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return filepath.Base(target)
|
||||
}
|
||||
|
||||
func findI2CAdapter(devicePath string) (string, error) {
|
||||
currentPath := devicePath
|
||||
|
||||
for range 10 {
|
||||
if _, err := os.Stat(filepath.Join(currentPath, "name")); err == nil {
|
||||
return currentPath, nil
|
||||
for strings.HasPrefix(resolved, "/sys/devices") {
|
||||
if info, err := os.Stat(filepath.Join(resolved, "class")); err == nil && info.Mode().IsRegular() {
|
||||
return resolved
|
||||
}
|
||||
|
||||
deviceLink := filepath.Join(currentPath, "device")
|
||||
target, err := os.Readlink(deviceLink)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(currentPath), target)
|
||||
}
|
||||
currentPath = filepath.Clean(target)
|
||||
resolved = filepath.Dir(resolved)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find adapter for %s", devicePath)
|
||||
// ddcutil's known_reliable_drivers (src/sysfs/sysfs_base.c): drivers that keep the DRM connector
|
||||
// edid, status and dpms attributes current.
|
||||
var sysfsReliableDrivers = []string{"i915", "xe", "amdgpu", "radeon", "nouveau"}
|
||||
|
||||
var (
|
||||
drmConnectorPattern = regexp.MustCompile(`^card[0-9]+-`)
|
||||
i2cBusPattern = regexp.MustCompile(`^i2c-([0-9]+)$`)
|
||||
)
|
||||
|
||||
type ddcBusVerdict int
|
||||
|
||||
const (
|
||||
ddcBusSkip ddcBusVerdict = iota
|
||||
ddcBusHasEDID
|
||||
ddcBusNeedsEDIDRead
|
||||
)
|
||||
|
||||
// Based on ddcutil's i2c_edid_exists() and its laptop panel exclusion (src/i2c/i2c_bus_core.c).
|
||||
func ddcBusVerdictFor(busno int, connectors map[int]string) ddcBusVerdict {
|
||||
name := getI2CDeviceSysfsName(busno)
|
||||
displayLink := name == "DisplayLink I2C Adapter"
|
||||
reliable := slices.Contains(sysfsReliableDrivers, getI2CSysfsDriver(busno))
|
||||
|
||||
connector, mapped := connectors[busno]
|
||||
if !mapped {
|
||||
if reliable && !displayLink && name != "DPMST" && len(connectors) > 0 {
|
||||
return ddcBusSkip
|
||||
}
|
||||
return ddcBusNeedsEDIDRead
|
||||
}
|
||||
if strings.Contains(connector, "-eDP-") || strings.Contains(connector, "-LVDS-") {
|
||||
return ddcBusSkip
|
||||
}
|
||||
if reliable || displayLink {
|
||||
if len(drmConnectorAttr(connector, "edid")) >= 128 {
|
||||
return ddcBusHasEDID
|
||||
}
|
||||
return ddcBusSkip
|
||||
}
|
||||
if strings.TrimSpace(drmConnectorAttr(connector, "status")) == "disconnected" {
|
||||
return ddcBusSkip
|
||||
}
|
||||
return ddcBusNeedsEDIDRead
|
||||
}
|
||||
|
||||
// Based on ddcutil's dpms_check_drm_asleep_by_businfo() (src/sysfs/sysfs_dpms.c).
|
||||
func ddcDisplayAsleep(busno int, connectors map[int]string) bool {
|
||||
connector, mapped := connectors[busno]
|
||||
if !mapped || !slices.Contains(sysfsReliableDrivers, getI2CSysfsDriver(busno)) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(drmConnectorAttr(connector, "dpms")) != "On"
|
||||
}
|
||||
|
||||
// Based on ddcutil's get_connector_bus_numbers() (src/sysfs/sysfs_simple.c): a DP connector's own
|
||||
// i2c-N directory, any other connector's ddc/i2c-dev/i2c-N.
|
||||
func drmConnectorsByBus() map[int]string {
|
||||
connectors := map[int]string{}
|
||||
entries, err := os.ReadDir("/sys/class/drm")
|
||||
if err != nil {
|
||||
return connectors
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !drmConnectorPattern.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join("/sys/class/drm", name)
|
||||
if !strings.Contains(name, "-DP-") {
|
||||
dir = filepath.Join(dir, "ddc", "i2c-dev")
|
||||
}
|
||||
if busno, ok := i2cSubdirBus(dir); ok {
|
||||
connectors[busno] = name
|
||||
}
|
||||
}
|
||||
return connectors
|
||||
}
|
||||
|
||||
func i2cSubdirBus(dir string) (int, bool) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
match := i2cBusPattern.FindStringSubmatch(entry.Name())
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
if busno, err := strconv.Atoi(match[1]); err == nil {
|
||||
return busno, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func drmConnectorAttr(connector, attr string) string {
|
||||
data, err := os.ReadFile(filepath.Join("/sys/class/drm", connector, attr))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
var edidHeader = []byte{0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00}
|
||||
|
||||
// Based on ddcutil's is_valid_raw_edid() (src/util/edid.c).
|
||||
func isValidEDID(edid []byte) bool {
|
||||
if len(edid) < 128 || !bytes.Equal(edid[:8], edidHeader) {
|
||||
return false
|
||||
}
|
||||
var sum byte
|
||||
for _, b := range edid[:128] {
|
||||
sum += b
|
||||
}
|
||||
return sum == 0
|
||||
}
|
||||
|
||||
// Based on ddcutil's is_laptop_parsed_edid() (src/util/edid.c).
|
||||
func isLaptopEDID(edid []byte) bool {
|
||||
return edidDescriptorText(edid, 0xfc) == "" && edidDescriptorText(edid, 0xff) == ""
|
||||
}
|
||||
|
||||
// Based on ddcutil's get_edid_descriptor_strings() (src/util/edid.c).
|
||||
func edidDescriptorText(edid []byte, tag byte) string {
|
||||
text := ""
|
||||
for i := range 4 {
|
||||
descriptor := edid[54+i*18 : 72+i*18]
|
||||
if descriptor[0] != 0 || descriptor[1] != 0 || descriptor[2] != 0 || descriptor[4] != 0 || descriptor[3] != tag {
|
||||
continue
|
||||
}
|
||||
raw := descriptor[5:18]
|
||||
if end := bytes.IndexByte(raw, 0x0a); end >= 0 {
|
||||
raw = raw[:end]
|
||||
}
|
||||
text = strings.TrimRight(string(raw), " \t\n\v\f\r")
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -42,12 +42,12 @@ func (m *Manager) initLogind() {
|
||||
}
|
||||
|
||||
func (m *Manager) initDDC() {
|
||||
if os.Getenv("DMS_NO_DDC") != "" {
|
||||
ddc, err := newDDCBackend(m.updateState)
|
||||
switch {
|
||||
case errors.Is(err, errDDCDisabled):
|
||||
log.Info("DDC backend disabled via DMS_NO_DDC")
|
||||
return
|
||||
}
|
||||
ddc, err := NewDDCBackend()
|
||||
if err != nil {
|
||||
case err != nil:
|
||||
log.Debugf("Failed to initialize DDC backend: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ type DDCBackend struct {
|
||||
debounceTimers map[string]*time.Timer
|
||||
debouncePending map[string]ddcPendingSet
|
||||
debounceWg sync.WaitGroup
|
||||
|
||||
ioMutex sync.Mutex
|
||||
onReread func()
|
||||
stop chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type ddcPendingSet struct {
|
||||
@@ -108,6 +113,7 @@ type ddcDevice struct {
|
||||
addr int
|
||||
id string
|
||||
name string
|
||||
adapter string
|
||||
max int
|
||||
lastBrightness int
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ func HandleRequest(conn *models.Conn, req models.Request, m *Manager) {
|
||||
handlePinEntry(conn, req, m)
|
||||
case "clipboard.unpinEntry":
|
||||
handleUnpinEntry(conn, req, m)
|
||||
case "clipboard.editEntry":
|
||||
handleEditEntry(conn, req, m)
|
||||
case "clipboard.getPinnedEntries":
|
||||
handleGetPinnedEntries(conn, req, m)
|
||||
case "clipboard.getPinnedCount":
|
||||
@@ -422,3 +424,24 @@ func handleCopyFile(conn *models.Conn, req models.Request, m *Manager) {
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"})
|
||||
}
|
||||
|
||||
func handleEditEntry(conn *models.Conn, req models.Request, m *Manager) {
|
||||
id, err := params.Int(req.Params, "id")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
text, err := params.String(req.Params, "text")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.EditEntry(uint64(id), text); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry updated"})
|
||||
}
|
||||
|
||||
@@ -795,6 +795,13 @@ func selectAltTextMimeType(mimes []string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func isTextMimeType(mime string) bool {
|
||||
if mime == "" || strings.HasPrefix(mime, "text/plain") {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(altTextMimeTypes, mime)
|
||||
}
|
||||
|
||||
func (m *Manager) isImageMimeType(mime string) bool {
|
||||
return strings.HasPrefix(mime, "image/")
|
||||
}
|
||||
@@ -1963,6 +1970,113 @@ func (m *Manager) UnpinEntry(id uint64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) EditEntry(id uint64, text string) error {
|
||||
if m.db == nil {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
data := []byte(text)
|
||||
mimeType := "text/plain;charset=utf-8"
|
||||
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return fmt.Errorf("cannot save empty entry")
|
||||
}
|
||||
|
||||
cfg := m.getConfig()
|
||||
if int64(len(data)) > cfg.MaxEntrySize {
|
||||
return fmt.Errorf("data too large")
|
||||
}
|
||||
|
||||
newHash := computeHash(data)
|
||||
preview := m.textPreview(data)
|
||||
|
||||
err := m.dbUpdate(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("clipboard"))
|
||||
if b == nil {
|
||||
return fmt.Errorf("clipboard bucket missing")
|
||||
}
|
||||
|
||||
oldKey := itob(id)
|
||||
v := b.Get(oldKey)
|
||||
if v == nil {
|
||||
return errEntryNotFound
|
||||
}
|
||||
|
||||
existing, err := decodeEntry(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.IsImage {
|
||||
return errors.New("cannot edit image entry")
|
||||
}
|
||||
|
||||
if !isTextMimeType(existing.MimeType) {
|
||||
return errors.New("cannot edit non-text entry")
|
||||
}
|
||||
|
||||
wasPinned := existing.Pinned
|
||||
|
||||
if err := b.Delete(oldKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.deduplicateInTx(b, newHash); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if wasPinned {
|
||||
c := b.Cursor()
|
||||
for k, val := c.First(); k != nil; k, val = c.Next() {
|
||||
if extractHash(val) == newHash {
|
||||
meta, err := decodeEntryMeta(val)
|
||||
if err == nil && meta.Pinned {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newID, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newEntry := Entry{
|
||||
ID: newID,
|
||||
Data: data,
|
||||
MimeType: mimeType,
|
||||
Preview: preview,
|
||||
Size: len(data),
|
||||
Timestamp: time.Now(),
|
||||
IsImage: false,
|
||||
Hash: newHash,
|
||||
Pinned: wasPinned,
|
||||
}
|
||||
|
||||
encoded, err := encodeEntry(newEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := b.Put(itob(newID), encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.trimLengthInTx(b)
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
if clipErr := m.SetClipboard(data, mimeType); clipErr != nil {
|
||||
log.Errorf("Failed to set clipboard selection: %v", clipErr)
|
||||
}
|
||||
m.updateState()
|
||||
m.notifySubscribers()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) GetPinnedEntries() []Entry {
|
||||
if m.db == nil {
|
||||
return nil
|
||||
|
||||
@@ -45,9 +45,15 @@ func newTestManagerWithDB(t *testing.T) *Manager {
|
||||
db.Close()
|
||||
})
|
||||
|
||||
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
||||
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
|
||||
fn()
|
||||
}).Maybe()
|
||||
|
||||
return &Manager{
|
||||
config: DefaultConfig(),
|
||||
db: db,
|
||||
wlCtx: mockCtx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,6 +584,175 @@ func TestCreateHistoryEntryFromPinned_KeepsLatestUnpinnedDuplicate(t *testing.T)
|
||||
assert.NotEqual(t, firstDuplicate.ID, latestDuplicate.ID)
|
||||
}
|
||||
|
||||
func TestEditEntry_UnpinnedEntry(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
|
||||
id := storeTestEntry(t, m, "original unpinned")
|
||||
require.NoError(t, m.EditEntry(id, "edited unpinned"))
|
||||
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
assert.Equal(t, "edited unpinned", history[0].Preview)
|
||||
assert.False(t, history[0].Pinned)
|
||||
assert.NotEqual(t, id, history[0].ID)
|
||||
|
||||
// Old entry should not exist
|
||||
oldEntry, err := m.GetEntry(id)
|
||||
assert.ErrorIs(t, err, errEntryNotFound)
|
||||
assert.Nil(t, oldEntry)
|
||||
}
|
||||
|
||||
func TestEditEntry_PinnedEntryRemainsPinned(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
|
||||
id := storeTestEntry(t, m, "original pinned")
|
||||
require.NoError(t, m.PinEntry(id))
|
||||
assert.Equal(t, 1, m.GetPinnedCount())
|
||||
|
||||
require.NoError(t, m.EditEntry(id, "edited pinned"))
|
||||
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
assert.Equal(t, "edited pinned", history[0].Preview)
|
||||
assert.True(t, history[0].Pinned)
|
||||
|
||||
pinnedEntries := m.GetPinnedEntries()
|
||||
require.Len(t, pinnedEntries, 1)
|
||||
assert.Equal(t, "edited pinned", pinnedEntries[0].Preview)
|
||||
assert.Equal(t, 1, m.GetPinnedCount())
|
||||
|
||||
// Old pinned entry should be deleted
|
||||
oldEntry, err := m.GetEntry(id)
|
||||
assert.ErrorIs(t, err, errEntryNotFound)
|
||||
assert.Nil(t, oldEntry)
|
||||
}
|
||||
|
||||
func TestEditEntry_NotFound(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
|
||||
err := m.EditEntry(99999, "new text")
|
||||
assert.ErrorIs(t, err, errEntryNotFound)
|
||||
}
|
||||
|
||||
func TestEditEntry_ImageReturnsError(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
|
||||
imgEntry := Entry{
|
||||
Data: []byte{0x89, 0x50, 0x4E, 0x47},
|
||||
MimeType: "image/png",
|
||||
Preview: "[[ image ]]",
|
||||
Size: 4,
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
IsImage: true,
|
||||
}
|
||||
require.NoError(t, m.storeEntry(imgEntry))
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
id := history[0].ID
|
||||
|
||||
err := m.EditEntry(id, "replacement text")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot edit image entry")
|
||||
}
|
||||
|
||||
func TestEditEntry_NonTextReturnsError(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
|
||||
uriEntry := Entry{
|
||||
Data: []byte("file:///path/to/file\n"),
|
||||
MimeType: "text/uri-list",
|
||||
Preview: "file:///path/to/file",
|
||||
Size: 21,
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
IsImage: false,
|
||||
}
|
||||
require.NoError(t, m.storeEntry(uriEntry))
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
id := history[0].ID
|
||||
|
||||
err := m.EditEntry(id, "replacement text")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot edit non-text entry")
|
||||
}
|
||||
|
||||
func TestEditEntry_AltTextMimeTypesAllowed(t *testing.T) {
|
||||
for _, mime := range []string{"UTF8_STRING", "STRING", "TEXT", "text/plain;charset=utf-8", "text/plain"} {
|
||||
t.Run(mime, func(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
entry := Entry{
|
||||
Data: []byte("old text"),
|
||||
MimeType: mime,
|
||||
Preview: "old text",
|
||||
Size: 8,
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
IsImage: false,
|
||||
}
|
||||
require.NoError(t, m.storeEntry(entry))
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
id := history[0].ID
|
||||
|
||||
err := m.EditEntry(id, "replacement text")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditEntry_EmptyOrWhitespaceReturnsError(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
id := storeTestEntry(t, m, "keep me")
|
||||
|
||||
for _, badText := range []string{"", " ", "\t\n\r"} {
|
||||
err := m.EditEntry(id, badText)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot save empty entry")
|
||||
}
|
||||
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
assert.Equal(t, "keep me", history[0].Preview)
|
||||
}
|
||||
|
||||
func TestEditEntry_DataTooLargeReturnsError(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
m.config.MaxEntrySize = 10
|
||||
id := storeTestEntry(t, m, "small")
|
||||
|
||||
err := m.EditEntry(id, "this text exceeds the 10-byte limit")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "data too large")
|
||||
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
assert.Equal(t, "small", history[0].Preview)
|
||||
}
|
||||
|
||||
func TestHandleEditEntry_SuccessAndValidation(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
id := storeTestEntry(t, m, "before edit")
|
||||
|
||||
mc := newClipboardTestConn()
|
||||
conn := models.NewConn(mc)
|
||||
handleEditEntry(conn, models.Request{
|
||||
ID: 1,
|
||||
Params: map[string]any{
|
||||
"id": float64(id),
|
||||
"text": "after edit",
|
||||
},
|
||||
}, m)
|
||||
|
||||
var resp models.Response[models.SuccessResult]
|
||||
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
|
||||
assert.Empty(t, resp.Error)
|
||||
require.NotNil(t, resp.Result)
|
||||
assert.True(t, resp.Result.Success)
|
||||
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
assert.Equal(t, "after edit", history[0].Preview)
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
subscribers: make(map[string]chan State),
|
||||
|
||||
@@ -126,7 +126,7 @@ func findKeyboards() ([]EvdevDevice, error) {
|
||||
|
||||
var keyboards []EvdevDevice
|
||||
for _, path := range matches {
|
||||
device, err := evdev.Open(path)
|
||||
device, err := evdev.OpenWithFlags(path, os.O_RDONLY)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func (m *Manager) watchForNewKeyboards() {
|
||||
continue
|
||||
}
|
||||
|
||||
device, err := evdev.Open(event.Name)
|
||||
device, err := evdev.OpenWithFlags(event.Name, os.O_RDONLY)
|
||||
if err != nil {
|
||||
m.devicesMutex.Unlock()
|
||||
continue
|
||||
|
||||
@@ -154,23 +154,7 @@ func (b *IWDBackend) applyManagedObjects(objects map[dbus.ObjectPath]map[string]
|
||||
}
|
||||
if _, hasDevice := interfaces[iwdDeviceInterface]; hasDevice {
|
||||
b.devicePath = path
|
||||
|
||||
if devProps, ok := interfaces[iwdDeviceInterface]; ok {
|
||||
if nameVar, ok := devProps["Name"]; ok {
|
||||
if name, ok := nameVar.Value().(string); ok {
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiDevice = name
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
}
|
||||
if poweredVar, ok := devProps["Powered"]; ok {
|
||||
if powered, ok := poweredVar.Value().(bool); ok {
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiEnabled = powered
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
b.applyDeviceProps(interfaces[iwdDeviceInterface])
|
||||
}
|
||||
if _, hasAdapter := interfaces[iwdAdapterInterface]; hasAdapter {
|
||||
b.adapterPath = path
|
||||
@@ -193,6 +177,47 @@ func (b *IWDBackend) applyManagedObjects(objects map[dbus.ObjectPath]map[string]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IWDBackend) applyDeviceProps(props map[string]dbus.Variant) {
|
||||
if nameVar, ok := props["Name"]; ok {
|
||||
if name, ok := nameVar.Value().(string); ok {
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiDevice = name
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
}
|
||||
if poweredVar, ok := props["Powered"]; ok {
|
||||
if powered, ok := poweredVar.Value().(bool); ok {
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiEnabled = powered
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setDevice records a device that appeared at a new path, which is what
|
||||
// happens when iwd restarts and recreates wlan0. It returns the path it
|
||||
// replaced so the caller can move the signal match over.
|
||||
func (b *IWDBackend) setDevice(path dbus.ObjectPath, props map[string]dbus.Variant) (prev dbus.ObjectPath, changed bool) {
|
||||
if path == "" || path == b.devicePath {
|
||||
return "", false
|
||||
}
|
||||
prev = b.devicePath
|
||||
b.devicePath = path
|
||||
b.applyDeviceProps(props)
|
||||
return prev, true
|
||||
}
|
||||
|
||||
func (b *IWDBackend) clearDevice(path dbus.ObjectPath) bool {
|
||||
if path == "" || path != b.devicePath {
|
||||
return false
|
||||
}
|
||||
b.devicePath = ""
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiEnabled = false
|
||||
b.stateMutex.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *IWDBackend) GetCurrentState() (*BackendState, error) {
|
||||
b.stateMutex.RLock()
|
||||
defer b.stateMutex.RUnlock()
|
||||
|
||||
@@ -26,12 +26,7 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
|
||||
b.conn.Signal(sigChan)
|
||||
|
||||
if b.devicePath != "" {
|
||||
err := b.conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(b.devicePath),
|
||||
dbus.WithMatchInterface(dbusPropertiesInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
if err != nil {
|
||||
if err := b.addDeviceSignalMatch(b.devicePath); err != nil {
|
||||
return fmt.Errorf("failed to add device signal match: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +81,48 @@ func (b *IWDBackend) addStationSignalMatch(path dbus.ObjectPath) error {
|
||||
)
|
||||
}
|
||||
|
||||
func (b *IWDBackend) addDeviceSignalMatch(path dbus.ObjectPath) error {
|
||||
return b.conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(path),
|
||||
dbus.WithMatchInterface(dbusPropertiesInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
}
|
||||
|
||||
func (b *IWDBackend) removeDeviceSignalMatch(path dbus.ObjectPath) {
|
||||
err := b.conn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(path),
|
||||
dbus.WithMatchInterface(dbusPropertiesInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to remove iwd device signal match for %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *IWDBackend) handleDeviceAdded(path dbus.ObjectPath, props map[string]dbus.Variant) bool {
|
||||
prev, changed := b.setDevice(path, props)
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
|
||||
if prev != "" {
|
||||
b.removeDeviceSignalMatch(prev)
|
||||
}
|
||||
if err := b.addDeviceSignalMatch(path); err != nil {
|
||||
log.Warnf("Failed to add iwd device signal match for %s: %v", path, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *IWDBackend) handleDeviceRemoved(path dbus.ObjectPath) bool {
|
||||
if !b.clearDevice(path) {
|
||||
return false
|
||||
}
|
||||
b.removeDeviceSignalMatch(path)
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *IWDBackend) handleStationAdded(path dbus.ObjectPath) bool {
|
||||
if path == "" || path == b.stationPath {
|
||||
return false
|
||||
@@ -139,6 +176,12 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
if len(sig.Body) >= 2 {
|
||||
path, _ := sig.Body[0].(dbus.ObjectPath)
|
||||
if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok {
|
||||
// Device before Station: handleStationAdded reads Powered from devicePath.
|
||||
if props, ok := interfaces[iwdDeviceInterface]; ok {
|
||||
if b.handleDeviceAdded(path, props) && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
}
|
||||
if _, ok := interfaces[iwdStationInterface]; ok {
|
||||
if b.handleStationAdded(path) && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
@@ -159,17 +202,19 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
path, _ := sig.Body[0].(dbus.ObjectPath)
|
||||
if interfaces, ok := sig.Body[1].([]string); ok {
|
||||
for _, iface := range interfaces {
|
||||
if iface == iwdStationInterface {
|
||||
switch iface {
|
||||
case iwdDeviceInterface:
|
||||
if b.handleDeviceRemoved(path) && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
case iwdStationInterface:
|
||||
if b.handleStationRemoved(path) && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
break
|
||||
}
|
||||
if iface == iwdKnownNetworkInterface {
|
||||
case iwdKnownNetworkInterface:
|
||||
if b.refreshWiFiNetworkState() && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,3 +460,71 @@ func TestConnectAttempt_DoubleFinalization(t *testing.T) {
|
||||
assert.Equal(t, "bad-credentials", backend.state.LastError)
|
||||
backend.stateMutex.RUnlock()
|
||||
}
|
||||
|
||||
func TestIWDBackend_SetDevice_FollowsRecreatedDevice(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
err := backend.applyManagedObjects(map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
|
||||
"/net/connman/iwd/0/3": {
|
||||
iwdDeviceInterface: {"Name": dbus.MakeVariant("wlan0"), "Powered": dbus.MakeVariant(true)},
|
||||
iwdStationInterface: {},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// iwd restarts: the old objects go away and wlan0 comes back with a new ifindex.
|
||||
assert.True(t, backend.handleStationRemoved("/net/connman/iwd/0/3"))
|
||||
assert.True(t, backend.clearDevice("/net/connman/iwd/0/3"))
|
||||
|
||||
prev, changed := backend.setDevice("/net/connman/iwd/0/7", map[string]dbus.Variant{
|
||||
"Name": dbus.MakeVariant("wlan0"),
|
||||
"Powered": dbus.MakeVariant(true),
|
||||
})
|
||||
|
||||
assert.True(t, changed)
|
||||
assert.Empty(t, prev)
|
||||
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/7"), backend.devicePath)
|
||||
|
||||
enabled, _ := backend.GetWiFiEnabled()
|
||||
assert.True(t, enabled, "Wi-Fi must be enabled again once the recreated device reports Powered")
|
||||
}
|
||||
|
||||
func TestIWDBackend_SetDevice_ReplacesPathWithoutRemovalSignal(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
backend.devicePath = "/net/connman/iwd/0/3"
|
||||
|
||||
prev, changed := backend.setDevice("/net/connman/iwd/0/7", map[string]dbus.Variant{
|
||||
"Powered": dbus.MakeVariant(true),
|
||||
})
|
||||
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/3"), prev)
|
||||
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/7"), backend.devicePath)
|
||||
}
|
||||
|
||||
func TestIWDBackend_SetDevice_SamePathIsNoop(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
backend.devicePath = "/net/connman/iwd/0/3"
|
||||
|
||||
_, changed := backend.setDevice("/net/connman/iwd/0/3", map[string]dbus.Variant{
|
||||
"Powered": dbus.MakeVariant(false),
|
||||
})
|
||||
assert.False(t, changed)
|
||||
|
||||
_, changed = backend.setDevice("", nil)
|
||||
assert.False(t, changed)
|
||||
}
|
||||
|
||||
func TestIWDBackend_ClearDevice(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
backend.devicePath = "/net/connman/iwd/0/3"
|
||||
|
||||
assert.False(t, backend.clearDevice("/net/connman/iwd/0/9"), "unrelated path must be ignored")
|
||||
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/3"), backend.devicePath)
|
||||
|
||||
assert.True(t, backend.clearDevice("/net/connman/iwd/0/3"))
|
||||
assert.Empty(t, backend.devicePath)
|
||||
|
||||
enabled, _ := backend.GetWiFiEnabled()
|
||||
assert.False(t, enabled)
|
||||
assert.ErrorContains(t, backend.SetWiFiEnabled(true), "no WiFi device available")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -30,12 +31,54 @@ func (l *linkInfo) isWired() bool {
|
||||
if looksVirtual(l.name) {
|
||||
return false
|
||||
}
|
||||
if l.linkType == "bridge" {
|
||||
return hasPhysicalMember(l.name)
|
||||
}
|
||||
if l.linkType != "" {
|
||||
return l.linkType == "ether"
|
||||
return isWiredLinkType(l.linkType)
|
||||
}
|
||||
return !strings.HasPrefix(l.name, "wlan") && !strings.HasPrefix(l.name, "wlp")
|
||||
}
|
||||
|
||||
// isWiredLinkType reports whether a networkd link Type can carry the wired
|
||||
// uplink. Link aggregates and bridges hold the IP configuration while the
|
||||
// enslaved member NIC stays in the "enslaved" state, same as #1581 for
|
||||
// NetworkManager. Bridges are further checked in isWired.
|
||||
func isWiredLinkType(t string) bool {
|
||||
switch t {
|
||||
case "ether", "bridge", "bond", "team", "vlan":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bridgeMembers lists the interfaces enslaved to a bridge. It is a variable so
|
||||
// tests can stub the sysfs lookup.
|
||||
var bridgeMembers = func(bridge string) []string {
|
||||
entries, err := os.ReadDir("/sys/class/net/" + bridge + "/brif")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// hasPhysicalMember reports whether a bridge has at least one member that is
|
||||
// not virtual (veth, tap, ...). This tells a host bridge carrying the uplink
|
||||
// apart from container/VM bridges (lxc, waydroid, ...) that only hold virtual
|
||||
// members.
|
||||
func hasPhysicalMember(bridge string) bool {
|
||||
for _, m := range bridgeMembers(bridge) {
|
||||
if !looksVirtual(m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (l *linkInfo) isWireless() bool {
|
||||
if looksVirtual(l.name) {
|
||||
return false
|
||||
|
||||
@@ -166,6 +166,15 @@ func TestLinkInfo_Classify(t *testing.T) {
|
||||
{"veth ether excluded", "veth1234", "ether", false, false},
|
||||
{"podman bridge ether excluded", "podman3", "ether", false, false},
|
||||
{"docker bridge ether excluded", "docker0", "ether", false, false},
|
||||
// The IP lives on the aggregate while the member NIC is "enslaved" (#3463).
|
||||
{"bridge type", "br0", "bridge", true, false},
|
||||
{"bond type", "bond0", "bond", true, false},
|
||||
{"team type", "team0", "team", true, false},
|
||||
{"vlan type", "vlan10", "vlan", true, false},
|
||||
{"docker user bridge excluded", "br-1a2b3c", "bridge", false, false},
|
||||
{"libvirt bridge excluded", "virbr0", "bridge", false, false},
|
||||
{"bridge with only virtual members excluded", "lxcbr0", "bridge", false, false},
|
||||
{"bridge without members excluded", "waydroid0", "bridge", false, false},
|
||||
// Fallback path: linkType unavailable, name-prefix heuristic applies.
|
||||
{"fallback enp wired", "enp141s0", "", true, false},
|
||||
{"fallback wlan wireless", "wlan0", "", false, true},
|
||||
@@ -174,6 +183,15 @@ func TestLinkInfo_Classify(t *testing.T) {
|
||||
{"fallback docker skipped", "docker0", "", false, false},
|
||||
{"fallback tun skipped", "tun0", "", false, false},
|
||||
}
|
||||
members := map[string][]string{
|
||||
"br0": {"enp42s0", "vnet0"},
|
||||
"lxcbr0": {"vethAbCd12"},
|
||||
"waydroid0": nil,
|
||||
}
|
||||
orig := bridgeMembers
|
||||
bridgeMembers = func(b string) []string { return members[b] }
|
||||
t.Cleanup(func() { bridgeMembers = orig })
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
l := &linkInfo{name: tc.ifname, linkType: tc.linkType}
|
||||
@@ -254,7 +272,7 @@ func TestLooksVirtual(t *testing.T) {
|
||||
for _, n := range virtual {
|
||||
assert.True(t, looksVirtual(n), "%s should look virtual", n)
|
||||
}
|
||||
real := []string{"enp141s0", "eno1", "wlan0", "wlp3s0", "wifi", "dock", "nebula.homelab", "wg0"}
|
||||
real := []string{"enp141s0", "eno1", "wlan0", "wlp3s0", "wifi", "dock", "nebula.homelab", "wg0", "br0", "bond0", "team0", "vlan10"}
|
||||
for _, n := range real {
|
||||
assert.False(t, looksVirtual(n), "%s should not look virtual", n)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ const (
|
||||
|
||||
metricPreferred = int64(100)
|
||||
metricNonPreferred = int64(300)
|
||||
metricDefault = int64(100)
|
||||
// -1 lets NetworkManager pick the metric by device type (nm-settings ipv4.route-metric)
|
||||
metricDefault = int64(-1)
|
||||
)
|
||||
|
||||
func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
|
||||
@@ -46,7 +47,7 @@ func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
|
||||
case PreferenceCellular:
|
||||
return m.prioritizeCellular()
|
||||
case PreferenceAuto:
|
||||
return m.balancePriorities()
|
||||
return m.restoreDefaultPriorities()
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -112,7 +113,7 @@ func (m *Manager) prioritizeCellular() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) balancePriorities() error {
|
||||
func (m *Manager) restoreDefaultPriorities() error {
|
||||
if err := m.setConnectionPriority("802-3-ethernet", priorityDefault, metricDefault); err != nil {
|
||||
log.Warnf("Failed to reset Ethernet priority: %v", err)
|
||||
}
|
||||
@@ -242,16 +243,20 @@ func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int
|
||||
}
|
||||
|
||||
func priorityMatches(variant dbus.Variant, expected int64) bool {
|
||||
value, ok := variantInt64(variant)
|
||||
return ok && value == expected
|
||||
return settingInt64(variant, int64(priorityDefault)) == expected
|
||||
}
|
||||
|
||||
func routeMetricMatches(section map[string]dbus.Variant, expected int64) bool {
|
||||
if section == nil {
|
||||
return false
|
||||
return settingInt64(section["route-metric"], metricDefault) == expected
|
||||
}
|
||||
|
||||
// GetSettings omits properties at their default, so a missing key is the default value
|
||||
func settingInt64(variant dbus.Variant, def int64) int64 {
|
||||
value, ok := variantInt64(variant)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
value, ok := variantInt64(section["route-metric"])
|
||||
return ok && value == expected
|
||||
return value
|
||||
}
|
||||
|
||||
func variantInt64(variant dbus.Variant) (int64, bool) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package network
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -45,7 +46,10 @@ func TestManager_GetConnectionPreference(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Full testing of priority operations would require mocking NetworkManager
|
||||
// D-Bus interfaces. The tests above cover the basic logic and error handling.
|
||||
// Integration tests would be needed for complete coverage of network connection
|
||||
// priority updates and reactivation.
|
||||
func TestProfileAtDefaultsMatchesAutoTargets(t *testing.T) {
|
||||
assert.True(t, priorityMatches(dbus.Variant{}, int64(priorityDefault)))
|
||||
assert.True(t, routeMetricMatches(nil, metricDefault))
|
||||
assert.True(t, routeMetricMatches(map[string]dbus.Variant{"method": dbus.MakeVariant("auto")}, metricDefault))
|
||||
assert.False(t, routeMetricMatches(map[string]dbus.Variant{"route-metric": dbus.MakeVariant(int64(100))}, metricDefault))
|
||||
assert.False(t, priorityMatches(dbus.MakeVariant(int32(100)), int64(priorityDefault)))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
func init() {
|
||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
|
||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
|
||||
RegisterSystemBackend(func() Backend { return &shellyBackend{} })
|
||||
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
|
||||
}
|
||||
|
||||
@@ -53,9 +54,7 @@ func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
||||
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
||||
}
|
||||
|
||||
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
|
||||
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
|
||||
// AUR packages are exempt — holding those cannot break the repo dependency graph.
|
||||
// Arch repository packages must upgrade together; AUR packages may be held.
|
||||
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
|
||||
if len(ignored) == 0 {
|
||||
return ignored
|
||||
@@ -81,7 +80,7 @@ func isPacmanFamily(b Backend) bool {
|
||||
return false
|
||||
}
|
||||
switch b.ID() {
|
||||
case "pacman", "paru", "yay":
|
||||
case "pacman", "paru", "yay", "shelly":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type shellyBackend struct{}
|
||||
|
||||
func (shellyBackend) ID() string { return "shelly" }
|
||||
func (shellyBackend) DisplayName() string { return "Shelly (AUR)" }
|
||||
func (shellyBackend) Repo() RepoKind { return RepoSystem }
|
||||
func (shellyBackend) NeedsAuth() bool { return true }
|
||||
func (shellyBackend) RunsInTerminal() bool {
|
||||
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
|
||||
}
|
||||
|
||||
func (shellyBackend) IsAvailable(ctx context.Context) bool {
|
||||
if !commandExists("shelly") {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
out, err := Capture(ctx, []string{"shelly", "--version", "--json"})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var version struct {
|
||||
Name string `json:"name"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
}
|
||||
// Shelly 3.1.3 introduced this CLI identity response.
|
||||
return json.Unmarshal([]byte(out), &version) == nil && version.Name == "shelly" && version.SchemaVersion == 1
|
||||
}
|
||||
|
||||
func (b shellyBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
pkgs, err := b.checkUpdates(ctx, RepoSystem)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aur, err := b.checkUpdates(ctx, RepoAUR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(pkgs, aur...), nil
|
||||
}
|
||||
|
||||
func (b shellyBackend) checkUpdates(ctx context.Context, repo RepoKind) ([]Package, error) {
|
||||
kind := "standard"
|
||||
if repo == RepoAUR {
|
||||
kind = "aur"
|
||||
}
|
||||
out, err := Capture(ctx, []string{"shelly", "list-updates", kind, "--json"})
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
return nil, fmt.Errorf("shelly %s update check: %w: %s", kind, err, strings.TrimSpace(string(exitErr.Stderr)))
|
||||
}
|
||||
return nil, fmt.Errorf("shelly %s update check: %w", kind, err)
|
||||
}
|
||||
return parseShellyUpdates(out, repo)
|
||||
}
|
||||
|
||||
func parseShellyUpdates(out string, repo RepoKind) ([]Package, error) {
|
||||
var updates []struct {
|
||||
Name string
|
||||
CurrentVersion string
|
||||
Version string
|
||||
NewVersion string
|
||||
DownloadSize int64
|
||||
PackageBase string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &updates); err != nil {
|
||||
return nil, fmt.Errorf("shelly %s updates: invalid JSON: %w", repo, err)
|
||||
}
|
||||
if updates == nil {
|
||||
return nil, fmt.Errorf("shelly %s updates: expected a JSON array", repo)
|
||||
}
|
||||
var pkgs []Package
|
||||
for _, update := range updates {
|
||||
fromVersion := update.CurrentVersion
|
||||
if repo == RepoAUR {
|
||||
fromVersion = update.Version
|
||||
}
|
||||
if update.Name == "" || fromVersion == "" || update.NewVersion == "" {
|
||||
return nil, fmt.Errorf("shelly %s updates: missing package name or version", repo)
|
||||
}
|
||||
pkg := Package{
|
||||
Name: update.Name,
|
||||
Backend: "shelly",
|
||||
Repo: repo,
|
||||
FromVersion: fromVersion,
|
||||
ToVersion: update.NewVersion,
|
||||
SizeBytes: max(0, update.DownloadSize),
|
||||
}
|
||||
if repo == RepoAUR {
|
||||
base := update.PackageBase
|
||||
if base == "" {
|
||||
base = update.Name
|
||||
}
|
||||
pkg.ChangelogURL = "https://aur.archlinux.org/packages/" + url.PathEscape(base)
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
func (b shellyBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||
return nil
|
||||
}
|
||||
var aur []Package
|
||||
if opts.IncludeAUR && (opts.DryRun || len(opts.Ignored) > 0) {
|
||||
// The manager has already removed held packages from opts.Targets.
|
||||
var err error
|
||||
aur, err = b.checkUpdates(ctx, RepoAUR)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, pkg := range aur {
|
||||
if slices.Contains(opts.Ignored, pkg.Name) {
|
||||
return fmt.Errorf("shelly cannot exclude held AUR package %q; disable AUR updates or remove its DMS hold before updating", pkg.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if opts.DryRun {
|
||||
pkgs, err := b.checkUpdates(ctx, RepoSystem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkgs = append(pkgs, aur...)
|
||||
for _, pkg := range pkgs {
|
||||
if onLine != nil {
|
||||
onLine(fmt.Sprintf("%s %s -> %s", pkg.Name, pkg.FromVersion, pkg.ToVersion))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
argv := shellyUpgradeArgv(opts.IncludeAUR)
|
||||
if opts.AttachStdio {
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: true})
|
||||
}
|
||||
if !b.RunsInTerminal() {
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine, Env: []string{"SHELLY_ELEVATOR=pkexec"}})
|
||||
}
|
||||
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)")
|
||||
}
|
||||
return Run(ctx, wrapInTerminal(term, "DMS — System Update (shelly)", strings.Join(argv, " "), opts.TerminalArgs), RunOptions{OnLine: onLine})
|
||||
}
|
||||
|
||||
func shellyUpgradeArgv(includeAUR bool) []string {
|
||||
if !includeAUR {
|
||||
return []string{"shelly", "upgrade", "standard", "--no-confirm"}
|
||||
}
|
||||
return []string{"shelly", "upgrade", "all", "--no-flatpak", "--no-appimage", "--no-confirm"}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const shellyRepoFixture = `[{"Name":"linux","CurrentVersion":"2:6.18-1","NewVersion":"2:6.19-1","DownloadSize":1024,"Repository":"core"}]`
|
||||
const shellyAURFixture = `[{"Name":"example-git","Version":"r1-1","NewVersion":"r2-1","DownloadSize":-1,"PackageBase":"example"}]`
|
||||
|
||||
func TestParseShellyUpdates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
out string
|
||||
repo RepoKind
|
||||
want []Package
|
||||
}{
|
||||
{"repository", shellyRepoFixture, RepoSystem, []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem, FromVersion: "2:6.18-1", ToVersion: "2:6.19-1", SizeBytes: 1024}}},
|
||||
{"aur package base", shellyAURFixture, RepoAUR, []Package{{Name: "example-git", Backend: "shelly", Repo: RepoAUR, FromVersion: "r1-1", ToVersion: "r2-1", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
|
||||
{"aur name fallback", `[{"Name":"example","Version":"1","NewVersion":"2"}]`, RepoAUR, []Package{{Name: "example", Backend: "shelly", Repo: RepoAUR, FromVersion: "1", ToVersion: "2", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
|
||||
{"empty", "[]\n", RepoSystem, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseShellyUpdates(tt.out, tt.repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("packages = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, out := range []string{"", "null", "{}", "[", "[] trailing", "[null]", `[{"Name":"linux"}]`, `[{"Name":"linux","CurrentVersion":"1","NewVersion":"2","DownloadSize":"large"}]`} {
|
||||
t.Run(out, func(t *testing.T) {
|
||||
if _, err := parseShellyUpdates(out, RepoSystem); err == nil {
|
||||
t.Fatalf("expected error for %q", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeUpdateExecutable(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellySelection(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
bins []string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{"shelly only", []string{"shelly"}, `{"schemaVersion":1,"name":"shelly","version":"3.1.3"}`, "shelly"},
|
||||
{"shelly before pacman", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly"}`, "shelly"},
|
||||
{"paru preferred", []string{"paru", "yay", "shelly", "pacman"}, "", "paru"},
|
||||
{"yay preferred", []string{"yay", "shelly", "pacman"}, "", "yay"},
|
||||
{"legacy shelly", []string{"shelly", "pacman"}, "2.4.1", "pacman"},
|
||||
{"wrong binary", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly-ui"}`, "pacman"},
|
||||
{"unknown schema", []string{"shelly", "pacman"}, `{"schemaVersion":2,"name":"shelly"}`, "pacman"},
|
||||
{"pacman only", []string{"pacman"}, "", "pacman"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
for _, bin := range tt.bins {
|
||||
writeUpdateExecutable(t, dir, bin, "printf '%s\\n' '"+tt.version+"'")
|
||||
}
|
||||
selection := Select(t.Context())
|
||||
if selection.System == nil || selection.System.ID() != tt.want {
|
||||
t.Fatalf("selection = %#v, want %s", selection.System, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fakeShelly(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
log := filepath.Join(dir, "calls")
|
||||
t.Setenv("PATH", dir)
|
||||
t.Setenv("TERMINAL", "")
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "")
|
||||
t.Setenv("DMS_TEST_SHELLY_LOG", log)
|
||||
t.Setenv("DMS_TEST_SHELLY_EXIT", "0")
|
||||
t.Setenv("SHELLY_ELEVATOR", "")
|
||||
writeUpdateExecutable(t, dir, "shelly", `printf '%s|%s\n' "$*" "$SHELLY_ELEVATOR" >> "$DMS_TEST_SHELLY_LOG"
|
||||
case "$*" in
|
||||
'list-updates standard --json') printf '%s\n' '`+shellyRepoFixture+`' ;;
|
||||
'list-updates aur --json') printf '%s\n' '`+shellyAURFixture+`' ;;
|
||||
'upgrade standard --no-confirm'|'upgrade all --no-flatpak --no-appimage --no-confirm') exit "$DMS_TEST_SHELLY_EXIT" ;;
|
||||
*) echo "unexpected arguments: $*" >&2; exit 99 ;;
|
||||
esac`)
|
||||
return dir, log
|
||||
}
|
||||
|
||||
func readUpdateCalls(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestShellyCheckUpdates(t *testing.T) {
|
||||
_, log := fakeShelly(t)
|
||||
pkgs, err := (shellyBackend{}).CheckUpdates(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pkgs) != 2 || pkgs[0].Repo != RepoSystem || pkgs[1].Repo != RepoAUR {
|
||||
t.Fatalf("unexpected packages: %#v", pkgs)
|
||||
}
|
||||
if got, want := readUpdateCalls(t, log), "list-updates standard --json|\nlist-updates aur --json|\n"; got != want {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyCheckFailures(t *testing.T) {
|
||||
for _, code := range []string{"1", "2"} {
|
||||
t.Run(code, func(t *testing.T) {
|
||||
dir, _ := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", "echo 'database unavailable' >&2; exit "+code)
|
||||
if _, err := (shellyBackend{}).CheckUpdates(t.Context()); err == nil || !strings.Contains(err.Error(), "database unavailable") {
|
||||
t.Fatalf("expected query failure with stderr, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("cancellation", func(t *testing.T) {
|
||||
dir, _ := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", "exec /bin/sleep 30")
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := (shellyBackend{}).CheckUpdates(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected deadline error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShellyUpgrade(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
includeAUR bool
|
||||
attached bool
|
||||
terminal bool
|
||||
}{
|
||||
{"pkexec repository", false, false, false},
|
||||
{"pkexec aur", true, false, false},
|
||||
{"attached repository", false, true, false},
|
||||
{"attached aur", true, true, false},
|
||||
{"terminal repository", false, false, true},
|
||||
{"terminal aur", true, false, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir, log := fakeShelly(t)
|
||||
opts := UpgradeOptions{IncludeAUR: tt.includeAUR, AttachStdio: tt.attached, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
elevator := ""
|
||||
if tt.terminal {
|
||||
opts.Terminal = "fake-terminal"
|
||||
writeUpdateExecutable(t, dir, opts.Terminal, `while [ "$1" != sh ] && [ "$#" -gt 0 ]; do shift; done
|
||||
shift
|
||||
exec /bin/sh "$@"`)
|
||||
} else if !tt.attached {
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
elevator = "pkexec"
|
||||
}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := "upgrade standard --no-confirm"
|
||||
if tt.includeAUR {
|
||||
command = "upgrade all --no-flatpak --no-appimage --no-confirm"
|
||||
}
|
||||
if got, want := readUpdateCalls(t, log), command+"|"+elevator+"\n"; got != want {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyUpgradeHolds(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
includeAUR bool
|
||||
ignored []string
|
||||
wantError bool
|
||||
dryRun bool
|
||||
}{
|
||||
{"held aur", true, []string{"example-git"}, true, false},
|
||||
{"repository only", false, []string{"example-git"}, false, false},
|
||||
{"repository hold cannot exclude system upgrade", true, []string{"linux"}, false, false},
|
||||
{"unrelated hold", true, []string{"org.example.Flatpak"}, false, false},
|
||||
{"dry held aur", true, []string{"example-git"}, true, true},
|
||||
{"dry repository only", false, []string{"example-git"}, false, true},
|
||||
{"dry repository hold", true, []string{"linux"}, false, true},
|
||||
{"dry unrelated hold", true, []string{"org.example.Flatpak"}, false, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, log := fakeShelly(t)
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
opts := UpgradeOptions{DryRun: tt.dryRun, IncludeAUR: tt.includeAUR, Ignored: tt.ignored, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
|
||||
calls := readUpdateCalls(t, log)
|
||||
if strings.Count(calls, "list-updates aur --json") > 1 {
|
||||
t.Fatalf("duplicate AUR check: %q", calls)
|
||||
}
|
||||
if tt.wantError {
|
||||
if err == nil || err.Error() != `shelly cannot exclude held AUR package "example-git"; disable AUR updates or remove its DMS hold before updating` || strings.Contains(calls, "upgrade ") {
|
||||
t.Fatalf("held AUR package must block upgrade, got %v, calls %q", err, readUpdateCalls(t, log))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || strings.Contains(calls, "upgrade ") == tt.dryRun {
|
||||
t.Fatalf("unexpected result for dryRun=%v: %v, calls %q", tt.dryRun, err, calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !isPacmanFamily(shellyBackend{}) {
|
||||
t.Fatal("Shelly must enforce repository hold restrictions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyHoldCheckFailure(t *testing.T) {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
dir, log := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", `printf '%s\n' "$*" >> "$DMS_TEST_SHELLY_LOG"
|
||||
echo 'AUR unavailable' >&2
|
||||
exit 1`)
|
||||
opts := UpgradeOptions{DryRun: dryRun, IncludeAUR: true, Ignored: []string{"example-git"}, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "AUR unavailable") {
|
||||
t.Fatalf("dryRun=%v: expected AUR query error, got %v", dryRun, err)
|
||||
}
|
||||
if calls := readUpdateCalls(t, log); calls != "list-updates aur --json\n" {
|
||||
t.Fatalf("query failure must stop the operation: %q", calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyDryRun(t *testing.T) {
|
||||
for _, includeAUR := range []bool{false, true} {
|
||||
_, log := fakeShelly(t)
|
||||
var lines []string
|
||||
opts := UpgradeOptions{DryRun: true, IncludeAUR: includeAUR, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, func(line string) { lines = append(lines, line) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantCalls := "list-updates standard --json|\n"
|
||||
wantLines := []string{"linux 2:6.18-1 -> 2:6.19-1"}
|
||||
if includeAUR {
|
||||
wantCalls = "list-updates aur --json|\n" + wantCalls
|
||||
wantLines = append(wantLines, "example-git r1-1 -> r2-1")
|
||||
}
|
||||
if got := readUpdateCalls(t, log); got != wantCalls || !reflect.DeepEqual(lines, wantLines) {
|
||||
t.Fatalf("calls = %q, lines = %q", got, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellySkipsExcludedTargets(t *testing.T) {
|
||||
for _, targets := range [][]Package{
|
||||
nil,
|
||||
{{Name: "example-git", Backend: "shelly", Repo: RepoAUR}},
|
||||
{{Name: "org.example.App", Backend: "flatpak", Repo: RepoFlatpak}},
|
||||
{{Name: "linux", Backend: "pacman", Repo: RepoSystem}},
|
||||
} {
|
||||
_, log := fakeShelly(t)
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), UpgradeOptions{Targets: targets, IncludeFlatpak: true}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readUpdateCalls(t, log); got != "" {
|
||||
t.Fatalf("excluded targets executed commands: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyUpgradeFailure(t *testing.T) {
|
||||
_, _ = fakeShelly(t)
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
t.Setenv("DMS_TEST_SHELLY_EXIT", "126")
|
||||
opts := UpgradeOptions{Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err == nil {
|
||||
t.Fatal("authentication or upgrade failure must propagate")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ func Run(ctx context.Context, argv []string, opts RunOptions) error {
|
||||
}
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
@@ -125,7 +126,14 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
||||
`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`
|
||||
closer := `dms_update_status=$?
|
||||
if [ "$dms_update_status" -eq 0 ]; then
|
||||
printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'
|
||||
else
|
||||
printf '\n\033[1;31m=== Update failed (exit %s). Press Enter to close. ===\033[0m\n' "$dms_update_status"
|
||||
fi
|
||||
read -r dms_update_reply
|
||||
exit "$dms_update_status"`
|
||||
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||
full := export + banner + "; " + shellCmd + "; " + closer
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTerminalWrapperPreservesExitStatus(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
command string
|
||||
status int
|
||||
message string
|
||||
}{
|
||||
{"true", 0, "Done."},
|
||||
{"false", 1, "Update failed (exit 1)"},
|
||||
{"sh -c 'exit 42'", 42, "Update failed (exit 42)"},
|
||||
} {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
argv := wrapInTerminal("xterm", "Test", tt.command, nil)
|
||||
cmd := exec.Command("sh", "-c", argv[len(argv)-1])
|
||||
cmd.Stdin = strings.NewReader("\n")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if tt.status == 0 && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.status != 0 {
|
||||
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||
if !ok || exitErr.ExitCode() != tt.status {
|
||||
t.Fatalf("expected exit %d, got %v (%s)", tt.status, err, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(out), tt.message) {
|
||||
t.Fatalf("missing status message %q: %s", tt.message, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAttachesStdin(t *testing.T) {
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
if _, err := writer.WriteString("confirmation\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer.Close()
|
||||
previous := os.Stdin
|
||||
os.Stdin = reader
|
||||
defer func() { os.Stdin = previous }()
|
||||
if err := Run(t.Context(), []string{"sh", "-c", `read -r answer && [ "$answer" = confirmation ]`}, RunOptions{AttachStdio: true}); err != nil {
|
||||
t.Fatalf("attached command did not receive stdin: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -420,8 +420,8 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
m.failCustomUpgrade(ErrCodeCancelled, err)
|
||||
return
|
||||
}
|
||||
// exit status reflects the trailing `read`, not the update command
|
||||
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
|
||||
m.failCustomUpgrade(ErrCodeBackendFailed, err)
|
||||
return
|
||||
}
|
||||
|
||||
m.finishSuccessfulUpgrade(false)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type customUpgradeTestBackend struct {
|
||||
pacmanBackend
|
||||
checks int
|
||||
}
|
||||
|
||||
func (b *customUpgradeTestBackend) CheckUpdates(context.Context) ([]Package, error) {
|
||||
b.checks++
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestCustomUpgradeFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
writeUpdateExecutable(t, dir, "fake-terminal", `while [ "$#" -gt 0 ] && [ "$1" != sh ]; do shift; done
|
||||
[ "$#" -gt 0 ] || exit 99
|
||||
shift
|
||||
exec /bin/sh "$@"`)
|
||||
|
||||
backend := &customUpgradeTestBackend{}
|
||||
m := &Manager{
|
||||
selection: Selection{System: backend},
|
||||
notifyDirty: make(chan struct{}, 1),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
m.runCustomUpgrade(t.Context(), UpgradeOptions{
|
||||
Terminal: "fake-terminal",
|
||||
CustomCommand: "false",
|
||||
})
|
||||
|
||||
state := m.GetState()
|
||||
if state.Phase != PhaseError {
|
||||
t.Errorf("phase = %s, want %s", state.Phase, PhaseError)
|
||||
}
|
||||
if state.Error == nil || state.Error.Code != ErrCodeBackendFailed || !strings.Contains(state.Error.Message, "exit status 1") {
|
||||
t.Errorf("error = %#v, want backend-failed with exit status 1", state.Error)
|
||||
}
|
||||
if slices.Contains(state.RecentLog, "Upgrade complete.") {
|
||||
t.Error("failed custom upgrade logged success")
|
||||
}
|
||||
if backend.checks != 0 {
|
||||
t.Errorf("failed custom upgrade refreshed %d times", backend.checks)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func convertStatus(status *ipnstate.Status) *TailscaleState {
|
||||
|
||||
if status.Self != nil {
|
||||
state.Self = convertPeerStatus(status.Self, users)
|
||||
state.Self.Online = true
|
||||
}
|
||||
|
||||
if len(status.Peer) > 0 {
|
||||
|
||||
@@ -95,7 +95,7 @@ func newManager(client tailscaleClient) *Manager {
|
||||
func (m *Manager) watchLoop(ctx context.Context) {
|
||||
defer m.watchWG.Done()
|
||||
|
||||
mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
|
||||
mask := ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
|
||||
backoff := time.Second
|
||||
unreachableSent := false
|
||||
|
||||
@@ -122,8 +122,13 @@ func (m *Manager) watchLoop(ctx context.Context) {
|
||||
log.Info("[Tailscale] Connected to IPN bus")
|
||||
m.markAvailable()
|
||||
|
||||
select {
|
||||
case m.dirty <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
for {
|
||||
notify, err := watcher.Next()
|
||||
_, err := watcher.Next()
|
||||
if err != nil {
|
||||
log.Warnf("[Tailscale] IPN bus error: %v", err)
|
||||
break
|
||||
@@ -131,9 +136,6 @@ func (m *Manager) watchLoop(ctx context.Context) {
|
||||
|
||||
backoff = time.Second
|
||||
|
||||
if notify.State == nil && notify.NetMap == nil { //nolint:staticcheck // NetMap is deprecated upstream but still the only activity signal on some platforms
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case m.dirty <- struct{}{}:
|
||||
default:
|
||||
|
||||
@@ -418,3 +418,55 @@ func TestManager_Actions_PropagateError(t *testing.T) {
|
||||
assert.Error(t, m.SetExitNode("nABC123"))
|
||||
assert.Error(t, m.SetAllowLANAccess(true))
|
||||
}
|
||||
|
||||
type dynamicWatcher struct {
|
||||
ctx context.Context
|
||||
ch chan ipn.Notify
|
||||
}
|
||||
|
||||
func (w *dynamicWatcher) Next() (ipn.Notify, error) {
|
||||
select {
|
||||
case n, ok := <-w.ch:
|
||||
if !ok {
|
||||
return ipn.Notify{}, fmt.Errorf("watcher closed")
|
||||
}
|
||||
return n, nil
|
||||
case <-w.ctx.Done():
|
||||
return ipn.Notify{}, w.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *dynamicWatcher) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWatchLoop_NotifyWithoutStateOrNetMap(t *testing.T) {
|
||||
var statusCalls atomic.Int32
|
||||
notifyCh := make(chan ipn.Notify, 1)
|
||||
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
return &dynamicWatcher{ctx: ctx, ch: notifyCh}, nil
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
statusCalls.Add(1)
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
// Wait for the initial connect-time status call to complete.
|
||||
require.Eventually(t, func() bool {
|
||||
return statusCalls.Load() == 1
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
// Send an event with State == nil and NetMap == nil.
|
||||
notifyCh <- ipn.Notify{Engine: &ipn.EngineStatus{}}
|
||||
|
||||
// Verify that the notification without State or NetMap triggers a second status call.
|
||||
require.Eventually(t, func() bool {
|
||||
return statusCalls.Load() >= 2
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -277,7 +277,8 @@ const (
|
||||
|
||||
// ipp defaults
|
||||
const (
|
||||
CharsetLanguage = "en-US"
|
||||
// cups 2.4.20+ validates this case-sensitively (lowercase only), and libcups itself sends lowercase
|
||||
CharsetLanguage = "en-us"
|
||||
Charset = "utf-8"
|
||||
ProtocolVersionMajor = int8(2)
|
||||
ProtocolVersionMinor = int8(0)
|
||||
|
||||
+1
-1
Submodule dank-qml-common updated: 26396ce432...1ae722232d
Generated
+4
-3
@@ -3,15 +3,16 @@
|
||||
"dank-qml-common": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1788376015,
|
||||
"narHash": "sha256-/tT8rTznLADndTwLBzdgzzgfJH1ZhYQibYo5v/F8U+U=",
|
||||
"lastModified": 1791392812,
|
||||
"narHash": "sha256-id6Y5XNO5K3KfXSOsEYVUwiq1ygYGNX5LeKSgHM3GR8=",
|
||||
"owner": "AvengeMedia",
|
||||
"repo": "dank-qml-common",
|
||||
"rev": "26396ce432d6c71c3f5367438f96f4a8d667e160",
|
||||
"rev": "1ae722232da7110d26cd2c355b6615e078e94197",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "AvengeMedia",
|
||||
"ref": "stable-1.6",
|
||||
"repo": "dank-qml-common",
|
||||
"type": "github"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
flake = false;
|
||||
};
|
||||
dank-qml-common = {
|
||||
url = "github:AvengeMedia/dank-qml-common";
|
||||
url = "github:AvengeMedia/dank-qml-common/stable-1.6";
|
||||
flake = false;
|
||||
};
|
||||
};
|
||||
@@ -111,7 +111,7 @@
|
||||
inherit version;
|
||||
pname = "dms-shell";
|
||||
src = ./core;
|
||||
vendorHash = "sha256-nswS/ygp0oYMXp6YHbbIxBha4xvzjKAxcAS5yOpbslg=";
|
||||
vendorHash = "sha256-ejDi+TzstUtxtWpcl5yYw4UZuTnZraOsIdJuXPKxJ6s=";
|
||||
|
||||
subPackages = [ "cmd/dms" ];
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
Heisenberg
|
||||
Marble Tabby
|
||||
|
||||
@@ -189,6 +189,26 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
readonly property var _termIndexes: new WeakMap()
|
||||
|
||||
function _termIndex(table) {
|
||||
const cached = _termIndexes.get(table);
|
||||
if (cached)
|
||||
return cached;
|
||||
const index = {};
|
||||
for (const c in table) {
|
||||
const bucket = table[c];
|
||||
if (!bucket)
|
||||
continue;
|
||||
for (const term in bucket) {
|
||||
if (!index[term] && bucket[term])
|
||||
index[term] = bucket[term];
|
||||
}
|
||||
}
|
||||
_termIndexes.set(table, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
function _lookup(table, term, context) {
|
||||
if (!table)
|
||||
return "";
|
||||
@@ -196,11 +216,7 @@ Singleton {
|
||||
return table[context][term];
|
||||
if (table[term] && table[term][term])
|
||||
return table[term][term];
|
||||
for (const c in table) {
|
||||
if (table[c] && table[c][term])
|
||||
return table[c][term];
|
||||
}
|
||||
return "";
|
||||
return _termIndex(table)[term] || "";
|
||||
}
|
||||
|
||||
// isRealContext is consumed by translations/extract_translations.py only:
|
||||
|
||||
@@ -3,6 +3,7 @@ import Quickshell
|
||||
|
||||
QtObject {
|
||||
required property Singleton service
|
||||
property var modules: null
|
||||
property bool active: true
|
||||
|
||||
property bool _held: false
|
||||
@@ -11,7 +12,15 @@ QtObject {
|
||||
if (wanted === _held)
|
||||
return;
|
||||
_held = wanted;
|
||||
service.refCount += wanted ? 1 : -1;
|
||||
if (modules === null) {
|
||||
service.refCount += wanted ? 1 : -1;
|
||||
return;
|
||||
}
|
||||
if (wanted) {
|
||||
service.addRef(modules);
|
||||
return;
|
||||
}
|
||||
service.removeRef(modules);
|
||||
}
|
||||
|
||||
onActiveChanged: sync(active)
|
||||
|
||||
@@ -14,7 +14,7 @@ Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("SessionData")
|
||||
|
||||
readonly property int sessionConfigVersion: 4
|
||||
readonly property int sessionConfigVersion: 5
|
||||
|
||||
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
||||
|
||||
|
||||
@@ -1988,8 +1988,11 @@ Singleton {
|
||||
function saveSettings() {
|
||||
if (_loading || _parseError || !_hasLoaded)
|
||||
return;
|
||||
const json = JSON.stringify(Store.toJson(root), null, 2);
|
||||
if (json === settingsFile.text())
|
||||
return;
|
||||
_selfWrite = true;
|
||||
settingsFile.setText(JSON.stringify(Store.toJson(root), null, 2));
|
||||
settingsFile.setText(json);
|
||||
if (_isReadOnly)
|
||||
_checkSettingsWritable();
|
||||
}
|
||||
@@ -3225,10 +3228,11 @@ Singleton {
|
||||
if (!app && !desktop)
|
||||
return -1;
|
||||
return rules.findIndex(rule => {
|
||||
if (!predicate(rule))
|
||||
if (!predicate(rule) || (rule.matchType || "contains").toString().toLowerCase() !== "exact")
|
||||
return false;
|
||||
const pattern = (rule.pattern || "").toString().toLowerCase();
|
||||
return pattern !== "" && (pattern === app || pattern === desktop);
|
||||
const value = !rule.field || rule.field === "appName" ? app : rule.field === "desktopEntry" ? desktop : "";
|
||||
return pattern !== "" && pattern === value;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3246,20 +3250,31 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function _removeAppRule(appName, desktopEntry, predicate) {
|
||||
function _hasNoAction(rule) {
|
||||
return (rule.action || "default").toString().toLowerCase() === "default";
|
||||
}
|
||||
|
||||
// Edits the first enabled matching rule and drops it once it no longer does anything.
|
||||
function _updateAppRule(appName, desktopEntry, predicate, changes) {
|
||||
var rules = JSON.parse(JSON.stringify(notificationRules || []));
|
||||
const index = _appRuleIndex(rules, appName, desktopEntry, predicate);
|
||||
const index = _appRuleIndex(rules, appName, desktopEntry, rule => rule.enabled !== false && predicate(rule));
|
||||
if (index === -1)
|
||||
return;
|
||||
rules.splice(index, 1);
|
||||
return false;
|
||||
const rule = Object.assign(rules[index], changes);
|
||||
if (_hasNoAction(rule) && (rule.urgency || "default").toString().toLowerCase() === "default" && !_isDndBypassRule(rule))
|
||||
rules.splice(index, 1);
|
||||
notificationRules = rules;
|
||||
saveSettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
function addMuteRuleForApp(appName, desktopEntry) {
|
||||
_addAppRule(appName, desktopEntry, {
|
||||
action: "mute"
|
||||
});
|
||||
if (!_updateAppRule(appName, desktopEntry, _hasNoAction, {
|
||||
action: "mute"
|
||||
}))
|
||||
_addAppRule(appName, desktopEntry, {
|
||||
action: "mute"
|
||||
});
|
||||
}
|
||||
|
||||
function isAppMuted(appName, desktopEntry) {
|
||||
@@ -3267,7 +3282,9 @@ Singleton {
|
||||
}
|
||||
|
||||
function removeMuteRuleForApp(appName, desktopEntry) {
|
||||
_removeAppRule(appName, desktopEntry, _isMuteRule);
|
||||
_updateAppRule(appName, desktopEntry, _isMuteRule, {
|
||||
action: "default"
|
||||
});
|
||||
}
|
||||
|
||||
function isAppDndBypassed(appName, desktopEntry) {
|
||||
@@ -3276,14 +3293,19 @@ Singleton {
|
||||
|
||||
function setAppDndBypass(appName, desktopEntry, enabled) {
|
||||
if (!enabled) {
|
||||
_removeAppRule(appName, desktopEntry, _isDndBypassRule);
|
||||
_updateAppRule(appName, desktopEntry, _isDndBypassRule, {
|
||||
bypassDnd: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isAppDndBypassed(appName, desktopEntry))
|
||||
return;
|
||||
_addAppRule(appName, desktopEntry, {
|
||||
bypassDnd: true
|
||||
});
|
||||
if (!_updateAppRule(appName, desktopEntry, () => true, {
|
||||
bypassDnd: true
|
||||
}))
|
||||
_addAppRule(appName, desktopEntry, {
|
||||
bypassDnd: true
|
||||
});
|
||||
}
|
||||
|
||||
function updateNotificationRule(index, ruleData) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.pragma library
|
||||
|
||||
// Qt Wayland sends scroll phases only for the finger axis source; pixelDelta also comes with wheels from Qt 6.12 (qtbase d19bd66b4)
|
||||
const Kind = Object.freeze({
|
||||
none: "none",
|
||||
touchpad: "touchpad",
|
||||
wheel: "wheel",
|
||||
highResWheel: "highResWheel"
|
||||
});
|
||||
|
||||
function isTouchpad(wheel) {
|
||||
if (wheel.phase === Qt.NoScrollPhase)
|
||||
return false;
|
||||
return wheel.pixelDelta.x !== 0 || wheel.pixelDelta.y !== 0;
|
||||
}
|
||||
|
||||
function verticalKind(wheel) {
|
||||
if (isTouchpad(wheel))
|
||||
return Kind.touchpad;
|
||||
const angle = wheel.angleDelta.y;
|
||||
if (angle === 0)
|
||||
return Kind.none;
|
||||
return angle % 120 === 0 ? Kind.wheel : Kind.highResWheel;
|
||||
}
|
||||
|
||||
function anyAxisDelta(wheel) {
|
||||
const point = isTouchpad(wheel) ? wheel.pixelDelta : wheel.angleDelta;
|
||||
return point.x || point.y;
|
||||
}
|
||||
@@ -83,5 +83,29 @@ function migrateToVersion(obj, targetVersion, settingsData) {
|
||||
session.configVersion = 4;
|
||||
}
|
||||
|
||||
if (currentVersion < 5) {
|
||||
console.info("SessionData: Migrating session to version 5");
|
||||
console.info("SessionData: Tray keys now use a stable id (or id::instance) instead of id::tooltipTitle");
|
||||
|
||||
function stripTrayKeys(list) {
|
||||
if (!list || list.constructor !== Array) return list;
|
||||
var out = [];
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var raw = list[i];
|
||||
if (typeof raw !== "string") continue;
|
||||
var key = raw.includes("::") ? raw.split("::")[0] : raw;
|
||||
var hasDup = false;
|
||||
for (var j = 0; j < out.length; j++) {
|
||||
if (out[j] === key) { hasDup = true; break; }
|
||||
}
|
||||
if (key && !hasDup) out.push(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (session.hiddenTrayIds !== undefined) session.hiddenTrayIds = stripTrayKeys(session.hiddenTrayIds);
|
||||
if (session.trayItemOrder !== undefined) session.trayItemOrder = stripTrayKeys(session.trayItemOrder);
|
||||
session.configVersion = 5;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -398,8 +398,8 @@ Item {
|
||||
|
||||
IpcHandler {
|
||||
function getFocusedScreenName() {
|
||||
if (CompositorService.isHyprland && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor) {
|
||||
return Hyprland.focusedWorkspace.monitor.name;
|
||||
if (CompositorService.isHyprland && Hyprland.focusedMonitor) {
|
||||
return Hyprland.focusedMonitor.name;
|
||||
}
|
||||
if (CompositorService.isNiri && NiriService.currentOutput) {
|
||||
return NiriService.currentOutput;
|
||||
|
||||
@@ -28,7 +28,7 @@ Item {
|
||||
readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
|
||||
|
||||
readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null
|
||||
readonly property bool canEditEntry: !!entry && !(entry.isImage ?? false)
|
||||
readonly property bool canEditEntry: ClipboardService.canEditEntry(entry)
|
||||
readonly property bool hasTextAlternative: !!entry && (entry.altMimeType ?? "") !== ""
|
||||
readonly property string pinText: entry?.pinned || hasPinnedDuplicate ? I18n.tr("Unpin") : I18n.tr("Pin")
|
||||
readonly property string pinIcon: entry?.pinned || hasPinnedDuplicate ? "keep_off" : "push_pin"
|
||||
|
||||
@@ -14,6 +14,20 @@ Item {
|
||||
|
||||
property var entry: null
|
||||
property string editorText: ""
|
||||
property bool textLoaded: false
|
||||
property bool loadFailed: false
|
||||
|
||||
Timer {
|
||||
id: loadTimeoutTimer
|
||||
interval: 5000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (!root.textLoaded) {
|
||||
root.loadFailed = true;
|
||||
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function releaseTextInputFocus() {
|
||||
if (editField) {
|
||||
@@ -68,35 +82,22 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
function setEntry(newEntry) {
|
||||
entry = newEntry;
|
||||
editorText = newEntry?.text ?? newEntry?.preview ?? "";
|
||||
if (editField) {
|
||||
editField.text = editorText;
|
||||
}
|
||||
Qt.callLater(function () {
|
||||
if (editField) {
|
||||
editField.forceActiveFocus();
|
||||
editField.cursorPosition = editField.text.length;
|
||||
}
|
||||
});
|
||||
|
||||
if (!newEntry || newEntry.isImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedId = newEntry.id;
|
||||
function fetchEntry(requestedId) {
|
||||
loadFailed = false;
|
||||
loadTimeoutTimer.restart();
|
||||
DMSService.sendRequest("clipboard.getEntry", {
|
||||
"id": requestedId
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
return;
|
||||
}
|
||||
loadTimeoutTimer.stop();
|
||||
if (!root.entry || root.entry.id !== requestedId) {
|
||||
return;
|
||||
}
|
||||
if (!response.result) {
|
||||
ClipboardService.refresh();
|
||||
if (response.error || !response.result) {
|
||||
root.loadFailed = true;
|
||||
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||
if (!response.result) {
|
||||
ClipboardService.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = response.result;
|
||||
@@ -108,8 +109,12 @@ Item {
|
||||
}
|
||||
|
||||
if (!fullText || fullText.length === 0) {
|
||||
root.loadFailed = true;
|
||||
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||
return;
|
||||
}
|
||||
root.loadFailed = false;
|
||||
root.textLoaded = true;
|
||||
root.editorText = fullText;
|
||||
if (editField) {
|
||||
if (fullText.length > 50000) {
|
||||
@@ -127,15 +132,47 @@ Item {
|
||||
});
|
||||
}
|
||||
|
||||
function setEntry(newEntry) {
|
||||
entry = newEntry;
|
||||
loadFailed = false;
|
||||
const hasFullText = typeof newEntry?.text === "string";
|
||||
textLoaded = !newEntry || !ClipboardService.canEditEntry(newEntry) || !(newEntry.id > 0) || hasFullText;
|
||||
editorText = newEntry?.text ?? newEntry?.preview ?? "";
|
||||
if (editField) {
|
||||
editField.text = editorText;
|
||||
}
|
||||
Qt.callLater(function () {
|
||||
if (editField) {
|
||||
editField.forceActiveFocus();
|
||||
editField.cursorPosition = editField.text.length;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasFullText || !newEntry || !ClipboardService.canEditEntry(newEntry) || !(newEntry.id > 0)) {
|
||||
loadTimeoutTimer.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
fetchEntry(newEntry.id);
|
||||
}
|
||||
|
||||
function saveEntry(action) {
|
||||
const saveAction = action ?? "history";
|
||||
DMSService.sendRequest("clipboard.copy", {
|
||||
"text": root.editorText
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
ToastService.showError(I18n.tr("Failed to update clipboard"));
|
||||
return;
|
||||
const entryId = root.entry?.id ?? 0;
|
||||
|
||||
if (entryId > 0 && !root.textLoaded) {
|
||||
if (root.loadFailed) {
|
||||
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||
root.fetchEntry(entryId);
|
||||
} else {
|
||||
ToastService.showWarning(I18n.tr("Loading full text, please wait...", "clipboard editor: save blocked while the entry's full text is still being fetched"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
loadTimeoutTimer.stop();
|
||||
|
||||
const onComplete = function () {
|
||||
if (saveAction === "history") {
|
||||
modal.mode = "history";
|
||||
Qt.callLater(function () {
|
||||
@@ -154,6 +191,27 @@ Item {
|
||||
if (saveAction === "paste") {
|
||||
ClipboardService.pasteClipboard(modal.hide);
|
||||
}
|
||||
};
|
||||
|
||||
if (entryId > 0) {
|
||||
ClipboardService.editEntry(root.entry, root.editorText, function (response) {
|
||||
if (response.error) {
|
||||
ToastService.showError(I18n.tr("Failed to update clipboard"));
|
||||
return;
|
||||
}
|
||||
onComplete();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
DMSService.sendRequest("clipboard.copy", {
|
||||
"text": root.editorText
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
ToastService.showError(I18n.tr("Failed to update clipboard"));
|
||||
return;
|
||||
}
|
||||
onComplete();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,6 +350,7 @@ Item {
|
||||
|
||||
width: cancelButton.width
|
||||
height: buttonHeight
|
||||
opacity: root.textLoaded ? 1 : 0.6
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -181,11 +181,44 @@ FocusScope {
|
||||
}
|
||||
|
||||
function editEntry(entry) {
|
||||
if (!entry || entry.isImage) {
|
||||
if (!ClipboardService.canEditEntry(entry)) {
|
||||
return;
|
||||
}
|
||||
editorView.setEntry(entry);
|
||||
mode = "editor";
|
||||
|
||||
const requestedId = entry.id;
|
||||
if (!requestedId) {
|
||||
editorView.setEntry(entry);
|
||||
mode = "editor";
|
||||
return;
|
||||
}
|
||||
|
||||
DMSService.sendRequest("clipboard.getEntry", {
|
||||
"id": requestedId
|
||||
}, function (response) {
|
||||
if (response.error || !response.result) {
|
||||
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||
if (!response.result) {
|
||||
ClipboardService.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = response.result;
|
||||
let fullText = "";
|
||||
if (result?.data) {
|
||||
fullText = editorView.decodeEntryData(result.data);
|
||||
} else {
|
||||
fullText = result?.preview ?? "";
|
||||
}
|
||||
|
||||
if (!fullText && (entry.preview ?? "").length > 0) {
|
||||
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const entryWithText = Object.assign({}, entry, { "text": fullText });
|
||||
editorView.setEntry(entryWithText);
|
||||
mode = "editor";
|
||||
});
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
|
||||
@@ -37,7 +37,7 @@ Item {
|
||||
|
||||
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
||||
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && effectiveModalLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
|
||||
|
||||
function _dockOccupiesSide(side) {
|
||||
if (!SettingsData.showDock)
|
||||
@@ -83,6 +83,12 @@ Item {
|
||||
property bool keepPopoutsOpen: false
|
||||
property var customKeyboardFocus: null
|
||||
property bool useOverlayLayer: false
|
||||
readonly property var effectiveModalLayer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
|
||||
"allow": ["top", "overlay"],
|
||||
"invalidLayer": WlrLayer.Top,
|
||||
"label": "modals",
|
||||
"error": true
|
||||
})
|
||||
property real frozenMotionOffsetX: 0
|
||||
property real frozenMotionOffsetY: 0
|
||||
readonly property alias contentWindow: contentWindow
|
||||
@@ -140,7 +146,7 @@ Item {
|
||||
"phase": phase,
|
||||
"visible": presented,
|
||||
"presented": presented,
|
||||
"layer": root.useOverlayLayer ? "overlay" : "top",
|
||||
"layer": root.effectiveModalLayer === WlrLayer.Overlay ? "overlay" : "top",
|
||||
"barSide": resolvedConnectedBarSide,
|
||||
"bodyRect": bodyRect,
|
||||
"animationOffset": animationOffset,
|
||||
@@ -397,12 +403,7 @@ Item {
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: root.layerNamespace
|
||||
WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
|
||||
"allow": ["top", "overlay"],
|
||||
"invalidLayer": WlrLayer.Top,
|
||||
"label": "modals",
|
||||
"error": true
|
||||
})
|
||||
WlrLayershell.layer: root.effectiveModalLayer
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(shouldHaveFocus, customKeyboardFocus)
|
||||
|
||||
|
||||
@@ -821,24 +821,15 @@ Item {
|
||||
}
|
||||
|
||||
if (isCategoryFiltered) {
|
||||
var categoryPluginId = AppSearchService.getPluginIdForCategory(appCategory);
|
||||
if (categoryPluginId) {
|
||||
var pluginCategoryItems = getPluginItems(categoryPluginId, "");
|
||||
for (var i = 0; i < pluginCategoryItems.length; i++) {
|
||||
allItems.push(pluginCategoryItems[i]);
|
||||
}
|
||||
} else {
|
||||
var rawApps = AppSearchService.getAppsInCategory(appCategory);
|
||||
for (var i = 0; i < rawApps.length; i++) {
|
||||
allItems.push(getOrTransformApp(rawApps[i]));
|
||||
}
|
||||
// Also include core apps (DMS Settings etc.) that match this category
|
||||
var allCoreApps = AppSearchService.getCoreApps("");
|
||||
for (var i = 0; i < allCoreApps.length; i++) {
|
||||
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
|
||||
if (coreAppCats.indexOf(appCategory) !== -1)
|
||||
allItems.push(transformCoreApp(allCoreApps[i]));
|
||||
}
|
||||
var rawApps = AppSearchService.getAppsInCategory(appCategory);
|
||||
for (var i = 0; i < rawApps.length; i++) {
|
||||
allItems.push(getOrTransformApp(rawApps[i]));
|
||||
}
|
||||
var allCoreApps = AppSearchService.getCoreApps("");
|
||||
for (var i = 0; i < allCoreApps.length; i++) {
|
||||
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
|
||||
if (coreAppCats.indexOf(appCategory) !== -1)
|
||||
allItems.push(transformCoreApp(allCoreApps[i]));
|
||||
}
|
||||
} else {
|
||||
var apps = searchApps(searchQuery);
|
||||
|
||||
@@ -85,7 +85,7 @@ Item {
|
||||
|
||||
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
||||
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && effectiveLauncherLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
|
||||
readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
|
||||
|
||||
function _dockOccupiesSide(side) {
|
||||
@@ -246,7 +246,7 @@ Item {
|
||||
"phase": phase,
|
||||
"visible": presented,
|
||||
"presented": presented,
|
||||
"layer": root.usesOverlayLayer ? "overlay" : "top",
|
||||
"layer": root.effectiveLauncherLayer === WlrLayer.Overlay ? "overlay" : "top",
|
||||
"barSide": resolvedConnectedBarSide,
|
||||
"bodyRect": bodyRect,
|
||||
"animationOffset": animationOffset,
|
||||
|
||||
@@ -47,23 +47,20 @@ Rectangle {
|
||||
Row {
|
||||
id: leftContent
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingXS
|
||||
anchors.leftMargin: hasAppCategories ? 0 : Theme.spacingXS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
// Whether the apps category picker should replace the plain title
|
||||
readonly property bool hasAppCategories: root.section?.id === "apps" && (root.controller?.appCategories?.length ?? 0) > 0
|
||||
|
||||
DankIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Hide section icon when the category chip already shows one
|
||||
visible: !leftContent.hasAppCategories
|
||||
name: root.section?.icon ?? "folder"
|
||||
size: 16
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
// Plain title — hidden when the category chip is shown
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !leftContent.hasAppCategories
|
||||
@@ -73,12 +70,10 @@ Rectangle {
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
// Compact inline category chip — only visible on the apps section
|
||||
Item {
|
||||
id: categoryChip
|
||||
visible: leftContent.hasAppCategories
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Size to content with a fixed-min width so it doesn't jump around
|
||||
width: chipRow.implicitWidth + Theme.spacingM * 2
|
||||
height: 24
|
||||
|
||||
@@ -237,7 +232,6 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Size to list content, cap at 10 visible items
|
||||
height: Math.min((root.controller?.appCategories?.length ?? 0) * 34, 10 * 34) + Theme.spacingS * 2 + 4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ DankFloatingWindow {
|
||||
}
|
||||
|
||||
function submitAndClose() {
|
||||
if (!root.visible)
|
||||
return;
|
||||
if (aqueousWorkspace || CompositorService.isAqueous) {
|
||||
if (!aqueousWorkspace || renaming)
|
||||
return;
|
||||
|
||||
@@ -34,6 +34,10 @@ PluginComponent {
|
||||
TailscaleService.connectTailscale(null);
|
||||
}
|
||||
|
||||
onCcWidgetExpanded: {
|
||||
TailscaleService.refresh(null);
|
||||
}
|
||||
|
||||
ccDetailContent: Component {
|
||||
Rectangle {
|
||||
id: detailRoot
|
||||
|
||||
@@ -19,8 +19,11 @@ Rectangle {
|
||||
signal settingsButtonClicked
|
||||
signal headerTapped
|
||||
|
||||
Component.onCompleted: DgopService.addRef("system")
|
||||
Component.onDestruction: DgopService.removeRef("system")
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: "system"
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
|
||||
implicitHeight: 70
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
@@ -19,7 +19,10 @@ Rectangle {
|
||||
|
||||
signal showPortSelector(var node)
|
||||
|
||||
Component.onCompleted: AudioService.refreshSinkPorts()
|
||||
Component.onCompleted: {
|
||||
AudioService.refreshSinkPorts();
|
||||
AudioService.refreshCards();
|
||||
}
|
||||
|
||||
implicitHeight: headerRow.height + (!hasVolumeSliderInCC ? volumeSlider.height : 0) + audioContent.height + Theme.spacingM
|
||||
radius: Theme.cornerRadius
|
||||
@@ -365,6 +368,78 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: AudioService.switchableOutputPorts
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: portDelegate
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
height: 50
|
||||
radius: Theme.cornerRadius
|
||||
color: portMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||
border.color: Theme.outlineLight
|
||||
border.width: 1
|
||||
|
||||
DankRipple {
|
||||
id: portRipple
|
||||
cornerRadius: portDelegate.radius
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: portDelegate.modelData.icon
|
||||
size: Theme.iconSize - 4
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - Theme.iconSize - parent.spacing
|
||||
|
||||
StyledText {
|
||||
text: portDelegate.modelData.title
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
wrapMode: Text.NoWrap
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: portDelegate.modelData.cardDescription
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
wrapMode: Text.NoWrap
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: portMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPressed: mouse => portRipple.trigger(mouse.x, mouse.y)
|
||||
onClicked: AudioService.activateOutputPort(portDelegate.modelData, (ok, message) => ToastService.showToast(message, ok ? ToastService.levelInfo : ToastService.levelError))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
id: playbackHeaderRow
|
||||
anchors.left: parent.left
|
||||
|
||||
@@ -20,12 +20,10 @@ Rectangle {
|
||||
border.color: Theme.outlineMedium
|
||||
border.width: Theme.layerOutlineWidth
|
||||
|
||||
Component.onCompleted: {
|
||||
DgopService.addRef(["diskmounts"]);
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
DgopService.removeRef(["diskmounts"]);
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: ["diskmounts"]
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
|
||||
@@ -67,11 +67,10 @@ CompoundPill {
|
||||
return Theme.surfaceText;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
DgopService.addRef(["diskmounts"]);
|
||||
}
|
||||
Component.onDestruction: {
|
||||
DgopService.removeRef(["diskmounts"]);
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: ["diskmounts"]
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
|
||||
onToggled: {
|
||||
|
||||
@@ -117,10 +117,9 @@ Rectangle {
|
||||
onClicked: root.clicked()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
DgopService.addRef(["diskmounts"]);
|
||||
}
|
||||
Component.onDestruction: {
|
||||
DgopService.removeRef(["diskmounts"]);
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: ["diskmounts"]
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import "../../Common/WheelInput.js" as WheelInput
|
||||
|
||||
MouseArea {
|
||||
id: root
|
||||
@@ -93,16 +94,15 @@ MouseArea {
|
||||
|
||||
const deltaY = wheel.angleDelta.y;
|
||||
const deltaX = wheel.angleDelta.x;
|
||||
const isTouchpadY = wheel.pixelDelta && wheel.pixelDelta.y !== 0;
|
||||
const isTouchpadX = wheel.pixelDelta && wheel.pixelDelta.x !== 0;
|
||||
const isTouchpad = WheelInput.isTouchpad(wheel);
|
||||
|
||||
if (CompositorService.isNiri && xBehavior !== "none" && Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
accumulateX(isTouchpadX, deltaX, xBehavior);
|
||||
accumulateX(isTouchpad, deltaX, xBehavior);
|
||||
return;
|
||||
}
|
||||
if (yBehavior === "none")
|
||||
return;
|
||||
accumulateY(isTouchpadY, deltaY, yBehavior);
|
||||
accumulateY(isTouchpad, deltaY, yBehavior);
|
||||
}
|
||||
|
||||
onWheel: wheel => processWheel(wheel)
|
||||
|
||||
@@ -39,6 +39,7 @@ Item {
|
||||
applyIndexLayout();
|
||||
}
|
||||
updateContentExtent();
|
||||
blurBarWindow?.refreshBlurRegion?.();
|
||||
}
|
||||
|
||||
function updateContentExtent() {
|
||||
|
||||
@@ -103,8 +103,8 @@ Item {
|
||||
|
||||
function triggerControlCenterOnFocusedScreen() {
|
||||
let focusedScreenName = "";
|
||||
if (CompositorService.isHyprland && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor) {
|
||||
focusedScreenName = Hyprland.focusedWorkspace.monitor.name;
|
||||
if (CompositorService.isHyprland && Hyprland.focusedMonitor) {
|
||||
focusedScreenName = Hyprland.focusedMonitor.name;
|
||||
} else if (CompositorService.isNiri && NiriService.currentOutput) {
|
||||
focusedScreenName = NiriService.currentOutput;
|
||||
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
|
||||
@@ -132,8 +132,8 @@ Item {
|
||||
|
||||
function triggerWallpaperBrowserOnFocusedScreen() {
|
||||
let focusedScreenName = "";
|
||||
if (CompositorService.isHyprland && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor) {
|
||||
focusedScreenName = Hyprland.focusedWorkspace.monitor.name;
|
||||
if (CompositorService.isHyprland && Hyprland.focusedMonitor) {
|
||||
focusedScreenName = Hyprland.focusedMonitor.name;
|
||||
} else if (CompositorService.isNiri && NiriService.currentOutput) {
|
||||
focusedScreenName = NiriService.currentOutput;
|
||||
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
|
||||
|
||||
@@ -164,6 +164,12 @@ Item {
|
||||
_blurRebuildTimer.restart();
|
||||
}
|
||||
|
||||
function refreshBlurRegion() {
|
||||
if (!blurRegion)
|
||||
return;
|
||||
blurRegion.changed();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: _blurRebuildTimer
|
||||
interval: 1
|
||||
@@ -386,12 +392,26 @@ Item {
|
||||
Connections {
|
||||
target: topBarSlide
|
||||
function onXChanged() {
|
||||
if (barWindow.blurRegion)
|
||||
barWindow.blurRegion.changed();
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onYChanged() {
|
||||
if (barWindow.blurRegion)
|
||||
barWindow.blurRegion.changed();
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: barUnitInset
|
||||
function onXChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onYChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onWidthChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onHeightChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,8 +421,7 @@ Item {
|
||||
_blurRebuildTimer.restart();
|
||||
}
|
||||
function onWingChanged() {
|
||||
if (barWindow.blurRegion)
|
||||
barWindow.blurRegion.changed();
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1149,20 +1168,8 @@ Item {
|
||||
HoverHandler {
|
||||
id: hoverPopoutHandler
|
||||
enabled: (barConfig?.hoverPopouts ?? false) && !barWindow.clickThroughEnabled
|
||||
|
||||
property real lastGlobalX: 0
|
||||
property real lastGlobalY: 0
|
||||
|
||||
onPointChanged: {
|
||||
const gp = barUnitInset.mapToItem(null, point.position.x, point.position.y);
|
||||
lastGlobalX = gp.x;
|
||||
lastGlobalY = gp.y;
|
||||
topBarContent.queueHoverPopout(gp.x, gp.y);
|
||||
}
|
||||
|
||||
onHoveredChanged: {
|
||||
topBarContent.updateHoverBarHovered(hovered);
|
||||
}
|
||||
onPointChanged: topBarContent.queueHoverFromItem(barUnitInset, point)
|
||||
onHoveredChanged: topBarContent.updateHoverBarHovered(hovered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ Item {
|
||||
property var leftWidgetsModel
|
||||
property var centerWidgetsModel
|
||||
property var rightWidgetsModel
|
||||
property var widgetOwner: null
|
||||
property var hoverSections: null
|
||||
property bool _animateFrameInsets: false
|
||||
|
||||
readonly property real innerPadding: barConfig?.innerPadding ?? 4
|
||||
@@ -85,6 +87,34 @@ Item {
|
||||
property alias vCenterSection: vCenterSection
|
||||
property alias vRightSection: vRightSection
|
||||
|
||||
readonly property var _defaultHoverSections: barWindow.isVertical ? [
|
||||
{
|
||||
section: vLeftSection,
|
||||
name: "left"
|
||||
},
|
||||
{
|
||||
section: vCenterSection,
|
||||
name: "center"
|
||||
},
|
||||
{
|
||||
section: vRightSection,
|
||||
name: "right"
|
||||
}
|
||||
] : [
|
||||
{
|
||||
section: hLeftSection,
|
||||
name: "left"
|
||||
},
|
||||
{
|
||||
section: hCenterSection,
|
||||
name: "center"
|
||||
},
|
||||
{
|
||||
section: hRightSection,
|
||||
name: "right"
|
||||
}
|
||||
]
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: _leftMargin
|
||||
anchors.rightMargin: _rightMargin
|
||||
@@ -413,12 +443,8 @@ Item {
|
||||
barContent: topBarContent
|
||||
barWindow: topBarContent.barWindow
|
||||
barConfig: topBarContent.barConfig
|
||||
hLeftSection: topBarContent.hLeftSection
|
||||
hCenterSection: topBarContent.hCenterSection
|
||||
hRightSection: topBarContent.hRightSection
|
||||
vLeftSection: topBarContent.vLeftSection
|
||||
vCenterSection: topBarContent.vCenterSection
|
||||
vRightSection: topBarContent.vRightSection
|
||||
widgetOwner: topBarContent.widgetOwner
|
||||
sections: topBarContent.hoverSections || topBarContent._defaultHoverSections
|
||||
leftWidgetsModel: topBarContent.leftWidgetsModel
|
||||
centerWidgetsModel: topBarContent.centerWidgetsModel
|
||||
rightWidgetsModel: topBarContent.rightWidgetsModel
|
||||
@@ -427,6 +453,28 @@ Item {
|
||||
readonly property string activeHoverTrigger: hoverController.activeHoverTrigger
|
||||
readonly property bool hoverPopoutsEnabled: hoverController.hoverPopoutsEnabled
|
||||
|
||||
function mapItemToScreen(item, x, y) {
|
||||
if (!item || typeof item.mapToItem !== "function")
|
||||
return null;
|
||||
try {
|
||||
const raw = item.mapToItem(null, x ?? 0, y ?? 0);
|
||||
if (!raw)
|
||||
return null;
|
||||
return Qt.point(raw.x + (barWindow?.hostOriginX ?? 0), raw.y + (barWindow?.hostOriginY ?? 0));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function queueHoverFromItem(item, point) {
|
||||
if (!point)
|
||||
return;
|
||||
const gp = mapItemToScreen(item, point.position.x, point.position.y);
|
||||
if (!gp)
|
||||
return;
|
||||
queueHoverPopout(gp.x, gp.y);
|
||||
}
|
||||
|
||||
function queueHoverPopout(gx, gy) {
|
||||
hoverController.queueHoverPoint(gx, gy);
|
||||
}
|
||||
@@ -447,6 +495,10 @@ Item {
|
||||
hoverController.updateBarHovered(hovered);
|
||||
}
|
||||
|
||||
function invalidateHoverCandidateCache() {
|
||||
hoverController.invalidateCandidateCache();
|
||||
}
|
||||
|
||||
function resetHoverForBarGeometryChange() {
|
||||
hoverController.resetForBarGeometryChange();
|
||||
}
|
||||
@@ -465,14 +517,13 @@ Item {
|
||||
const centerSection = barWindow.isVertical ? vCenterSection : hCenterSection;
|
||||
if (centerSection) {
|
||||
if (barWindow.isVertical) {
|
||||
const centerY = centerSection.height / 2;
|
||||
return {
|
||||
triggerPos: centerSection.mapToItem(null, 0, centerY),
|
||||
triggerPos: mapItemToScreen(centerSection, 0, centerSection.height / 2),
|
||||
triggerWidth: centerSection.height
|
||||
};
|
||||
}
|
||||
return {
|
||||
triggerPos: centerSection.mapToItem(null, 0, 0),
|
||||
triggerPos: mapItemToScreen(centerSection, 0, 0),
|
||||
triggerWidth: centerSection.width
|
||||
};
|
||||
}
|
||||
@@ -480,7 +531,7 @@ Item {
|
||||
const ref = opts.visualItem || widgetItem.visualContent || widgetItem;
|
||||
const w = opts.triggerWidth !== undefined ? opts.triggerWidth : (widgetItem.visualWidth !== undefined ? widgetItem.visualWidth : widgetItem.width);
|
||||
return {
|
||||
triggerPos: ref.mapToItem(null, 0, 0),
|
||||
triggerPos: mapItemToScreen(ref, 0, 0),
|
||||
triggerWidth: w
|
||||
};
|
||||
}
|
||||
@@ -987,7 +1038,9 @@ Item {
|
||||
if (loader.item.setBarContext)
|
||||
loader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0);
|
||||
if (loader.item.setTriggerPosition) {
|
||||
const globalPos = launcherButton.visualContent.mapToItem(null, 0, 0);
|
||||
const globalPos = topBarContent.mapItemToScreen(launcherButton.visualContent, 0, 0);
|
||||
if (!globalPos)
|
||||
return false;
|
||||
const currentScreen = barWindow.screen;
|
||||
const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, barWindow.effectiveBarThickness, launcherButton.visualWidth, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig);
|
||||
loader.item.setTriggerPosition(pos.x, pos.y, pos.width, launcherButton.section, currentScreen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig);
|
||||
|
||||
@@ -10,12 +10,8 @@ Item {
|
||||
required property var barContent
|
||||
required property var barWindow
|
||||
required property var barConfig
|
||||
required property var hLeftSection
|
||||
required property var hCenterSection
|
||||
required property var hRightSection
|
||||
required property var vLeftSection
|
||||
required property var vCenterSection
|
||||
required property var vRightSection
|
||||
property var widgetOwner: null
|
||||
property var sections: []
|
||||
|
||||
property var leftWidgetsModel
|
||||
property var centerWidgetsModel
|
||||
@@ -46,6 +42,7 @@ Item {
|
||||
onLeftWidgetsModelChanged: invalidateCandidateCache()
|
||||
onCenterWidgetsModelChanged: invalidateCandidateCache()
|
||||
onRightWidgetsModelChanged: invalidateCandidateCache()
|
||||
onSectionsChanged: invalidateCandidateCache()
|
||||
|
||||
onHoverPopoutsEnabledChanged: {
|
||||
if (hoverPopoutsEnabled)
|
||||
@@ -62,7 +59,7 @@ Item {
|
||||
Component.onDestruction: _disconnectCandidateWatchers()
|
||||
|
||||
Connections {
|
||||
target: root.barContent
|
||||
target: root.widgetOwner || root.barContent
|
||||
|
||||
function onWidthChanged() {
|
||||
root.invalidateCandidateCache();
|
||||
@@ -227,41 +224,12 @@ Item {
|
||||
}
|
||||
|
||||
function _getBarSections() {
|
||||
if (barWindow.isVertical) {
|
||||
return [
|
||||
{
|
||||
section: vLeftSection,
|
||||
name: "left"
|
||||
},
|
||||
{
|
||||
section: vCenterSection,
|
||||
name: "center"
|
||||
},
|
||||
{
|
||||
section: vRightSection,
|
||||
name: "right"
|
||||
}
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
section: hLeftSection,
|
||||
name: "left"
|
||||
},
|
||||
{
|
||||
section: hCenterSection,
|
||||
name: "center"
|
||||
},
|
||||
{
|
||||
section: hRightSection,
|
||||
name: "right"
|
||||
}
|
||||
];
|
||||
return root.sections || [];
|
||||
}
|
||||
|
||||
// The widget registry is keyed by (widgetId, screenName)
|
||||
function _itemBelongsToThisBar(item) {
|
||||
const owner = barContent;
|
||||
const owner = root.widgetOwner || barContent;
|
||||
if (!owner || !item)
|
||||
return true;
|
||||
let node = item;
|
||||
@@ -489,17 +457,15 @@ Item {
|
||||
}
|
||||
|
||||
function _globalItemBounds(item) {
|
||||
try {
|
||||
const topLeft = item.mapToItem(null, 0, 0);
|
||||
return {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
width: item.width,
|
||||
height: item.height
|
||||
};
|
||||
} catch (e) {
|
||||
const topLeft = barContent.mapItemToScreen(item, 0, 0);
|
||||
if (!topLeft)
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
width: item.width,
|
||||
height: item.height
|
||||
};
|
||||
}
|
||||
|
||||
function _hitBoundsForWidget(widgetItem, wrapper) {
|
||||
|
||||
@@ -25,6 +25,13 @@ Item {
|
||||
readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false)
|
||||
property alias widgetLayoutLoader: layoutLoader
|
||||
|
||||
function refreshBlur() {
|
||||
blurBarWindow?.refreshBlurRegion?.();
|
||||
}
|
||||
|
||||
onXChanged: refreshBlur()
|
||||
onYChanged: refreshBlur()
|
||||
|
||||
implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0
|
||||
implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0
|
||||
|
||||
@@ -52,6 +59,7 @@ Item {
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
width: widgetLoader.item ? widgetLoader.item.width : 0
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onXChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -100,6 +108,7 @@ Item {
|
||||
property var itemData: modelData
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onYChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
@@ -25,6 +25,13 @@ Item {
|
||||
readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false)
|
||||
property alias widgetLayoutLoader: layoutLoader
|
||||
|
||||
function refreshBlur() {
|
||||
blurBarWindow?.refreshBlurRegion?.();
|
||||
}
|
||||
|
||||
onXChanged: refreshBlur()
|
||||
onYChanged: refreshBlur()
|
||||
|
||||
implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0
|
||||
implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0
|
||||
|
||||
@@ -54,6 +61,7 @@ Item {
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
width: widgetLoader.item ? widgetLoader.item.width : 0
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onXChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -102,6 +110,7 @@ Item {
|
||||
property var itemData: modelData
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onYChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
@@ -10,7 +10,7 @@ import qs.Widgets
|
||||
BasePill {
|
||||
id: root
|
||||
|
||||
readonly property string focusedScreenName: (CompositorService.isHyprland && typeof Hyprland !== "undefined" && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor ? (Hyprland.focusedWorkspace.monitor.name || "") : CompositorService.isNiri && typeof NiriService !== "undefined" && NiriService.currentOutput ? NiriService.currentOutput : "")
|
||||
readonly property string focusedScreenName: (CompositorService.isHyprland && typeof Hyprland !== "undefined" && Hyprland.focusedMonitor ? (Hyprland.focusedMonitor.name || "") : CompositorService.isNiri && typeof NiriService !== "undefined" && NiriService.currentOutput ? NiriService.currentOutput : "")
|
||||
readonly property string targetScreenName: parentScreen?.name || focusedScreenName
|
||||
|
||||
function resolveNotepadInstance() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.SystemTray
|
||||
import Quickshell.Wayland
|
||||
@@ -39,13 +40,33 @@ BasePill {
|
||||
return !hiddenTrayIds.includes(itemId.toLowerCase());
|
||||
});
|
||||
}
|
||||
property var _trayKeyByItem: new Map()
|
||||
function getTrayItemKey(item) {
|
||||
const id = item?.id || "";
|
||||
const tooltipTitle = item?.tooltipTitle || "";
|
||||
if (!tooltipTitle || tooltipTitle === id) {
|
||||
return id;
|
||||
if (!id)
|
||||
return "";
|
||||
if (root._trayKeyByItem.has(item))
|
||||
return root._trayKeyByItem.get(item);
|
||||
const taken = new Set();
|
||||
for (const live of root.allTrayItems) {
|
||||
if (live !== item && (live?.id || "") === id && root._trayKeyByItem.has(live))
|
||||
taken.add(root._trayKeyByItem.get(live));
|
||||
}
|
||||
return `${id}::${tooltipTitle}`;
|
||||
let key = id;
|
||||
for (let n = 1; taken.has(key); n++)
|
||||
key = `${id}::${n}`;
|
||||
root._trayKeyByItem.set(item, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
function resolveOrderIndex(key, orderMap) {
|
||||
if (orderMap.has(key))
|
||||
return orderMap.get(key);
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isTrayIdHidden(key) {
|
||||
return SessionData.isHiddenTrayId(key);
|
||||
}
|
||||
|
||||
function trayIconSourceFor(trayItem) {
|
||||
@@ -149,6 +170,13 @@ BasePill {
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SystemTray
|
||||
function onItemUnregistered(item) {
|
||||
root._trayKeyByItem.delete(item);
|
||||
}
|
||||
}
|
||||
|
||||
function sortByPreferredOrder(items, trigger) {
|
||||
void trigger;
|
||||
const savedOrder = SessionData.trayItemOrder || [];
|
||||
@@ -158,15 +186,17 @@ BasePill {
|
||||
return [...items].sort((a, b) => {
|
||||
const keyA = getTrayItemKey(a);
|
||||
const keyB = getTrayItemKey(b);
|
||||
const orderA = orderMap.has(keyA) ? orderMap.get(keyA) : 10000 + items.indexOf(a);
|
||||
const orderB = orderMap.has(keyB) ? orderMap.get(keyB) : 10000 + items.indexOf(b);
|
||||
return orderA - orderB;
|
||||
const orderA = resolveOrderIndex(keyA, orderMap);
|
||||
const orderB = resolveOrderIndex(keyB, orderMap);
|
||||
const posA = orderA >= 0 ? orderA : 10000 + items.indexOf(a);
|
||||
const posB = orderB >= 0 ? orderB : 10000 + items.indexOf(b);
|
||||
return posA - posB;
|
||||
});
|
||||
}
|
||||
|
||||
readonly property var allSortedTrayItems: sortByPreferredOrder(allTrayItems, _trayOrderTrigger)
|
||||
readonly property var allSortedTrayItemKeys: allSortedTrayItems.map(item => getTrayItemKey(item))
|
||||
readonly property var visibleSortedTrayItems: allSortedTrayItems.filter(item => !SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
|
||||
readonly property var visibleSortedTrayItems: allSortedTrayItems.filter(item => !root.isTrayIdHidden(root.getTrayItemKey(item)))
|
||||
readonly property int automaticVisibleItemLimit: {
|
||||
if (!root.useAutomaticOverflow)
|
||||
return root.visibleSortedTrayItems.length;
|
||||
@@ -189,7 +219,7 @@ BasePill {
|
||||
item: item
|
||||
}))
|
||||
readonly property var autoOverflowBarItems: visibleSortedTrayItems.slice(automaticVisibleItemLimit)
|
||||
readonly property var manualHiddenBarItems: allSortedTrayItems.filter(item => SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
|
||||
readonly property var manualHiddenBarItems: allSortedTrayItems.filter(item => root.isTrayIdHidden(root.getTrayItemKey(item)))
|
||||
readonly property var hiddenBarItemKeys: manualHiddenBarItems.concat(autoOverflowBarItems).map(item => root.getTrayItemKey(item))
|
||||
readonly property var hiddenBarItems: allSortedTrayItems.filter(item => hiddenBarItemKeys.indexOf(root.getTrayItemKey(item)) !== -1)
|
||||
readonly property string trayIconTintMode: {
|
||||
@@ -297,7 +327,7 @@ BasePill {
|
||||
}
|
||||
|
||||
function isManualHiddenTrayItem(item) {
|
||||
return SessionData.isHiddenTrayId(getTrayItemKey(item));
|
||||
return root.isTrayIdHidden(root.getTrayItemKey(item));
|
||||
}
|
||||
|
||||
function isAutoOverflowTrayItem(item) {
|
||||
@@ -1899,6 +1929,8 @@ BasePill {
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
elide: Text.ElideMiddle
|
||||
wrapMode: Text.NoWrap
|
||||
maximumLineCount: 1
|
||||
width: parent.width - Theme.spacingS * 2 - (Theme.iconSizeSmall + Theme.spacingS)
|
||||
}
|
||||
|
||||
@@ -2026,7 +2058,7 @@ BasePill {
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
RowLayout {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.right: parent.right
|
||||
@@ -2036,9 +2068,9 @@ BasePill {
|
||||
visible: !menuEntry?.isSeparator
|
||||
|
||||
Rectangle {
|
||||
width: Theme.iconSizeSmall
|
||||
height: Theme.iconSizeSmall
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
Layout.preferredWidth: Theme.iconSizeSmall
|
||||
Layout.preferredHeight: Theme.iconSizeSmall
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0
|
||||
radius: menuEntry?.buttonType === 2 ? width / 2 : 2
|
||||
border.width: 1
|
||||
@@ -2064,9 +2096,9 @@ BasePill {
|
||||
}
|
||||
|
||||
Item {
|
||||
width: Theme.iconSizeSmall
|
||||
height: Theme.iconSizeSmall
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
Layout.preferredWidth: Theme.iconSizeSmall
|
||||
Layout.preferredHeight: Theme.iconSizeSmall
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: (menuEntry?.icon ?? "") !== ""
|
||||
|
||||
Image {
|
||||
@@ -2084,22 +2116,23 @@ BasePill {
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: (menuEntry?.enabled !== false) ? Theme.surfaceText : Theme.surfaceTextMedium
|
||||
elide: Text.ElideRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.max(150, parent.width - 64)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumWidth: 0
|
||||
wrapMode: Text.NoWrap
|
||||
}
|
||||
|
||||
Item {
|
||||
width: Theme.iconSizeSmall
|
||||
height: Theme.iconSizeSmall
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
Layout.preferredWidth: Theme.iconSizeSmall
|
||||
Layout.preferredHeight: Theme.iconSizeSmall
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: menuEntry?.hasChildren ?? false
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "chevron_right"
|
||||
size: Theme.iconSizeSmall - 2
|
||||
color: Theme.widgetTextColor
|
||||
visible: menuEntry?.hasChildren ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Quickshell.WindowManager
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import "../../../Common/WheelInput.js" as WheelInput
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -26,44 +27,19 @@ Item {
|
||||
readonly property bool isMango: CompositorService.isMango
|
||||
readonly property bool useAqueous: CompositorService.isAqueous && AqueousService.available && Quickshell.env("DMS_FORCE_EXTWS") !== "1"
|
||||
|
||||
readonly property real _leftMargin: {
|
||||
if (isVertical)
|
||||
return 0;
|
||||
root.x;
|
||||
if (!root.parent)
|
||||
return 0;
|
||||
const gap = root.mapToItem(null, 0, 0).x;
|
||||
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||
}
|
||||
readonly property real _rightMargin: {
|
||||
if (isVertical)
|
||||
return 0;
|
||||
root.x;
|
||||
root.width;
|
||||
if (!root.parent || !blurBarWindow)
|
||||
return 0;
|
||||
const gap = blurBarWindow.width - root.mapToItem(null, root.width, 0).x;
|
||||
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||
}
|
||||
readonly property real _topMargin: {
|
||||
if (!isVertical)
|
||||
return axis?.edge === "top" ? crossEdgeExtension : 0;
|
||||
root.y;
|
||||
if (!root.parent)
|
||||
return 0;
|
||||
const gap = root.mapToItem(null, 0, 0).y;
|
||||
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||
}
|
||||
readonly property real _bottomMargin: {
|
||||
if (!isVertical)
|
||||
return axis?.edge === "bottom" ? crossEdgeExtension : 0;
|
||||
root.y;
|
||||
root.height;
|
||||
if (!root.parent || !blurBarWindow)
|
||||
return 0;
|
||||
const gap = blurBarWindow.height - root.mapToItem(null, 0, root.height).y;
|
||||
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||
}
|
||||
property bool isFirst: false
|
||||
property bool isLast: false
|
||||
property real sectionSpacing: 0
|
||||
property bool isLeftBarEdge: false
|
||||
property bool isRightBarEdge: false
|
||||
property bool isTopBarEdge: false
|
||||
property bool isBottomBarEdge: false
|
||||
|
||||
readonly property real barEdgeExtension: 1000
|
||||
readonly property real _leftMargin: isVertical ? 0 : (isLeftBarEdge && isFirst ? barEdgeExtension : (isFirst ? sectionSpacing : sectionSpacing / 2))
|
||||
readonly property real _rightMargin: isVertical ? 0 : (isRightBarEdge && isLast ? barEdgeExtension : (isLast ? sectionSpacing : sectionSpacing / 2))
|
||||
readonly property real _topMargin: isVertical ? (isTopBarEdge && isFirst ? barEdgeExtension : (isFirst ? sectionSpacing : sectionSpacing / 2)) : (axis?.edge === "top" ? crossEdgeExtension : 0)
|
||||
readonly property real _bottomMargin: isVertical ? (isBottomBarEdge && isLast ? barEdgeExtension : (isLast ? sectionSpacing : sectionSpacing / 2)) : (axis?.edge === "bottom" ? crossEdgeExtension : 0)
|
||||
|
||||
property int _desktopEntriesUpdateTrigger: 0
|
||||
readonly property var sortedToplevels: {
|
||||
@@ -1017,7 +993,7 @@ Item {
|
||||
return;
|
||||
|
||||
const delta = wheel.angleDelta.y;
|
||||
const isTouchpad = wheel.pixelDelta && wheel.pixelDelta.y !== 0;
|
||||
const isTouchpad = WheelInput.isTouchpad(wheel);
|
||||
const reverse = SettingsData.reverseScrolling ? -1 : 1;
|
||||
|
||||
if (isTouchpad) {
|
||||
|
||||
@@ -194,7 +194,6 @@ Item {
|
||||
id: mediaExpandedLoader
|
||||
|
||||
activity: "media"
|
||||
height: Math.max(target.height, root.height)
|
||||
active: root.mediaSurfaceActive && (root.expanded || root.expandedFade > 0)
|
||||
asynchronous: false
|
||||
sourceComponent: root.mediaExpandedComponent
|
||||
|
||||
@@ -153,6 +153,9 @@ Item {
|
||||
property bool hasAdjacentLeftBar: false
|
||||
property bool hasAdjacentRightBar: false
|
||||
property var hyprlandOverviewLoader: root.hyprlandOverviewLoader
|
||||
property var hostWindow: root.hostWindow
|
||||
readonly property int hostOriginX: hostWindow?.hostOriginX ?? 0
|
||||
readonly property int hostOriginY: hostWindow?.hostOriginY ?? 0
|
||||
property var controlCenterButtonRef: null
|
||||
property var clockButtonRef: null
|
||||
property var systemUpdateButtonRef: null
|
||||
@@ -166,6 +169,9 @@ Item {
|
||||
function unregisterBlurWidget(item) {
|
||||
root.unregisterBlurWidget(item);
|
||||
}
|
||||
function refreshBlurRegion() {
|
||||
root.kickBlur();
|
||||
}
|
||||
}
|
||||
|
||||
property var _blurWidgetItems: []
|
||||
@@ -266,9 +272,16 @@ Item {
|
||||
return;
|
||||
blurRebuildTimer.restart();
|
||||
blurTrailTimer.restart();
|
||||
componentProvider.invalidateHoverCandidateCache();
|
||||
}
|
||||
}
|
||||
|
||||
function onSatelliteRowMoved() {
|
||||
kickBlur();
|
||||
if (!root.motionRunning)
|
||||
componentProvider.invalidateHoverCandidateCache();
|
||||
}
|
||||
|
||||
function kickBlur() {
|
||||
if (root.motionRunning)
|
||||
return;
|
||||
@@ -409,11 +422,26 @@ Item {
|
||||
barWindow: satelliteBarWindow
|
||||
rootWindow: root.hostWindow
|
||||
barConfig: root.satelliteConfig
|
||||
widgetOwner: root
|
||||
hoverSections: [
|
||||
{
|
||||
section: leadingWidgetSection,
|
||||
name: "left"
|
||||
},
|
||||
{
|
||||
section: trailingWidgetSection,
|
||||
name: "right"
|
||||
}
|
||||
]
|
||||
leftWidgetsModel: emptyWidgetsModel
|
||||
centerWidgetsModel: emptyWidgetsModel
|
||||
rightWidgetsModel: emptyWidgetsModel
|
||||
}
|
||||
|
||||
readonly property bool satelliteHoverTracking: root.visible && componentProvider.hoverPopoutsEnabled && !root.controller.inputSuspended
|
||||
readonly property bool satellitesHovered: satelliteHoverTracking && (leadingEnvelopeHoverHandler.hovered || trailingEnvelopeHoverHandler.hovered || leadingWidgetHoverHandler.hovered || trailingWidgetHoverHandler.hovered)
|
||||
onSatellitesHoveredChanged: componentProvider.updateHoverBarHovered(satellitesHovered)
|
||||
|
||||
Item {
|
||||
id: leadingInputEnvelope
|
||||
|
||||
@@ -426,6 +454,12 @@ Item {
|
||||
y: root.isVertical ? alongPos : crossPos
|
||||
width: root.isVertical ? crossSize : alongSize
|
||||
height: root.isVertical ? alongSize : crossSize
|
||||
|
||||
HoverHandler {
|
||||
id: leadingEnvelopeHoverHandler
|
||||
enabled: root.satelliteHoverTracking
|
||||
onPointChanged: componentProvider.queueHoverFromItem(leadingInputEnvelope, point)
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -441,6 +475,12 @@ Item {
|
||||
y: root.isVertical ? alongPos : crossPos
|
||||
width: root.isVertical ? crossSize : alongSize
|
||||
height: root.isVertical ? alongSize : crossSize
|
||||
|
||||
HoverHandler {
|
||||
id: trailingEnvelopeHoverHandler
|
||||
enabled: root.satelliteHoverTracking
|
||||
onPointChanged: componentProvider.queueHoverFromItem(trailingInputEnvelope, point)
|
||||
}
|
||||
}
|
||||
|
||||
IslandSatelliteChrome {
|
||||
@@ -497,8 +537,10 @@ Item {
|
||||
y: root.isVertical ? alongPos : root.rowCross
|
||||
width: root.isVertical ? crossSize : alongSize
|
||||
height: root.isVertical ? alongSize : crossSize
|
||||
onAlongPosChanged: root.kickBlur()
|
||||
onAlongSizeChanged: root.kickBlur()
|
||||
onXChanged: root.onSatelliteRowMoved()
|
||||
onYChanged: root.onSatelliteRowMoved()
|
||||
onWidthChanged: root.onSatelliteRowMoved()
|
||||
onHeightChanged: root.onSatelliteRowMoved()
|
||||
|
||||
LeftSection {
|
||||
id: leadingWidgetSection
|
||||
@@ -518,6 +560,12 @@ Item {
|
||||
crossEdgeExtension: root.crossEdgeExtension
|
||||
edgeIsScreenEdge: root.edgeAligned
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: leadingWidgetHoverHandler
|
||||
enabled: root.satelliteHoverTracking
|
||||
onPointChanged: componentProvider.queueHoverFromItem(leadingInput, point)
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -531,8 +579,10 @@ Item {
|
||||
y: root.isVertical ? alongPos : root.rowCross
|
||||
width: root.isVertical ? crossSize : alongSize
|
||||
height: root.isVertical ? alongSize : crossSize
|
||||
onAlongPosChanged: root.kickBlur()
|
||||
onAlongSizeChanged: root.kickBlur()
|
||||
onXChanged: root.onSatelliteRowMoved()
|
||||
onYChanged: root.onSatelliteRowMoved()
|
||||
onWidthChanged: root.onSatelliteRowMoved()
|
||||
onHeightChanged: root.onSatelliteRowMoved()
|
||||
|
||||
RightSection {
|
||||
id: trailingWidgetSection
|
||||
@@ -552,5 +602,11 @@ Item {
|
||||
crossEdgeExtension: root.crossEdgeExtension
|
||||
edgeIsScreenEdge: root.edgeAligned
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: trailingWidgetHoverHandler
|
||||
enabled: root.satelliteHoverTracking
|
||||
onPointChanged: componentProvider.queueHoverFromItem(trailingInput, point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ Item {
|
||||
onTriggered: dock.startupRevealDone = true
|
||||
}
|
||||
|
||||
readonly property bool overviewReveal: CompositorService.overviewActiveOnScreen(screenName) && SettingsData.dockOpenOnOverview
|
||||
readonly property bool overviewReveal: CompositorService.overviewActiveOnScreen(dock._dockScreenName) && SettingsData.dockOpenOnOverview
|
||||
readonly property bool hoverOrActive: dockMouseArea.containsMouse || dockApps.requestDockShow || contextMenuOpen || revealSticky
|
||||
|
||||
onOverviewRevealChanged: {
|
||||
|
||||
@@ -43,7 +43,7 @@ Variants {
|
||||
if (launcherOpen || _openedForCurrentHover)
|
||||
return;
|
||||
_openedForCurrentHover = true;
|
||||
PopoutService.openDankLauncherV2(CompositorService.framePeerSurfacesUseOverlayForScreen(zoneLoader.modelData), true);
|
||||
PopoutService.openDankLauncherV2(false, true);
|
||||
}
|
||||
|
||||
screen: zoneLoader.modelData
|
||||
|
||||
@@ -272,7 +272,8 @@ Scope {
|
||||
property bool isActiveScreen: {
|
||||
if (Quickshell.screens.length <= 1)
|
||||
return true;
|
||||
return SettingsData.getFilteredScreens("lockScreen").includes(screen);
|
||||
const screens = SettingsData.getFilteredScreens("lockScreen");
|
||||
return screens.length === 0 || screens.includes(screen);
|
||||
}
|
||||
|
||||
color: isActiveScreen ? "transparent" : SettingsData.lockScreenInactiveColor
|
||||
|
||||
@@ -82,6 +82,8 @@ Item {
|
||||
return I18n.tr("Too many attempts - locked out");
|
||||
if (root.pamState === "fail")
|
||||
return I18n.tr("Incorrect password - try again");
|
||||
if (pam.fprint.status === "disabled")
|
||||
return "";
|
||||
if (pam.fprintState === "error")
|
||||
return I18n.tr("Fingerprint error");
|
||||
if (pam.fprintState === "max")
|
||||
@@ -861,10 +863,15 @@ Item {
|
||||
name: {
|
||||
if (pam.u2fPending)
|
||||
return "passkey";
|
||||
if (pam.fprint.tries >= SettingsData.maxFprintTries)
|
||||
switch (pam.fprint.status) {
|
||||
case "max":
|
||||
case "stopped":
|
||||
return "fingerprint_off";
|
||||
if (pam.fprint.active || pam.fprint.retrying)
|
||||
case "active":
|
||||
return "fingerprint";
|
||||
case "retrying":
|
||||
return "hourglass_empty";
|
||||
}
|
||||
if (pam.u2f.active)
|
||||
return "passkey";
|
||||
return "lock";
|
||||
@@ -1005,6 +1012,8 @@ Item {
|
||||
return;
|
||||
}
|
||||
|
||||
root.pam.retryFprintOnActivity();
|
||||
|
||||
if (root.unlocking) {
|
||||
event.accepted = true;
|
||||
return;
|
||||
|
||||
@@ -16,7 +16,20 @@ FocusScope {
|
||||
signal passwordChanged(string newPassword)
|
||||
signal unlockRequested
|
||||
|
||||
HoverHandler {
|
||||
enabled: root.pam.awaitingActivityRetry
|
||||
onPointChanged: root.pam.retryFprintOnActivity()
|
||||
}
|
||||
|
||||
PointHandler {
|
||||
onActiveChanged: {
|
||||
if (active)
|
||||
root.pam.retryFprintOnActivity();
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onPressed: event => {
|
||||
root.pam.retryFprintOnActivity();
|
||||
if (videoScreensaver.active && videoScreensaver.inputEnabled) {
|
||||
videoScreensaver.dismiss();
|
||||
event.accepted = true;
|
||||
|
||||
+154
-52
@@ -32,9 +32,8 @@ Scope {
|
||||
|
||||
function resetAuthFlows(): void {
|
||||
passwd.abort();
|
||||
fprint.abort();
|
||||
fprint.stop();
|
||||
u2f.abort();
|
||||
errorRetry.running = false;
|
||||
u2fErrorRetry.running = false;
|
||||
u2fPendingTimeout.running = false;
|
||||
passwdActiveTimeout.running = false;
|
||||
@@ -58,9 +57,8 @@ Scope {
|
||||
if (!root.unlockInProgress) {
|
||||
root.unlockInProgress = true;
|
||||
passwd.abort();
|
||||
fprint.abort();
|
||||
fprint.stop();
|
||||
u2f.abort();
|
||||
errorRetry.running = false;
|
||||
u2fErrorRetry.running = false;
|
||||
u2fPendingTimeout.running = false;
|
||||
root.u2fPending = false;
|
||||
@@ -217,7 +215,7 @@ Scope {
|
||||
|
||||
if (res === PamResult.Success) {
|
||||
if (!root.unlockInProgress) {
|
||||
fprint.abort();
|
||||
fprint.stop();
|
||||
root.proceedAfterPrimaryAuth();
|
||||
}
|
||||
return;
|
||||
@@ -260,69 +258,147 @@ Scope {
|
||||
PamContext {
|
||||
id: fprint
|
||||
|
||||
property bool available: SettingsData.lockFingerprintReady
|
||||
property int tries
|
||||
property int errorTries
|
||||
property bool retrying: false
|
||||
readonly property bool available: SettingsData.lockFingerprintReady
|
||||
property int tries: 0
|
||||
property int errorTries: 0
|
||||
property double attemptStartedAt: 0
|
||||
property bool completedDuringStart: false
|
||||
readonly property int maxErrorTries: 200
|
||||
readonly property int daemonIdleExitMs: 30000
|
||||
// sessionTimeoutMs mirrors `timeout=` in assets/pam/fprint
|
||||
readonly property int sessionTimeoutMs: 90000
|
||||
readonly property int verifyStartSlackMs: 10000
|
||||
readonly property bool retrying: errorRetry.running
|
||||
readonly property int retryInterval: errorRetry.interval
|
||||
readonly property bool allowed: available && SettingsData.enableFprint && root.lockSecured && !root.fprintSuppressedByPrimaryPam && !root.unlockInProgress && !root.u2fPending && !SessionService.preparingForSleep && !IdleService.monitorsOff && tries < SettingsData.maxFprintTries
|
||||
readonly property string status: {
|
||||
if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByPrimaryPam)
|
||||
return "disabled";
|
||||
if (tries >= SettingsData.maxFprintTries)
|
||||
return "max";
|
||||
if (!allowed)
|
||||
return "paused";
|
||||
if (active)
|
||||
return "active";
|
||||
if (retrying)
|
||||
return "retrying";
|
||||
return errorTries >= maxErrorTries ? "stopped" : "idle";
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
errorRetry.stop();
|
||||
attemptStartedAt = 0;
|
||||
abort();
|
||||
}
|
||||
|
||||
// pam_fprintd reports an expired timeout as PAM_AUTHINFO_UNAVAIL, the same
|
||||
// result a device fault gives, and its "Verification timed out" message is
|
||||
// translated, so age is the signal we can rely on. Its timeout runs from the
|
||||
// verify rather than from the claim, which is why the age is measured from
|
||||
// the message announcing the verify: a slow claim then cannot make a fault
|
||||
// look old enough, and a session that never reached a verify never qualifies.
|
||||
// That message lands after pam_fprintd has already armed its deadline, so the
|
||||
// age read here runs short of the real timeout and the slack makes it up. The
|
||||
// bias is deliberate: a timeout misread as a fault parks the reader behind the
|
||||
// backoff, while a fault misread as a timeout costs one extra retry.
|
||||
function attemptSettled(): bool {
|
||||
return attemptStartedAt > 0 && Date.now() - attemptStartedAt >= sessionTimeoutMs - verifyStartSlackMs;
|
||||
}
|
||||
|
||||
function checkAvail(): void {
|
||||
if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByPrimaryPam) {
|
||||
retrying = false;
|
||||
abort();
|
||||
if (!allowed) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
if (!active && !retrying)
|
||||
startAttempt();
|
||||
}
|
||||
|
||||
function startAttempt(): void {
|
||||
if (!allowed || errorTries >= maxErrorTries) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
if (active)
|
||||
return;
|
||||
errorRetry.stop();
|
||||
if (root.fprintState === "error")
|
||||
root.fprintState = "";
|
||||
attemptStartedAt = 0;
|
||||
completedDuringStart = false;
|
||||
if (start())
|
||||
return;
|
||||
if (!completedDuringStart)
|
||||
scheduleErrorRetry();
|
||||
}
|
||||
|
||||
tries = 0;
|
||||
function scheduleErrorRetry(): void {
|
||||
stop();
|
||||
if (!allowed)
|
||||
return;
|
||||
errorTries++;
|
||||
root.fprintState = "error";
|
||||
if (errorTries === 1)
|
||||
root.flashMsg();
|
||||
fprintStateReset.restart();
|
||||
if (errorTries < maxErrorTries)
|
||||
errorRetry.restart();
|
||||
}
|
||||
|
||||
function recover(): void {
|
||||
if (!allowed)
|
||||
return;
|
||||
errorTries = 0;
|
||||
retrying = false;
|
||||
start();
|
||||
if (!active)
|
||||
startAttempt();
|
||||
}
|
||||
|
||||
onAllowedChanged: {
|
||||
if (!allowed)
|
||||
stop();
|
||||
else
|
||||
Qt.callLater(checkAvail);
|
||||
}
|
||||
|
||||
config: "fprint"
|
||||
configDirectory: Quickshell.shellDir + "/assets/pam"
|
||||
|
||||
onPamMessage: {
|
||||
if (attemptStartedAt === 0)
|
||||
attemptStartedAt = Date.now();
|
||||
}
|
||||
|
||||
onCompleted: res => {
|
||||
if (!available)
|
||||
completedDuringStart = true;
|
||||
if (!allowed)
|
||||
return;
|
||||
|
||||
switch (res) {
|
||||
case PamResult.Success:
|
||||
retrying = false;
|
||||
if (!root.unlockInProgress) {
|
||||
passwd.abort();
|
||||
root.proceedAfterPrimaryAuth();
|
||||
}
|
||||
stop();
|
||||
passwd.abort();
|
||||
root.proceedAfterPrimaryAuth();
|
||||
return;
|
||||
case PamResult.Error:
|
||||
errorTries++;
|
||||
if (errorTries < 200) {
|
||||
retrying = true;
|
||||
abort();
|
||||
errorRetry.restart();
|
||||
if (attemptSettled()) {
|
||||
stop();
|
||||
errorTries = 0;
|
||||
startAttempt();
|
||||
return;
|
||||
}
|
||||
retrying = false;
|
||||
abort();
|
||||
scheduleErrorRetry();
|
||||
return;
|
||||
case PamResult.Failed:
|
||||
case PamResult.MaxTries:
|
||||
retrying = false;
|
||||
stop();
|
||||
tries++;
|
||||
if (tries < SettingsData.maxFprintTries) {
|
||||
root.fprintState = "fail";
|
||||
start();
|
||||
} else {
|
||||
root.fprintState = "max";
|
||||
abort();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
errorTries = 0;
|
||||
root.fprintState = tries < SettingsData.maxFprintTries ? "fail" : "max";
|
||||
root.flashMsg();
|
||||
fprintStateReset.restart();
|
||||
startAttempt();
|
||||
return;
|
||||
}
|
||||
|
||||
root.flashMsg();
|
||||
fprintStateReset.start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,20 +502,35 @@ Scope {
|
||||
Timer {
|
||||
id: errorRetry
|
||||
|
||||
interval: Math.min(1500 * Math.pow(2, Math.max(0, fprint.errorTries - 1)), 30000)
|
||||
onTriggered: fprint.start()
|
||||
// A device wedged by a suspend mid-verify only comes back when fprintd
|
||||
// restarts, and fprintd only exits after its idle timeout with no client
|
||||
// attached, so the second retry outlasts that instead of climbing to it.
|
||||
interval: {
|
||||
if (fprint.errorTries <= 1)
|
||||
return 1500;
|
||||
if (fprint.errorTries === 2)
|
||||
return fprint.daemonIdleExitMs + 5000;
|
||||
return 60000;
|
||||
}
|
||||
onTriggered: fprint.startAttempt()
|
||||
}
|
||||
|
||||
readonly property bool awaitingActivityRetry: fprint.errorTries > 0 && !fprint.retrying && !fprint.active
|
||||
|
||||
function retryFprintOnActivity(): void {
|
||||
if (root.awaitingActivityRetry)
|
||||
fprint.recover();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SessionService
|
||||
|
||||
// timers run on monotonic time, so suspend leaves the backoff remainder to burn after wake (#3171)
|
||||
function onSessionResumed() {
|
||||
if (!fprint.available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByPrimaryPam)
|
||||
return;
|
||||
fprint.errorTries = 0;
|
||||
if (errorRetry.running)
|
||||
errorRetry.restart();
|
||||
fprint.recover();
|
||||
}
|
||||
|
||||
function onLidOpened() {
|
||||
fprint.recover();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +582,10 @@ Scope {
|
||||
id: fprintStateReset
|
||||
|
||||
interval: 4000
|
||||
onTriggered: root.fprintState = ""
|
||||
onTriggered: {
|
||||
if (root.fprintState !== "max")
|
||||
root.fprintState = "";
|
||||
}
|
||||
}
|
||||
|
||||
onLockSecuredChanged: {
|
||||
@@ -508,6 +602,8 @@ Scope {
|
||||
root.attemptInfoMessages = [];
|
||||
root.lockoutAnnouncedThisAttempt = false;
|
||||
root.resetAuthFlows();
|
||||
fprint.tries = 0;
|
||||
fprint.errorTries = 0;
|
||||
if (!SettingsData.lockPamExternallyManaged && !dankshellConfigWatcher.loaded && !userPamWatcher.loaded)
|
||||
ensureUserPamConfig();
|
||||
// FileView cannot watch a path that does not exist yet; re-read so a
|
||||
@@ -521,11 +617,17 @@ Scope {
|
||||
target: SettingsData
|
||||
|
||||
function onEnableFprintChanged(): void {
|
||||
fprint.checkAvail();
|
||||
if (SettingsData.enableFprint)
|
||||
fprint.recover();
|
||||
else
|
||||
fprint.checkAvail();
|
||||
}
|
||||
|
||||
function onLockFingerprintReadyChanged(): void {
|
||||
fprint.checkAvail();
|
||||
if (SettingsData.lockFingerprintReady)
|
||||
fprint.recover();
|
||||
else
|
||||
fprint.checkAvail();
|
||||
}
|
||||
|
||||
function onEnableU2fChanged(): void {
|
||||
|
||||
@@ -698,7 +698,7 @@ Column {
|
||||
color: Theme.surfaceTextSecondary
|
||||
font.family: textArea.font.family
|
||||
font.pixelSize: textArea.font.pixelSize
|
||||
visible: textArea.text.length === 0
|
||||
visible: textArea.text.length === 0 && !textArea.inputMethodComposing
|
||||
anchors.left: textArea.left
|
||||
anchors.top: textArea.top
|
||||
anchors.leftMargin: textArea.leftPadding
|
||||
|
||||
@@ -16,22 +16,43 @@ Item {
|
||||
return Theme.surfaceText;
|
||||
}
|
||||
|
||||
// The sysfs names DMS can write, in the order they are tried. Hardware that
|
||||
// exposes none of them, a Lenovo IdeaPad on ideapad_laptop for instance,
|
||||
// used to run the apply script to completion without writing anything, and
|
||||
// sh exiting 0 was indistinguishable from a successful write.
|
||||
readonly property string thresholdFileList: "charge_control_limit_max charge_stop_threshold charge_control_end_threshold"
|
||||
readonly property int noThresholdExitCode: 2
|
||||
|
||||
property bool chargeLimitSupported: true
|
||||
|
||||
Process {
|
||||
id: thresholdProbe
|
||||
running: Qt.platform.os === "linux"
|
||||
command: ["sh", "-c", "for bat in /sys/class/power_supply/BAT*; do for file in " + root.thresholdFileList + "; do [ -f \"$bat/$file\" ] && exit 0; done; done; exit 1"]
|
||||
onExited: exitCode => root.chargeLimitSupported = exitCode === 0
|
||||
}
|
||||
|
||||
Process {
|
||||
id: applyLimitProcess
|
||||
command: ["pkexec", "sh", "-c", "
|
||||
found=0
|
||||
for bat in /sys/class/power_supply/BAT*; do
|
||||
if [ -f \"$bat/charge_control_limit_max\" ]; then
|
||||
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_limit_max\"
|
||||
elif [ -f \"$bat/charge_stop_threshold\" ]; then
|
||||
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_stop_threshold\"
|
||||
elif [ -f \"$bat/charge_control_end_threshold\" ]; then
|
||||
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_end_threshold\"
|
||||
fi
|
||||
for file in " + root.thresholdFileList + "; do
|
||||
if [ -f \"$bat/$file\" ]; then
|
||||
found=1
|
||||
echo " + SettingsData.batteryChargeLimit + " > \"$bat/$file\" || exit 1
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
[ \"$found\" = 1 ] || exit " + root.noThresholdExitCode + "
|
||||
"]
|
||||
running: false
|
||||
onExited: exitCode => {
|
||||
if (exitCode !== 0) {
|
||||
if (exitCode === root.noThresholdExitCode) {
|
||||
root.chargeLimitSupported = false;
|
||||
ToastService.showError(I18n.tr("Charge limit not supported on this hardware", "battery settings: toast title when sysfs has no charge threshold"), I18n.tr("No writable charge threshold file was found under /sys/class/power_supply.", "battery settings: why the charge limit could not be applied"));
|
||||
} else if (exitCode !== 0) {
|
||||
ToastService.showError(I18n.tr("Failed to apply charge limit to system"), I18n.tr("Process exited with code %1").arg(exitCode));
|
||||
} else {
|
||||
ToastService.showInfo(I18n.tr("Charge limit applied successfully"), I18n.tr("Limit set to %1%").arg(SettingsData.batteryChargeLimit));
|
||||
@@ -214,9 +235,24 @@ done
|
||||
onSliderValueChanged: newValue => SettingsData.set("batteryChargeLimit", newValue)
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
// A machine without a battery has no charge threshold to
|
||||
// write to either, but saying so there would be noise: the
|
||||
// limit was never applicable in the first place.
|
||||
visible: Qt.platform.os === "linux" && BatteryService.batteryAvailable && !root.chargeLimitSupported
|
||||
text: I18n.tr("No writable charge threshold file was found under /sys/class/power_supply.", "battery settings: why the charge limit could not be applied")
|
||||
wrapMode: Text.WordWrap
|
||||
color: Theme.surfaceVariantText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
|
||||
Row {
|
||||
// charge_control_* live in Linux sysfs; no BSD equivalent
|
||||
visible: Qt.platform.os === "linux"
|
||||
// charge_control_* live in Linux sysfs; no BSD equivalent.
|
||||
// Without a battery the apply button cannot do anything,
|
||||
// and offering it anyway is what produced the false
|
||||
// success this change is about.
|
||||
visible: Qt.platform.os === "linux" && BatteryService.batteryAvailable && root.chargeLimitSupported
|
||||
width: parent.width
|
||||
height: applyButton.height
|
||||
layoutDirection: I18n.isRtl ? Qt.LeftToRight : Qt.RightToLeft
|
||||
|
||||
@@ -1113,6 +1113,13 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: HyprlandService
|
||||
function onMonitorLayoutChanged() {
|
||||
root.outputs = root.buildOutputsMap();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: CompositorService
|
||||
function onCompositorChanged() {
|
||||
@@ -1806,6 +1813,13 @@ Singleton {
|
||||
"transform": mapWlrTransform(output.transform)
|
||||
}
|
||||
};
|
||||
const live = HyprlandService.liveMonitor(output.name);
|
||||
if (!live)
|
||||
continue;
|
||||
map[output.name].logical.x = live.x;
|
||||
map[output.name].logical.y = live.y;
|
||||
map[output.name].logical.scale = live.scale || 1.0;
|
||||
map[output.name].logical.transform = hyprlandToTransform(live.lastIpcObject?.transform ?? 0);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -244,14 +244,13 @@ StyledRect {
|
||||
width: parent.width
|
||||
dropdownWidth: parent.width
|
||||
visible: !scaleContainer.customMode
|
||||
currentValue: scaleContainer.currentScale
|
||||
options: scaleContainer.scaleOptionsData.labels
|
||||
onValueChanged: value => {
|
||||
if (value === I18n.tr("Custom...")) {
|
||||
scaleContainer.customMode = true;
|
||||
currentValue = scaleContainer.currentScale;
|
||||
scaleInput.text = scaleContainer.currentScale;
|
||||
scaleInput.forceActiveFocus();
|
||||
scaleInput.selectAll();
|
||||
scaleContainer.customMode = true;
|
||||
customFocusTimer.restart();
|
||||
return;
|
||||
}
|
||||
const mapped = scaleContainer.scaleOptionsData.valueByLabel[value];
|
||||
@@ -259,6 +258,22 @@ StyledRect {
|
||||
}
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: scaleDropdown
|
||||
property: "currentValue"
|
||||
value: scaleContainer.currentScale
|
||||
}
|
||||
|
||||
// the dropdown takes focus back when its menu finishes closing
|
||||
Timer {
|
||||
id: customFocusTimer
|
||||
interval: Theme.shortDuration + 100
|
||||
onTriggered: {
|
||||
scaleInput.forceActiveFocus();
|
||||
scaleInput.selectAll();
|
||||
}
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
id: scaleInput
|
||||
width: parent.width
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user