1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-07 14:08:29 -04:00

Compare commits

...

47 Commits

Author SHA1 Message Date
purian23 ec70437b36 feat(dankdash): add native DankConnect integration 2026-08-06 23:25:44 -04:00
David Mireles 3f6cd0b579 feat(registry): support multiple plugin and theme registries via env vars (#2972)
feat(registry): support multiple plugin/theme registries

closes #2763

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-08-06 22:43:44 -04:00
dms-ci[bot] 630e9bd3cd nix: sync flake.lock to dank-qml-common submodule 2026-08-07 00:52:37 +00:00
purian23 9013464312 feat(theme/settings): dedicated floating window styling with shared chrome
- Refactor Widget Styling into Surface Styling & Floating
  Windows
- New sync per-host opacity & foreground layer control
- Shared DankFloatingWindow chrome
- Settings fix to destroy Settings window on-hide so niri floating
  rules match subsequent runs
Related: #2709
2026-08-06 20:52:14 -04:00
Huỳnh Thiện Lộc dade416ca0 feat(settings): add keyboard configuration tab for Niri (#2937)
* feat(settings): add dedicated keyboard configuration tab

* chore(settings): configure default keyboard settings and clean up embedded niri config

* settings/keyboard: emit niri keyboard config only when set, dedup setup banner, index tab

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-08-06 18:40:17 -04:00
FormalSnake 401872881e media: add opt-in Apple Music animated album covers (#2918)
* media: add opt-in Apple Music animated album covers

* media: validate itunes match, touch files for cache

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-08-06 14:47:51 -04:00
Huỳnh Thiện Lộc 7cb97f4462 fix(launcher): honor source badge setting (#3002) 2026-08-06 00:33:39 -04:00
Huỳnh Thiện Lộc 82b1b36b1a fix(settings): hide removed widget tooltip (#3003) 2026-08-06 00:33:17 -04:00
FormalSnake e203e76ccc powermenu: support user-defined custom power buttons (#2916) 2026-08-05 20:46:42 -04:00
FormalSnake adcafbcc8e location: add missing subscribe fan-out for location events (#2914)
handleSubscribe forwards every service except location, so neither
subscribe-all connections nor explicit location subscriptions ever
receive location events, and the shell's only source of coordinates
is a single getState pull that races the geoclue TimeThreshold and
loses. Wire location into the fan-out like loginctl/sysupdate:
initial state on subscribe, then manager pushes.
2026-08-05 19:28:33 -04:00
Huỳnh Thiện Lộc 9981fcf529 fix(slideout): yield keyboard focus when modal is open on same screen (#2902)
DankSlideout always held WlrKeyboardFocus.OnDemand regardless of modals,
causing Notepad to steal focus back when Launcher was opened.

ModalManager: clone currentModalsByScreen on mutation so QML bindings
re-evaluate when modals open/close.
DankSlideout: set keyboardFocus to None while a modal is active on the
same screen, letting the modal (launcher) claim focus exclusively.
2026-08-05 19:19:47 -04:00
lankong0522 2baf048293 feat(tray): add IPC action to open tray menus (#2701)
* feat(tray): add IPC action to open tray menus

* tray: open IPC-requested menus on the focused screen only

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-08-05 17:31:25 -04:00
bbedward cffc33e14a display config/niri: fix nested kdl blocks as being attributed to output
state

fixes #2966
port 1.5
2026-08-05 17:02:38 -04:00
bbedward 0c811e3417 gamma: validate config before write and publish state every scheduler
wakeup

fixes #2967
port 1.5
2026-08-05 16:55:34 -04:00
bbedward 6ad46cf2c2 bar: fix shadow from tinting goth corners
fixes #2975
port 1.5
2026-08-04 22:15:34 -04:00
Thomas Kroll 34626070af fix(tray): don't dismiss click-opened tray menus from hover controller (#2979)
closeHoverSurfaces() called TrayMenuManager.closeAllMenus(), tearing down
every registered tray menu including ones opened by right-click. With
hoverPopouts enabled, moving the pointer after a right-click re-ran the
hit test and closed the menu before it could be reached.

Tag each menu with how it was opened and add closeHoverMenus(), which
only closes hover-opened menus. The hover controller now uses that.

Click-driven callers pass no argument, so byHover stays undefined and
`byHover === true` evaluates to false.

Fixes #2978


Claude-Session: https://claude.ai/code/session_01DYF9vdsaToU4Usu7kxPCNB

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:25:22 -04:00
bbedward 80b27b9a6a settings: remove dead keys
fixes #2977
2026-08-04 21:25:06 -04:00
jack-mil 49f968d26b fix(mpris): remove required non-null artist/title from idle condition (#2993)
The current isIdle detection only works if the player has a title _and_ artist metadata,
which is not appropriate for all cases, e.g. MPV playing a video does not set artist.

In the current implementation, after a video stops playing in MPV, and the window closes
(but MPV and the MPRIS plugin server is still running as a background process), the playback controls remain
shown on the bar, and have non-functional controls.

It is sufficient to only check for the "Stopped" playing state. The MPRIS specification
states that Playback_Status==Stopped should be used to indicate that *NO* track is currently
playing. A compliant player should set that, and should also set the CanPlay/CanPause boolean
to false is not track is being played.

See: https://specifications.freedesktop.org/mpris/latest/Player_Interface.html#Enum:Playback_Status
2026-08-04 21:22:22 -04:00
Huỳnh Thiện Lộc 99b0dc596d feat(wallpaper): support random cycling order (#2936) 2026-08-04 21:21:52 -04:00
rredlip 158c0c12d8 Changed margin of the rectangle so the pixel of the half circle are completely covered. (#2989) 2026-08-04 21:20:53 -04:00
bbedward e089948225 powermenu: add soft reboot option for systemd systems
fixes #2986
port 1.5
2026-08-04 18:04:19 -04:00
bbedward 7f2ba56e06 dank16: correct DPS lab lightness scale
fixes #2987
port 1.5
2026-08-04 17:55:53 -04:00
nozsh 6de5593216 feat(keybinds): add keybind for PAM U2F on lock screen (#2930)
* feat(keybinds): add keybind for PAM U2F on lock screen

* add: tooltip, settings json & error handling

---------
2026-08-03 22:09:53 -04:00
bbedward 365474b0d9 launcher: dispatch plugin-category items as plugin, not app
fixes #2988
port 1.5
2026-08-03 20:05:23 -04:00
bbedward dc8a47644a evdev: resolve capslock state from any available keyboard
fixes #2991
port 1.5
2026-08-03 19:59:12 -04:00
bbedward 27483e68dc dankinstall/gentoo: perform autounmask on install
fixes #2992
port 1.5
2026-08-03 19:40:00 -04:00
purian23 400a18a8ed fix(battery): reliably apply configured power profiles at shell startup
Port 1.5
2026-08-02 19:01:39 -04:00
purian23 32ddf614c3 refactor(media OSD): add previous/next UI controls
Port 1.5
2026-08-02 18:10:12 -04:00
purian23 19d919ed5c refactor(mpris): consolidate equivalent-player resolution & update track matching
Port 1.5
2026-08-02 18:09:18 -04:00
Rubén García 594a2cde19 refactor(mpris): enrich metadata across equivalent players (#2928)
* fix(mpris): enrich metadata across equivalent players

* fix(mpris): address review feedback on source stealing and title stripping

- _bestPlayingPlayer: when the active player is playing but not
  controllable, only hand ownership to a controllable peer that passes
  isSameTrack; unrelated players can no longer steal the active source.
- displayTrackTitle: keep the full trackTitle for display. Suffix
  stripping now only applies to the isSameTrack matching key, and only
  for a known app-name allowlist (YouTube, SoundCloud, browsers, etc.)
  so legitimate titles containing ' | ' are never mangled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Port 1.5
2026-08-02 17:34:19 -04:00
Connor Welsh 11287459c3 popout: re-apply surface geometry after shouldBeVisible (#2983)
Popouts sized from their content sometimes open too short and clip what
they render, then come back correct on the next open.

open() snapshots the surface geometry before setting shouldBeVisible,
and the onAligned{X,Y,Width,Height}Changed handlers are all gated on
that flag. Mapping contentWindow happens in between, and that's what
finally runs the layout Qt deferred while the window was hidden.
Content-sized popouts pick up their real height in that gap, so the
correction is dropped and the surface stays short. Re-snapshot once the
flag is set.
2026-08-02 01:30:21 -04:00
bbedward ef191babb7 tailscale: add missing ID
related #2969
port 1.5
2026-07-31 09:37:01 -04:00
bbedward fe64a342f9 network: rescan when network panel is open 2026-07-31 09:33:17 -04:00
bbedward e54be7d12d i18n: sync 2026-07-30 17:11:29 -04:00
bbedward 81c886784b lock: add separate text input for IMEs
related #2950
2026-07-30 17:08:59 -04:00
bbedward 6682bb120c matugen: tweak qt6ct colors
related #2951
2026-07-30 17:08:59 -04:00
bbedward 5c02ec4789 lock: avoid lock at startup firing when using dms-greeter
fixes #2952
2026-07-30 17:08:59 -04:00
bbedward de1e1757c3 keybinds: preserve raw lua actions in parser
fixes #2953
2026-07-30 17:08:59 -04:00
bbedward 43d331d6cf desktop plugins: ensure transparent frame is shown when desktop widgets
disabled

related #2955
2026-07-30 17:08:59 -04:00
Grant Mosha 01832856d4 fix(network): authenticate Fortinet VPNs natively (#2959)
* fix(network): authenticate Fortinet VPNs natively

* cleanup and add test

---------

Co-authored-by: Grant Mosha <grant.mosha@nida.go.tz>
Co-authored-by: bbedward <bbedward@gmail.com>
2026-07-30 16:45:53 -04:00
Huỳnh Thiện Lộc 0033e3f0e0 feat: add QR Generator launcher (#2941)
* feat: add QR Generator modal with auto-generate on input debounce

- QRGeneratorModal with dual-buffer sequential fade animation
- Auto-generate QR code on typing stop (200ms debounce)
- Clear text button on input field
- Escape key closes modal
- Register modal in PopoutService and AppSearchService
- Go backend for text QR code generation

* fix: add QR Generator toggle to Launcher DMS settings

* qr: also add qrg built in launcher plugin

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-07-30 16:33:36 -04:00
bbedward 2d3706321a frame: fix popout IPCs in some situations
related #2956
2026-07-30 15:30:44 -04:00
bbedward 5bb884db57 keybinds: fix labeling of spawn keybinds
fixes #2957
2026-07-30 15:30:44 -04:00
dms-ci[bot] b42763ccbf nix: update vendorHash for go.mod changes 2026-07-30 19:13:28 +00:00
bbedward f2a6d62d65 matugen: add description for vscode template
fixes #2958
2026-07-30 15:10:54 -04:00
bbedward f66693df6b core: bump dankgo 2026-07-30 14:52:14 -04:00
bbedward a710d6d7cc launcher: fix regex escaping launch args
fixes #2961
port 1.5
2026-07-30 14:33:58 -04:00
166 changed files with 14787 additions and 6433 deletions
+1
View File
@@ -704,6 +704,7 @@ func getCommonCommands() []*cobra.Command {
ipcCmd,
debugSrvCmd,
pluginsCmd,
registryCmd,
dank16Cmd,
brightnessCmd,
dpmsCmd,
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
var registryCmd = &cobra.Command{
Use: "registry",
Short: "Manage plugin and theme registries",
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active; additional registries can be added by name and git URL.",
}
var registryListCmd = &cobra.Command{
Use: "list",
Short: "List configured registries",
Run: func(cmd *cobra.Command, args []string) {
for _, s := range registries.Load(afero.NewOsFs()) {
suffix := ""
if s.Official() {
suffix = " (official)"
}
fmt.Printf("%s%s\n %s\n", s.Name, suffix, s.URL)
}
},
}
var registryAddCmd = &cobra.Command{
Use: "add <name> <url>",
Short: "Add a registry",
Long: "Add a registry by name and git URL. The repository must contain a plugins/ or themes/ directory in the registry format.",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Add(afero.NewOsFs(), args[0], args[1]); err != nil {
log.Fatalf("Error adding registry: %v", err)
}
fmt.Printf("Registry added: %s\n", args[0])
},
}
var registryRemoveCmd = &cobra.Command{
Use: "remove <name>",
Short: "Remove a registry",
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var names []string
for _, s := range registries.Load(afero.NewOsFs()) {
if !s.Official() {
names = append(names, s.Name)
}
}
return names, cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Remove(afero.NewOsFs(), args[0]); err != nil {
log.Fatalf("Error removing registry: %v", err)
}
fmt.Printf("Registry removed: %s\n", args[0])
},
}
+1
View File
@@ -16,6 +16,7 @@ func init() {
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd)
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd)
+1 -1
View File
@@ -69,7 +69,7 @@ require (
)
require (
github.com/AvengeMedia/dankgo v0.0.0-20260724133713-a4ef23371e05
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b
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
+2 -2
View File
@@ -1,7 +1,7 @@
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-20260724133713-a4ef23371e05 h1:Ij/yzOT8y2HL7V5Rkec1GxJV0rUvhKqfAzyGxVRTk1o=
github.com/AvengeMedia/dankgo v0.0.0-20260724133713-a4ef23371e05/go.mod h1:xt8RldAfti0QCWidwYIzsSSoJWsE61WgEhTu2H9UpD4=
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b h1:UwX1H4BkzazL7ips9ljnHzBXGttYARcIi5Njj9aqIt4=
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b/go.mod h1:xt8RldAfti0QCWidwYIzsSSoJWsE61WgEhTu2H9UpD4=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
+22 -7
View File
@@ -313,6 +313,7 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
Lf, af, bf := cf.Lab()
Lf *= 100.0
dir := 1.0
if isLightMode {
@@ -341,6 +342,7 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
origL, af, bf := cf.Lab()
origL *= 100.0
var darkerResult, lighterResult string
darkerL, lighterL := origL, origL
@@ -420,6 +422,24 @@ func blendHue(base, target, factor float64) float64 {
return result
}
// color8 sits a fixed L* offset from the background so it keeps its dim role
// regardless of primary brightness (conventional palettes put ANSI bright
// black ~2-2.5:1 from the background, e.g. catppuccin-mocha #585b70 on #1e1e2e)
func DeriveDim(bgHex string, hue, sat float64, isLight bool) string {
offset := 22.0
if isLight {
offset = -offset
}
bgL := getLstar(bgHex)
targetL := math.Max(0, math.Min(100, bgL+offset))
tint := HSVToRGB(HSV{H: hue, S: sat, V: 0.5})
c := colorful.Color{R: tint.R, G: tint.G, B: tint.B}
_, af, bf := c.Lab()
return labToHex(targetL, af, bf)
}
func DeriveContainer(primary string, isLight bool) string {
rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb)
@@ -500,10 +520,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
gray7V := baseVal * 0.28
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
gray8S := baseSat * 0.05
gray8V := baseVal * 0.85
dimTarget := secondaryTarget * 0.5
palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts))
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.05, true))
brightRedS := math.Min(baseSat*1.0, 1.0)
brightRedV := math.Min(baseVal*1.2, 1.0)
@@ -559,9 +576,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
gray7V := math.Min(baseVal*1.05, 1.0)
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
gray8S := baseSat * 0.15
gray8V := baseVal * 0.65
palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts))
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.15, false))
brightRedS := math.Min(baseSat*0.75, 1.0)
brightRedV := math.Min(baseVal*1.35, 1.0)
+70
View File
@@ -679,3 +679,73 @@ func TestContrastAlgorithmComparison(t *testing.T) {
t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount)
}
func TestEnsureContrastDPSLightModeStaysLight(t *testing.T) {
tests := []struct {
name string
result string
bg string
minLstar float64
}{
{
name: "bidirectional adjustment",
result: EnsureContrastDPSBidirectional("#d0ccc6", "#f8f8f8", 17.5, true),
bg: "#f8f8f8",
minLstar: 20.0,
},
{
name: "lstar adjustment",
result: EnsureContrastDPSLstar("#c0c0c0", "#f8f8f8", 30.0, true),
bg: "#f8f8f8",
minLstar: 20.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lstar := getLstar(tt.result)
if lstar < tt.minLstar {
t.Errorf("result %s has L* %.2f on light bg %s, expected >= %.2f (collapsed to near-black)",
tt.result, lstar, tt.bg, tt.minLstar)
}
})
}
}
func TestGeneratePaletteColor8Dim(t *testing.T) {
hues := []string{"#e91e63", "#f59e0b", "#22c55e", "#06b6d4", "#8b5cf6", "#ef4444"}
for _, base := range hues {
t.Run(base, func(t *testing.T) {
palette := GeneratePalette(base, PaletteOptions{IsLight: false, UseDPS: true})
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
if bgRatio < 1.5 || bgRatio > 3.0 {
t.Errorf("Color8 %s vs bg %s ratio %.2f, expected 1.5-3.0 (bright black stays near bg)",
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
sepRatio := ContrastRatio(palette.Color4.Hex, palette.Color8.Hex)
if sepRatio < 2.0 {
t.Errorf("Color4 %s vs Color8 %s ratio %.2f, expected >= 2.0 (blue must not collide with bright black)",
palette.Color4.Hex, palette.Color8.Hex, sepRatio)
}
})
}
}
func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
palette := GeneratePalette("#f59e0b", PaletteOptions{IsLight: true, UseDPS: true})
lstar := getLstar(palette.Color8.Hex)
if lstar < 60.0 {
t.Errorf("light mode Color8 %s has L* %.2f, expected >= 60 (dim grey, not near-black)",
palette.Color8.Hex, lstar)
}
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
if bgRatio > 3.0 {
t.Errorf("light mode Color8 %s vs bg %s ratio %.2f, expected <= 3.0",
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
}
+9 -7
View File
@@ -53,6 +53,11 @@ func NewGentooDistribution(config DistroConfig, logChan chan<- string) *GentooDi
}
}
func emergeInstallArgs(packages []string) []string {
args := []string{"emerge", "--ask=n", "--quiet", "--autounmask-continue=y", "--autounmask-keep-keywords=y", "--autounmask-keep-masks=y"}
return append(args, packages...)
}
func (g *GentooDistribution) getArchKeyword() string {
arch := runtime.GOARCH
switch arch {
@@ -319,6 +324,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
}
g.log("Portage tree synced successfully")
args := emergeInstallArgs(missingPkgs)
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
@@ -326,12 +332,10 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")),
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...)
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
@@ -521,8 +525,7 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
}
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, packageNames...)
args := emergeInstallArgs(packageNames)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
@@ -713,8 +716,7 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
guruPackages[i] = pkg + "::guru"
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, guruPackages...)
args := emergeInstallArgs(guruPackages)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
+45 -5
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@@ -224,6 +225,44 @@ func (h *HyprlandProvider) validateAction(action string) error {
return nil
}
var luaExprActionPattern = regexp.MustCompile(`^(function\s*\(|hl\.)`)
// isRawLuaActionText reports that action is a Lua expression to re-emit
// verbatim rather than freeform dispatcher text to wrap for hyprctl. The
// balance check keeps malformed input from corrupting the generated file.
func isRawLuaActionText(action string) bool {
if !luaExprActionPattern.MatchString(action) {
return false
}
depth := 0
var quote byte
escaped := false
for i := 0; i < len(action); i++ {
c := action[i]
switch {
case escaped:
escaped = false
case quote != 0:
switch c {
case '\\':
escaped = true
case quote:
quote = 0
}
case c == '"' || c == '\'':
quote = c
case c == '(':
depth++
case c == ')':
depth--
if depth < 0 {
return false
}
}
}
return depth == 0 && quote == 0 && !escaped
}
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
if err := h.ensureWritableConfig(); err != nil {
return err
@@ -254,11 +293,12 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
canonicalKey := canonicalHyprlandOverrideKey(key)
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
existingBinds[normalizedKey] = &hyprlandOverrideBind{
Key: canonicalKey,
Action: action,
Description: description,
Flags: flags,
Options: options,
Key: canonicalKey,
Action: action,
Description: description,
Flags: flags,
Options: options,
RawLuaAction: isRawLuaActionText(action),
}
return h.writeOverrideBinds(existingBinds)
@@ -463,6 +463,56 @@ func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) {
}
}
func TestIsRawLuaActionText(t *testing.T) {
cases := []struct {
action string
want bool
}{
{`function() hl.plugin.scrolloverview.overview("toggle") end`, true},
{`hl.dsp.exec_cmd("foo")`, true},
{`hl.dsp.no_op()`, true},
{"workspace 3", false},
{"exec zeditor", false},
{`function() hl.foo( end`, false},
{`function() hl.foo("a) end`, false},
{`hl.foo()) hl.bar((`, false},
}
for _, tc := range cases {
if got := isRawLuaActionText(tc.action); got != tc.want {
t.Errorf("isRawLuaActionText(%q) = %v, want %v", tc.action, got, tc.want)
}
}
}
func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte("-- DMS user keybind overrides\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
rawAction := `function() hl.plugin.scrolloverview.overview("toggle") end`
if err := provider.SetBind("SUPER + G", rawAction, "Toggle overview", nil); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
got := string(data)
if !strings.Contains(got, `hl.bind("SUPER + G", `+rawAction) {
t.Fatalf("expected raw Lua action to be written verbatim, got:\n%s", got)
}
if strings.Contains(got, "hyprctl dispatch function") {
t.Fatalf("expected raw Lua action to not be wrapped in hyprctl dispatch, got:\n%s", got)
}
}
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
+22 -16
View File
@@ -471,12 +471,9 @@ output_path = '%s'
case TemplateKindTerminal:
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
case TemplateKindVSCode:
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
for _, editor := range vscodeEditors {
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
}
case TemplateKindEmacs:
if utils.EmacsConfigDir() != "" {
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
@@ -633,6 +630,23 @@ func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []stri
return false
}
type vscodeEditor struct {
name string
dataDir string
}
var vscodeEditors = []vscodeEditor{
{"vscode", ".vscode"},
{"codium", ".vscode-oss"},
{"cursor", ".cursor"},
{"windsurf", ".windsurf"},
{"vscode-insiders", ".vscode-insiders"},
}
func (e vscodeEditor) extensionsDir(homeDir string) string {
return filepath.Join(homeDir, e.dataDir, "extensions")
}
func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) {
pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*")
matches, err := filepath.Glob(pattern)
@@ -1168,16 +1182,8 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
}
func checkVSCodeExtension(homeDir string) bool {
extDirs := []string{
filepath.Join(homeDir, ".vscode/extensions"),
filepath.Join(homeDir, ".vscode-oss/extensions"),
filepath.Join(homeDir, ".config/Code - OSS/extensions"),
filepath.Join(homeDir, ".cursor/extensions"),
filepath.Join(homeDir, ".windsurf/extensions"),
}
for _, extDir := range extDirs {
pattern := filepath.Join(extDir, "danklinux.dms-theme-*")
for _, editor := range vscodeEditors {
pattern := filepath.Join(editor.extensionsDir(homeDir), "danklinux.dms-theme-*")
if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
return true
}
+98 -44
View File
@@ -2,17 +2,17 @@ package plugins
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/go-git/go-git/v6"
"github.com/spf13/afero"
)
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
type Plugin struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -33,6 +33,7 @@ type Plugin struct {
type GitClient interface {
PlainClone(path string, url string) error
Pull(path string) error
OriginURL(path string) (string, error)
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
}
@@ -65,6 +66,22 @@ func (g *realGitClient) Pull(path string) error {
return nil
}
func (g *realGitClient) OriginURL(path string) (string, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return "", err
}
remote, err := repo.Remote("origin")
if err != nil {
return "", err
}
urls := remote.Config().URLs
if len(urls) == 0 {
return "", errors.New("origin remote has no URL")
}
return urls[0], nil
}
func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
repo, err := git.PlainOpen(path)
if err != nil {
@@ -119,10 +136,11 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
}
type Registry struct {
fs afero.Fs
cacheDir string
plugins []Plugin
git GitClient
fs afero.Fs
cacheDir string
registries []registries.Source
plugins []Plugin
git GitClient
}
func NewRegistry() (*Registry, error) {
@@ -130,63 +148,63 @@ func NewRegistry() (*Registry, error) {
}
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
cacheDir := getCacheDir()
return &Registry{
fs: fs,
cacheDir: cacheDir,
git: &realGitClient{},
fs: fs,
cacheDir: getCacheDir(),
registries: registries.Load(fs),
git: &realGitClient{},
}, nil
}
func (r *Registry) cacheDirFor(src registries.Source) string {
return filepath.Join(r.cacheDir, src.Name)
}
func getCacheDir() string {
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
}
func (r *Registry) Update() error {
exists, err := afero.DirExists(r.fs, r.cacheDir)
// A cached clone is reused only when its origin still matches the configured
// URL; renamed or re-pointed registries re-clone instead of pulling from the
// stale remote.
func (r *Registry) updateOne(src registries.Source) error {
dir := r.cacheDirFor(src)
exists, err := afero.DirExists(r.fs, dir)
if err != nil {
return fmt.Errorf("failed to check cache directory: %w", err)
}
if !exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
if exists {
origin, originErr := r.git.OriginURL(dir)
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
return nil
}
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to clone registry: %w", err)
}
} else {
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
if err := r.git.Pull(r.cacheDir); err != nil {
// Repository is likely corrupted or has issues, delete and re-clone
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
return fmt.Errorf("failed to remove corrupted registry: %w", err)
}
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to re-clone registry: %w", err)
}
if err := r.fs.RemoveAll(dir); err != nil {
return fmt.Errorf("failed to remove stale registry cache: %w", err)
}
}
return r.loadPlugins()
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(dir, src.URL); err != nil {
return fmt.Errorf("failed to clone: %w", err)
}
return nil
}
func (r *Registry) loadPlugins() error {
pluginsDir := filepath.Join(r.cacheDir, "plugins")
// A registry without a plugins/ directory is a valid themes-only registry.
func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
pluginsDir := filepath.Join(dir, "plugins")
entries, err := afero.ReadDir(r.fs, pluginsDir)
if err != nil {
return fmt.Errorf("failed to read plugins directory: %w", err)
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read plugins directory: %w", err)
}
r.plugins = []Plugin{}
var plugins []Plugin
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
@@ -206,15 +224,51 @@ func (r *Registry) loadPlugins() error {
plugin.ID = strings.TrimSuffix(entry.Name(), ".json")
}
r.plugins = append(r.plugins, plugin)
plugins = append(plugins, plugin)
}
return plugins, nil
}
return nil
// Pre-multi-registry caches were a single clone at the base dir; the per-name
// layout nests under it, so a leftover clone is deleted wholesale first.
func (r *Registry) resetLegacyCache() {
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
_ = r.fs.RemoveAll(r.cacheDir)
}
}
// Update refreshes every configured registry, aggregating plugins in
// declaration order (first occurrence of an ID wins). A failing registry is
// reported in the joined error but does not block the others.
func (r *Registry) Update() error {
r.resetLegacyCache()
r.plugins = []Plugin{}
seen := make(map[string]struct{})
var errs []error
for _, src := range r.registries {
if err := r.updateOne(src); err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
plugins, err := r.loadPluginsFrom(r.cacheDirFor(src))
if err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
for _, p := range plugins {
if _, dup := seen[p.ID]; dup {
continue
}
seen[p.ID] = struct{}{}
r.plugins = append(r.plugins, p)
}
}
return errors.Join(errs...)
}
func (r *Registry) List() ([]Plugin, error) {
if len(r.plugins) == 0 {
if err := r.Update(); err != nil {
if err := r.Update(); err != nil && len(r.plugins) == 0 {
return nil, err
}
}
+232 -57
View File
@@ -2,17 +2,22 @@ package plugins
import (
"encoding/json"
"errors"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testRegistryURL = "https://example.com/test-registry.git"
type mockGitClient struct {
cloneFunc func(path string, url string) error
pullFunc func(path string) error
originFunc func(path string) (string, error)
hasUpdatesFunc func(path string) (bool, string, string, error)
}
@@ -30,6 +35,13 @@ func (m *mockGitClient) Pull(path string) error {
return nil
}
func (m *mockGitClient) OriginURL(path string) (string, error) {
if m.originFunc != nil {
return m.originFunc(path)
}
return "", errors.New("not a repository")
}
func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
if m.hasUpdatesFunc != nil {
return m.hasUpdatesFunc(path)
@@ -42,6 +54,8 @@ func TestNewRegistry(t *testing.T) {
assert.NoError(t, err)
assert.NotNil(t, registry)
assert.NotEmpty(t, registry.cacheDir)
require.NotEmpty(t, registry.registries)
assert.Equal(t, registries.OfficialName, registry.registries[0].Name)
}
func TestGetCacheDir(t *testing.T) {
@@ -53,10 +67,11 @@ func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
fs := afero.NewMemMapFs()
tmpDir := "/test-cache"
registry := &Registry{
fs: fs,
cacheDir: tmpDir,
plugins: []Plugin{},
git: &mockGitClient{},
fs: fs,
cacheDir: tmpDir,
registries: []registries.Source{{Name: "test", URL: testRegistryURL}},
plugins: []Plugin{},
git: &mockGitClient{},
}
return registry, fs, tmpDir
}
@@ -104,14 +119,14 @@ func TestLoadPlugins(t *testing.T) {
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
err := registry.loadPlugins()
plugins, err := registry.loadPluginsFrom(tmpDir)
assert.NoError(t, err)
assert.Len(t, registry.plugins, 2)
assert.Len(t, plugins, 2)
assert.Equal(t, "TestPlugin1", registry.plugins[0].Name)
assert.Equal(t, "TestPlugin2", registry.plugins[1].Name)
assert.Equal(t, []string{"dankbar-widget"}, registry.plugins[0].Capabilities)
assert.Equal(t, []string{"dep1", "dep2"}, registry.plugins[1].Dependencies)
assert.Equal(t, "TestPlugin1", plugins[0].Name)
assert.Equal(t, "TestPlugin2", plugins[1].Name)
assert.Equal(t, []string{"dankbar-widget"}, plugins[0].Capabilities)
assert.Equal(t, []string{"dep1", "dep2"}, plugins[1].Dependencies)
})
t.Run("skips non-json files", func(t *testing.T) {
@@ -136,34 +151,10 @@ func TestLoadPlugins(t *testing.T) {
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
plugins, err := registry.loadPluginsFrom(tmpDir)
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
})
t.Run("skips directories", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0o755)
require.NoError(t, err)
plugin := Plugin{
Name: "ValidPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
assert.Len(t, plugins, 1)
assert.Equal(t, "ValidPlugin", plugins[0].Name)
})
t.Run("skips invalid json files", func(t *testing.T) {
@@ -188,18 +179,18 @@ func TestLoadPlugins(t *testing.T) {
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
plugins, err := registry.loadPluginsFrom(tmpDir)
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
assert.Len(t, plugins, 1)
assert.Equal(t, "ValidPlugin", plugins[0].Name)
})
t.Run("returns error when plugins directory missing", func(t *testing.T) {
t.Run("missing plugins directory is a themes-only registry", func(t *testing.T) {
registry, _, _ := setupTestRegistry(t)
err := registry.loadPlugins()
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read plugins directory")
plugins, err := registry.loadPluginsFrom(registry.cacheDir)
assert.NoError(t, err)
assert.Empty(t, plugins)
})
}
@@ -240,19 +231,40 @@ func TestList(t *testing.T) {
Distro: []string{"any"},
}
mockGit := &mockGitClient{
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
plugins, err := registry.List()
assert.NoError(t, err)
assert.Len(t, plugins, 1)
assert.Equal(t, "NewPlugin", plugins[0].Name)
})
t.Run("partial registry failure still returns loaded plugins", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
registry.registries = []registries.Source{
{Name: "test", URL: testRegistryURL},
{Name: "broken", URL: "https://example.com/broken.git"},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
if url != testRegistryURL {
return errors.New("clone failed")
}
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
plugins, err := registry.List()
assert.NoError(t, err)
assert.Len(t, plugins, 1)
})
}
func TestUpdate(t *testing.T) {
@@ -271,16 +283,15 @@ func TestUpdate(t *testing.T) {
}
cloneCalled := false
mockGit := &mockGitClient{
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
cloneCalled = true
assert.Equal(t, registryRepo, url)
assert.Equal(t, tmpDir, path)
assert.Equal(t, testRegistryURL, url)
assert.Equal(t, filepath.Join(tmpDir, "test"), path)
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
err := registry.Update()
assert.NoError(t, err)
@@ -289,7 +300,7 @@ func TestUpdate(t *testing.T) {
assert.Equal(t, "RepoPlugin", registry.plugins[0].Name)
})
t.Run("pulls updates when cache exists", func(t *testing.T) {
t.Run("pulls when cache exists with matching origin", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
plugin := Plugin{
@@ -303,24 +314,188 @@ func TestUpdate(t *testing.T) {
Distro: []string{"any"},
}
err := fs.MkdirAll(tmpDir, 0o755)
require.NoError(t, err)
subdir := filepath.Join(tmpDir, "test")
require.NoError(t, fs.MkdirAll(subdir, 0o755))
pullCalled := false
mockGit := &mockGitClient{
registry.git = &mockGitClient{
originFunc: func(path string) (string, error) {
return testRegistryURL, nil
},
pullFunc: func(path string) error {
pullCalled = true
assert.Equal(t, tmpDir, path)
assert.Equal(t, subdir, path)
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
err = registry.Update()
err := registry.Update()
assert.NoError(t, err)
assert.True(t, pullCalled)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name)
})
t.Run("re-clones when cached origin does not match configured URL", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
subdir := filepath.Join(tmpDir, "test")
require.NoError(t, fs.MkdirAll(subdir, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(subdir, "stale"), []byte("x"), 0o644))
pullCalled := false
cloneCalled := false
registry.git = &mockGitClient{
originFunc: func(path string) (string, error) {
return "https://example.com/old-origin.git", nil
},
pullFunc: func(path string) error {
pullCalled = true
return nil
},
cloneFunc: func(path string, url string) error {
cloneCalled = true
assert.Equal(t, testRegistryURL, url)
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.False(t, pullCalled, "stale origin must not be pulled")
assert.True(t, cloneCalled)
exists, _ := afero.Exists(fs, filepath.Join(subdir, "stale"))
assert.False(t, exists, "stale cache contents removed before re-clone")
})
t.Run("re-clones when pull fails", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
subdir := filepath.Join(tmpDir, "test")
require.NoError(t, fs.MkdirAll(subdir, 0o755))
cloneCalled := false
registry.git = &mockGitClient{
originFunc: func(path string) (string, error) {
return testRegistryURL, nil
},
pullFunc: func(path string) error {
return errors.New("shallow clone corruption")
},
cloneFunc: func(path string, url string) error {
cloneCalled = true
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.True(t, cloneCalled)
})
t.Run("aggregates from multiple registries", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
pluginA := Plugin{ID: "a", Name: "PluginA", Compositors: []string{"niri"}, Distro: []string{"any"}}
pluginB := Plugin{ID: "b", Name: "PluginB", Compositors: []string{"niri"}, Distro: []string{"any"}}
registry.registries = []registries.Source{
{Name: "official", URL: testRegistryURL},
{Name: "louzt", URL: "https://example.com/louzt.git"},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
switch filepath.Base(path) {
case "official":
createTestPlugin(t, fs, path, "x.json", pluginA)
case "louzt":
createTestPlugin(t, fs, path, "x.json", pluginB)
}
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 2)
assert.Equal(t, "a", registry.plugins[0].ID)
assert.Equal(t, "b", registry.plugins[1].ID)
})
t.Run("dedupes by ID with declaration order priority", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
pluginOfficial := Plugin{ID: "weather", Name: "OfficialWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
pluginLouzt := Plugin{ID: "weather", Name: "LouztWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
registry.registries = []registries.Source{
{Name: "official", URL: testRegistryURL},
{Name: "louzt", URL: "https://example.com/louzt.git"},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
switch filepath.Base(path) {
case "official":
createTestPlugin(t, fs, path, "x.json", pluginOfficial)
case "louzt":
createTestPlugin(t, fs, path, "x.json", pluginLouzt)
}
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "OfficialWeather", registry.plugins[0].Name)
})
t.Run("continues past failing registry and reports it", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
registry.registries = []registries.Source{
{Name: "broken", URL: "https://example.com/broken.git"},
{Name: "test", URL: testRegistryURL},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
if url != testRegistryURL {
return errors.New("network unreachable")
}
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.Error(t, err)
assert.Contains(t, err.Error(), "registry broken")
assert.Len(t, registry.plugins, 1, "healthy registry still loads")
})
t.Run("removes legacy single-clone cache at base", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
require.NoError(t, fs.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755))
createTestPlugin(t, fs, tmpDir, "legacy.json", Plugin{ID: "legacy", Name: "Legacy"})
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
exists, _ := afero.DirExists(fs, filepath.Join(tmpDir, ".git"))
assert.False(t, exists, "legacy clone removed")
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "x", registry.plugins[0].ID)
})
}
@@ -22,6 +22,16 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
if !strings.Contains(content, "Keys.onPressed") || !strings.Contains(content, "event.text") {
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
}
// Wayland IMEs commit unconsumed printable keys as text-input text rather
// than forwarding raw keys, so the lock screen needs an IME commit sink
// alongside raw key handling.
if !strings.Contains(content, "id: imeCommitSink") {
t.Fatalf("passwordField must keep the imeCommitSink TextInput so IME-routed keyboards can type (#2950)")
}
if !strings.Contains(content, "Qt.ImhSensitiveData") {
t.Fatalf("imeCommitSink must advertise hidden-text hints so IMEs treat it as a password field")
}
}
func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
+130
View File
@@ -0,0 +1,130 @@
package registries
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/spf13/afero"
)
const (
OfficialName = "official"
officialURL = "https://github.com/AvengeMedia/dms-plugin-registry.git"
)
// Source identifies a registry repository. Name doubles as the per-registry
// cache subdirectory, so it is restricted to a filesystem-safe slug.
type Source struct {
Name string `json:"name"`
URL string `json:"url"`
}
func (s Source) Official() bool {
return s.Name == OfficialName
}
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
func configPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get user config dir: %w", err)
}
return filepath.Join(configDir, "DankMaterialShell", "registries.json"), nil
}
// Load returns the official registry followed by any user-configured extras.
// A missing or unreadable config yields just the official registry.
func Load(fs afero.Fs) []Source {
sources := []Source{{Name: OfficialName, URL: officialURL}}
path, err := configPath()
if err != nil {
return sources
}
data, err := afero.ReadFile(fs, path)
if err != nil {
return sources
}
var extras []Source
if err := json.Unmarshal(data, &extras); err != nil {
return sources
}
for _, s := range extras {
if !nameRe.MatchString(s.Name) || s.Name == OfficialName || s.URL == "" {
continue
}
sources = append(sources, s)
}
return sources
}
func saveExtras(fs afero.Fs, extras []Source) error {
path, err := configPath()
if err != nil {
return err
}
if err := fs.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("failed to create config dir: %w", err)
}
data, err := json.MarshalIndent(extras, "", " ")
if err != nil {
return err
}
return afero.WriteFile(fs, path, append(data, '\n'), 0o644)
}
func loadExtras(fs afero.Fs) []Source {
sources := Load(fs)
return sources[1:]
}
func Add(fs afero.Fs, name, url string) error {
name = strings.TrimSpace(name)
url = strings.TrimSpace(url)
if !nameRe.MatchString(name) {
return fmt.Errorf("invalid registry name %q: use 1-32 lowercase letters, digits or hyphens", name)
}
if name == OfficialName {
return fmt.Errorf("registry name %q is reserved", OfficialName)
}
if url == "" {
return fmt.Errorf("registry URL is required")
}
extras := loadExtras(fs)
for _, s := range extras {
if s.Name == name {
return fmt.Errorf("registry %q already exists", name)
}
if s.URL == url {
return fmt.Errorf("registry %q already uses this URL", s.Name)
}
}
return saveExtras(fs, append(extras, Source{Name: name, URL: url}))
}
func Remove(fs afero.Fs, name string) error {
if name == OfficialName {
return fmt.Errorf("the official registry cannot be removed")
}
extras := loadExtras(fs)
kept := make([]Source, 0, len(extras))
for _, s := range extras {
if s.Name != name {
kept = append(kept, s)
}
}
if len(kept) == len(extras) {
return fmt.Errorf("registry %q not found", name)
}
return saveExtras(fs, kept)
}
@@ -0,0 +1,79 @@
package registries
import (
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupFs(t *testing.T) afero.Fs {
t.Setenv("XDG_CONFIG_HOME", "/xdg")
return afero.NewMemMapFs()
}
func TestLoadDefaults(t *testing.T) {
fs := setupFs(t)
sources := Load(fs)
require.Len(t, sources, 1)
assert.Equal(t, OfficialName, sources[0].Name)
assert.Equal(t, officialURL, sources[0].URL)
assert.True(t, sources[0].Official())
}
func TestAddAndLoad(t *testing.T) {
fs := setupFs(t)
require.NoError(t, Add(fs, "extra", "https://example.com/extra.git"))
require.NoError(t, Add(fs, "another", "https://example.com/another.git"))
sources := Load(fs)
require.Len(t, sources, 3)
assert.Equal(t, OfficialName, sources[0].Name)
assert.Equal(t, "extra", sources[1].Name)
assert.Equal(t, "another", sources[2].Name)
assert.False(t, sources[1].Official())
}
func TestAddValidation(t *testing.T) {
fs := setupFs(t)
assert.Error(t, Add(fs, "", "https://example.com/x.git"))
assert.Error(t, Add(fs, "Has Spaces", "https://example.com/x.git"))
assert.Error(t, Add(fs, "UPPER", "https://example.com/x.git"))
assert.Error(t, Add(fs, "../escape", "https://example.com/x.git"))
assert.Error(t, Add(fs, OfficialName, "https://example.com/x.git"))
assert.Error(t, Add(fs, "noname", ""))
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
assert.Error(t, Add(fs, "extra", "https://example.com/other.git"), "duplicate name rejected")
assert.Error(t, Add(fs, "extra2", "https://example.com/x.git"), "duplicate URL rejected")
}
func TestRemove(t *testing.T) {
fs := setupFs(t)
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
require.NoError(t, Remove(fs, "extra"))
assert.Len(t, Load(fs), 1)
assert.Error(t, Remove(fs, "extra"), "already removed")
assert.Error(t, Remove(fs, OfficialName), "official is not removable")
}
func TestLoadIgnoresInvalidConfig(t *testing.T) {
fs := setupFs(t)
require.NoError(t, fs.MkdirAll("/xdg/DankMaterialShell", 0o755))
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte("{not json"), 0o644))
assert.Len(t, Load(fs), 1)
entries := `[{"name":"ok","url":"https://example.com/ok.git"},{"name":"Bad Name","url":"https://example.com/bad.git"},{"name":"official","url":"https://example.com/spoof.git"},{"name":"nourl","url":""}]`
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte(entries), 0o644))
sources := Load(fs)
require.Len(t, sources, 2, "invalid entries dropped")
assert.Equal(t, "ok", sources[1].Name)
assert.Equal(t, officialURL, sources[0].URL, "official cannot be spoofed from config")
}
+25 -21
View File
@@ -48,7 +48,7 @@ func NewManager() (*Manager, error) {
return nil, fmt.Errorf("failed to find keyboards: %w", err)
}
initialCapsLock := readInitialCapsLockState(devices[0])
initialCapsLock, _ := capsLockFromDevices(devices)
watcher, err := fsnotify.NewWatcher()
if err != nil {
@@ -85,14 +85,21 @@ func NewManager() (*Manager, error) {
return m, nil
}
func readInitialCapsLockState(device EvdevDevice) bool {
ledStates, err := device.State(evLedType)
if err != nil {
log.Debugf("Could not read LED state: %v", err)
return false
func capsLockFromDevices(devices []EvdevDevice) (bool, bool) {
for _, device := range devices {
if device == nil {
continue
}
ledStates, err := device.State(evLedType)
if err != nil || len(ledStates) == 0 {
continue
}
return ledStates[ledCapslockKey], true
}
return ledStates[ledCapslockKey]
return false, false
}
func findKeyboards() ([]EvdevDevice, error) {
@@ -297,25 +304,22 @@ func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) {
m.devicesMutex.RUnlock()
return
}
device := m.devices[deviceIndex]
ordered := make([]EvdevDevice, 0, len(m.devices))
ordered = append(ordered, m.devices[deviceIndex])
for i, device := range m.devices {
if i == deviceIndex {
continue
}
ordered = append(ordered, device)
}
m.devicesMutex.RUnlock()
ledStates, err := device.State(evLedType)
if err != nil {
log.Warnf("Failed to read LED state: %v", err)
capsLockState, ok := capsLockFromDevices(ordered)
if !ok {
log.Debug("No LED-capable device available for caps lock state")
return
}
if len(ledStates) == 0 {
log.Debug("No LED state available (empty map)")
// This means the device either:
// - doesn't support LED reporting at all, or
// - the kernel returned an empty state
return
}
capsLockState := ledStates[ledCapslockKey]
m.updateCapsLockStateDirect(capsLockState)
}
+22 -4
View File
@@ -306,7 +306,7 @@ func TestNotifySubscribers(t *testing.T) {
m.Close()
}
func TestReadInitialCapsLockState(t *testing.T) {
func TestCapsLockFromDevices(t *testing.T) {
t.Run("caps lock is on", func(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t)
ledState := evdev.StateMap{
@@ -314,7 +314,8 @@ func TestReadInitialCapsLockState(t *testing.T) {
}
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result := readInitialCapsLockState(mockDevice)
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.True(t, ok)
assert.True(t, result)
})
@@ -325,7 +326,8 @@ func TestReadInitialCapsLockState(t *testing.T) {
}
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result := readInitialCapsLockState(mockDevice)
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.True(t, ok)
assert.False(t, result)
})
@@ -333,9 +335,25 @@ func TestReadInitialCapsLockState(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t)
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once()
result := readInitialCapsLockState(mockDevice)
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.False(t, ok)
assert.False(t, result)
})
t.Run("falls back past device without LED state", func(t *testing.T) {
noLedDevice := mocks.NewMockEvdevDevice(t)
noLedDevice.EXPECT().State(evdev.EvType(evLedType)).Return(evdev.StateMap{}, nil).Once()
ledDevice := mocks.NewMockEvdevDevice(t)
ledState := evdev.StateMap{
ledCapslockKey: true,
}
ledDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result, ok := capsLockFromDevices([]EvdevDevice{noLedDevice, nil, ledDevice})
assert.True(t, ok)
assert.True(t, result)
})
}
func TestHasInputGroupAccess(t *testing.T) {
@@ -302,17 +302,17 @@ func (a *SecretAgent) GetSecrets(
}
a.backend.cachedVPNCredsMu.Unlock()
a.backend.cachedGPSamlMu.Lock()
cachedGPSaml := a.backend.cachedGPSamlCookie
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
a.backend.cachedGPSamlCookie = nil
a.backend.cachedGPSamlMu.Unlock()
a.backend.cachedOpenConnectMu.Lock()
cachedOpenConnect := a.backend.cachedOpenConnectAuth
if cachedOpenConnect != nil && cachedOpenConnect.ConnectionUUID == connUuid {
a.backend.cachedOpenConnectAuth = nil
a.backend.cachedOpenConnectMu.Unlock()
log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
log.Infof("[SecretAgent] Using cached OpenConnect authentication for %s", connUuid)
return buildGPSamlSecretsResponse(settingName, cachedGPSaml.Cookie, cachedGPSaml.Host, cachedGPSaml.Fingerprint), nil
return buildOpenConnectSecretsResponse(settingName, cachedOpenConnect.Cookie, cachedOpenConnect.Host, cachedOpenConnect.Fingerprint), nil
}
a.backend.cachedGPSamlMu.Unlock()
a.backend.cachedOpenConnectMu.Unlock()
if len(fields) == 1 && fields[0] == "gp-saml" {
gateway := ""
@@ -347,17 +347,17 @@ func (a *SecretAgent) GetSecrets(
log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager")
a.backend.cachedGPSamlMu.Lock()
a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{
a.backend.cachedOpenConnectMu.Lock()
a.backend.cachedOpenConnectAuth = &cachedOpenConnectAuth{
ConnectionUUID: connUuid,
Cookie: authResult.Cookie,
Host: authResult.Host,
User: authResult.User,
Fingerprint: authResult.Fingerprint,
}
a.backend.cachedGPSamlMu.Unlock()
a.backend.cachedOpenConnectMu.Unlock()
return buildGPSamlSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
return buildOpenConnectSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
}
}
@@ -987,7 +987,7 @@ func buildWiFiSecretsResponse(settingName string, secrets map[string]string) nmS
return out
}
func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
func buildOpenConnectSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
out := nmSettingMap{}
vpnSec := nmVariantMap{}
@@ -122,7 +122,7 @@ func TestNeedsExternalBrowserAuth(t *testing.T) {
}
}
func TestBuildGPSamlSecretsResponse(t *testing.T) {
func TestBuildOpenConnectSecretsResponse(t *testing.T) {
tests := []struct {
name string
settingName string
@@ -155,7 +155,7 @@ func TestBuildGPSamlSecretsResponse(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildGPSamlSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
result := buildOpenConnectSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
assert.NotNil(t, result)
assert.Contains(t, result, tt.settingName)
@@ -80,16 +80,16 @@ type NetworkManagerBackend struct {
hotspotPendingDevice string
pendingVPNSave *pendingVPNCredentials
pendingVPNSaveMu sync.Mutex
cachedVPNCreds *cachedVPNCredentials
cachedVPNCredsMu sync.Mutex
cachedPKCS11PIN *cachedPKCS11PIN
cachedPKCS11Mu sync.Mutex
cachedGPSamlCookie *cachedGPSamlCookie
cachedGPSamlMu sync.Mutex
cachedWiFiSecret *cachedWiFiSecret
cachedWiFiSecretMu sync.Mutex
pendingVPNSave *pendingVPNCredentials
pendingVPNSaveMu sync.Mutex
cachedVPNCreds *cachedVPNCredentials
cachedVPNCredsMu sync.Mutex
cachedPKCS11PIN *cachedPKCS11PIN
cachedPKCS11Mu sync.Mutex
cachedOpenConnectAuth *cachedOpenConnectAuth
cachedOpenConnectMu sync.Mutex
cachedWiFiSecret *cachedWiFiSecret
cachedWiFiSecretMu sync.Mutex
onStateChange func()
}
@@ -100,8 +100,9 @@ type pendingVPNCredentials struct {
Password string
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
// falls back to Password under the "password" key when empty.
Secrets map[string]string
SavePassword bool
Secrets map[string]string
PersistentSecrets map[string]string
SavePassword bool
}
type cachedVPNCredentials struct {
@@ -124,7 +125,7 @@ type cachedWiFiSecret struct {
Secrets map[string]string
}
type cachedGPSamlCookie struct {
type cachedOpenConnectAuth struct {
ConnectionUUID string
Cookie string
Host string
@@ -3,6 +3,7 @@ package network
import (
"bufio"
"context"
"errors"
"fmt"
"os/exec"
"strings"
@@ -10,16 +11,29 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
type gpSamlAuthResult struct {
type openConnectAuthResult struct {
Cookie string
Host string
User string
Fingerprint string
}
type openConnectAuthError struct {
cause error
serverCert string
}
func (e *openConnectAuthError) Error() string {
return fmt.Sprintf("openconnect --authenticate failed: %v", e.cause)
}
func (e *openConnectAuthError) Unwrap() error {
return e.cause
}
// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui.
// Only supports protocol=gp. Other protocols need their own implementations.
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*gpSamlAuthResult, error) {
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*openConnectAuthResult, error) {
if gateway == "" {
return nil, fmt.Errorf("GP SAML auth: gateway is empty")
}
@@ -63,7 +77,7 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
}
}()
result := &gpSamlAuthResult{Host: gateway}
result := &openConnectAuthResult{Host: gateway}
var allOutput []string
scanner := bufio.NewScanner(stdout)
@@ -117,13 +131,8 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
return result, nil
}
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) {
ocPath, err := exec.LookPath("openconnect")
if err != nil {
return nil, fmt.Errorf("openconnect not found: %w", err)
}
args := []string{
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*openConnectAuthResult, error) {
return runOpenConnectAuthenticate(ctx, []string{
"--protocol=gp",
"--usergroup=gateway:prelogin-cookie",
"--user=" + user,
@@ -131,18 +140,83 @@ func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user
"--allow-insecure-crypto",
"--authenticate",
gateway,
}, preloginCookie)
}
func runOpenConnectPasswordAuth(
ctx context.Context,
data map[string]string,
username, password, serverCert string,
) (*openConnectAuthResult, error) {
if data["protocol"] != "fortinet" {
return nil, fmt.Errorf("only Fortinet password authentication is supported")
}
gateway := data["gateway"]
if gateway == "" {
return nil, fmt.Errorf("OpenConnect gateway is empty")
}
if username == "" || password == "" {
return nil, fmt.Errorf("OpenConnect username and password are required")
}
args := []string{
"--protocol=fortinet",
"--user=" + username,
"--passwd-on-stdin",
"--non-inter",
}
if usergroup := data["usergroup"]; usergroup != "" {
args = append(args, "--usergroup="+usergroup)
}
if serverCert != "" {
args = append(args, "--servercert="+serverCert)
}
args = append(args, "--authenticate", gateway)
result, err := runOpenConnectAuthenticate(ctx, args, password)
if err == nil {
result.Host = gateway
if result.Fingerprint == "" {
result.Fingerprint = serverCert
}
}
return result, err
}
func runOpenConnectAuthenticate(ctx context.Context, args []string, secret string) (*openConnectAuthResult, error) {
ocPath, err := exec.LookPath("openconnect")
if err != nil {
return nil, fmt.Errorf("openconnect not found: %w", err)
}
cmd := exec.CommandContext(ctx, ocPath, args...)
cmd.Stdin = strings.NewReader(preloginCookie)
cmd.Stdin = strings.NewReader(secret + "\n")
output, err := cmd.CombinedOutput()
result := parseOpenConnectAuthenticateOutput(string(output))
serverCert := suggestedOpenConnectServerCert(string(output))
if err != nil {
return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output))
if ctx.Err() != nil {
return nil, fmt.Errorf("openconnect authentication timed out or was cancelled: %w", ctx.Err())
}
return nil, &openConnectAuthError{cause: err, serverCert: serverCert}
}
if result.Cookie == "" {
return nil, &openConnectAuthError{
cause: errors.New("no COOKIE in command output"),
serverCert: serverCert,
}
}
result := &gpSamlAuthResult{}
for _, line := range strings.Split(string(output), "\n") {
log.Infof("[OpenConnect] Authentication successful: cookie_len=%d, host=%s, has_fingerprint=%v",
len(result.Cookie), result.Host, result.Fingerprint != "")
return result, nil
}
func parseOpenConnectAuthenticateOutput(output string) *openConnectAuthResult {
result := &openConnectAuthResult{}
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "COOKIE="):
@@ -158,15 +232,7 @@ func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user
}
}
}
if result.Cookie == "" {
return nil, fmt.Errorf("no COOKIE in openconnect --authenticate output: %s", string(output))
}
log.Infof("[GP-SAML] openconnect --authenticate: cookie_len=%d, host=%s, fingerprint=%s",
len(result.Cookie), result.Host, result.Fingerprint)
return result, nil
return result
}
func unshellQuote(s string) string {
@@ -179,7 +245,7 @@ func unshellQuote(s string) string {
return s
}
func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) {
func parseGPSamlFromCommandLine(line string, result *openConnectAuthResult) {
if !strings.Contains(line, "openconnect") {
return
}
@@ -1,6 +1,10 @@
package network
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@@ -71,7 +75,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
tests := []struct {
name string
line string
initialResult *gpSamlAuthResult
initialResult *openConnectAuthResult
expectedCookie string
expectedUser string
expectedFP string
@@ -79,7 +83,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "full openconnect command",
line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john",
initialResult: &gpSamlAuthResult{},
initialResult: &openConnectAuthResult{},
expectedCookie: "AUTH123",
expectedUser: "john",
expectedFP: "pin-sha256:ABC",
@@ -87,7 +91,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "with equals signs in cookie",
line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane",
initialResult: &gpSamlAuthResult{},
initialResult: &openConnectAuthResult{},
expectedCookie: "authcookie=xyz123&portal=GATE",
expectedUser: "jane",
expectedFP: "",
@@ -95,7 +99,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "non-openconnect line",
line: "some other output",
initialResult: &gpSamlAuthResult{},
initialResult: &openConnectAuthResult{},
expectedCookie: "",
expectedUser: "",
expectedFP: "",
@@ -103,7 +107,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "preserves existing values",
line: "openconnect --user=newuser",
initialResult: &gpSamlAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
initialResult: &openConnectAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
expectedCookie: "existing",
expectedUser: "newuser",
expectedFP: "existing-fp",
@@ -111,7 +115,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "only updates empty fields",
line: "openconnect --cookie=NEW --user=NEW",
initialResult: &gpSamlAuthResult{Cookie: "OLD"},
initialResult: &openConnectAuthResult{Cookie: "OLD"},
expectedCookie: "OLD",
expectedUser: "NEW",
expectedFP: "",
@@ -119,7 +123,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "real gp-saml-gui output",
line: "openconnect --protocol=gp --user=john.doe@example.com --os=linux-64 --usergroup=gateway:prelogin-cookie --passwd-on-stdin",
initialResult: &gpSamlAuthResult{},
initialResult: &openConnectAuthResult{},
expectedCookie: "",
expectedUser: "john.doe@example.com",
expectedFP: "",
@@ -127,7 +131,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "with server cert flag",
line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com",
initialResult: &gpSamlAuthResult{},
initialResult: &openConnectAuthResult{},
expectedCookie: "",
expectedUser: "",
expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
@@ -158,7 +162,7 @@ func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
"",
}
result := &gpSamlAuthResult{}
result := &openConnectAuthResult{}
for _, line := range lines {
parseGPSamlFromCommandLine(line, result)
}
@@ -167,3 +171,19 @@ func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
assert.Empty(t, result.Cookie, "cookie should not be parsed from command line")
assert.Empty(t, result.Fingerprint)
}
func TestRunOpenConnectAuthenticateSanitizesFailure(t *testing.T) {
binDir := t.TempDir()
openConnectPath := filepath.Join(binDir, "openconnect")
script := "#!/bin/sh\nprintf '%s\\n' 'Cookie: should-not-leak' 'Add --servercert pin-sha256:TEST-FINGERPRINT' >&2\nexit 1\n"
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
t.Setenv("PATH", binDir)
_, err := runOpenConnectAuthenticate(context.Background(), []string{"--authenticate", "vpn.example.test"}, "password")
assert.Error(t, err)
assert.NotContains(t, err.Error(), "should-not-leak")
var authErr *openConnectAuthError
assert.True(t, errors.As(err, &authErr))
assert.Equal(t, "pin-sha256:TEST-FINGERPRINT", authErr.serverCert)
}
@@ -326,6 +326,7 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
}
authAction := detectVPNAuthAction(vpnServiceType, vpnData)
var openConnectAuth *openConnectAuthResult
switch authAction {
case "openvpn_username":
@@ -335,6 +336,19 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
return err
}
case "openconnect_password":
if err := b.ensureOpenConnectAgentFlags(targetConn, vpnData); err != nil {
return fmt.Errorf("failed to prepare OpenConnect connection: %w", err)
}
authCtx, authCancel := context.WithTimeout(context.Background(), 5*time.Minute)
openConnectAuth, err = b.handleOpenConnectPasswordAuth(
authCtx, targetConn, connName, targetUUID, vpnServiceType, vpnData,
)
authCancel()
if err != nil {
return fmt.Errorf("OpenConnect authentication failed: %w", err)
}
case "gp_saml":
gateway := vpnData["gateway"]
protocol := vpnData["protocol"]
@@ -345,7 +359,7 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
log.Infof("[ConnectVPN] GlobalProtect SAML/SSO authentication required for %s (gateway=%s)", connName, gateway)
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
authResult, err := b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
openConnectAuth, err = b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
samlCancel()
if err != nil {
errMsg := err.Error()
@@ -363,16 +377,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
}
}
b.cachedGPSamlMu.Lock()
b.cachedGPSamlCookie = &cachedGPSamlCookie{
ConnectionUUID: targetUUID,
Cookie: authResult.Cookie,
Host: authResult.Host,
User: authResult.User,
Fingerprint: authResult.Fingerprint,
}
b.cachedGPSamlMu.Unlock()
if err := targetConn.ClearSecrets(); err != nil {
log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err)
} else {
@@ -382,6 +386,19 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
log.Infof("[ConnectVPN] GlobalProtect SAML cookie cached for %s, proceeding with activation", connName)
}
if openConnectAuth != nil {
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = &cachedOpenConnectAuth{
ConnectionUUID: targetUUID,
Cookie: openConnectAuth.Cookie,
Host: openConnectAuth.Host,
User: openConnectAuth.User,
Fingerprint: openConnectAuth.Fingerprint,
}
b.cachedOpenConnectMu.Unlock()
log.Infof("[ConnectVPN] OpenConnect authentication cached for %s, proceeding with activation", connName)
}
b.stateMutex.Lock()
b.state.IsConnectingVPN = true
b.state.ConnectingVPNUUID = targetUUID
@@ -394,6 +411,13 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
nm := b.nmConn.(gonetworkmanager.NetworkManager)
_, err = nm.ActivateConnection(targetConn, nil, nil)
if err != nil {
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = nil
b.cachedOpenConnectMu.Unlock()
b.pendingVPNSaveMu.Lock()
b.pendingVPNSave = nil
b.pendingVPNSaveMu.Unlock()
b.stateMutex.Lock()
b.state.IsConnectingVPN = false
b.state.ConnectingVPNUUID = ""
@@ -425,6 +449,9 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
log.Infof("[VPN] External browser auth detected for protocol '%s' but only GlobalProtect (gp) is currently supported", protocol)
}
}
if protocol == "fortinet" && data["authtype"] == "password" {
return "openconnect_password"
}
case strings.Contains(serviceType, "openvpn"):
connType := data["connection-type"]
username := data["username"]
@@ -435,6 +462,200 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
return ""
}
func setOpenConnectAgentFlags(data map[string]string) bool {
changed := false
for _, field := range []string{"cookie", "gateway", "gwcert"} {
key := field + "-flags"
if data[key] != "2" {
data[key] = "2"
changed = true
}
}
return changed
}
func (b *NetworkManagerBackend) ensureOpenConnectAgentFlags(conn gonetworkmanager.Connection, data map[string]string) error {
if !setOpenConnectAgentFlags(data) {
return nil
}
if b.dbusConn == nil {
return fmt.Errorf("NetworkManager D-Bus connection is unavailable")
}
connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", conn.GetPath())
var existingSettings map[string]map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
return fmt.Errorf("failed to get connection settings: %w", err)
}
vpn, ok := existingSettings["vpn"]
if !ok {
return fmt.Errorf("VPN settings are missing")
}
vpn["data"] = dbus.MakeVariant(data)
var stored map[string]map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSecrets", 0, "vpn").Store(&stored); err != nil {
return fmt.Errorf("failed to preserve VPN secrets: %w", err)
}
if storedVPN, ok := stored["vpn"]; ok {
if secrets, ok := storedVPN["secrets"]; ok {
vpn["secrets"] = secrets
}
}
settings := map[string]map[string]dbus.Variant{"vpn": vpn}
if connection, ok := existingSettings["connection"]; ok {
settings["connection"] = connection
}
var result map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil {
return fmt.Errorf("failed to set NetworkManager secret-agent flags: %w", err)
}
return nil
}
func (b *NetworkManagerBackend) handleOpenConnectPasswordAuth(
ctx context.Context,
targetConn gonetworkmanager.Connection,
connName, targetUUID, vpnServiceType string,
data map[string]string,
) (*openConnectAuthResult, error) {
username := data["username"]
secrets := map[string]string{}
if stored, err := targetConn.GetSecrets("vpn"); err == nil {
if vpn, ok := stored["vpn"]; ok {
if saved, ok := vpn["secrets"].(map[string]string); ok {
secrets = saved
}
}
}
password := secrets["password"]
serverCert := secrets["certificate:"+data["gateway"]]
if serverCert == "" {
serverCert = secrets["gwcert"]
}
var reply PromptReply
if username == "" || password == "" {
if b.promptBroker == nil {
return nil, fmt.Errorf("password authentication requires an interactive prompt")
}
fields := []string{}
fieldsInfo := []FieldInfo{}
if username == "" {
fields = append(fields, "username")
fieldsInfo = append(fieldsInfo, FieldInfo{Name: "username", Label: "Username", IsSecret: false})
}
if password == "" {
fields = append(fields, "password")
fieldsInfo = append(fieldsInfo, FieldInfo{Name: "password", Label: "Password", IsSecret: true})
}
token, err := b.promptBroker.Ask(ctx, PromptRequest{
Name: connName,
ConnType: "vpn",
VpnService: vpnServiceType,
SettingName: "vpn",
Fields: fields,
FieldsInfo: fieldsInfo,
Reason: "required",
ConnectionId: connName,
ConnectionUuid: targetUUID,
ConnectionPath: string(targetConn.GetPath()),
})
if err != nil {
return nil, fmt.Errorf("failed to request credentials: %w", err)
}
reply, err = b.promptBroker.Wait(ctx, token)
if err != nil {
return nil, fmt.Errorf("credentials prompt failed: %w", err)
}
if username == "" {
username = reply.Secrets["username"]
}
if password == "" {
password = reply.Secrets["password"]
}
}
auth, err := runOpenConnectPasswordAuth(ctx, data, username, password, serverCert)
persistentSecrets := map[string]string{}
var authErr *openConnectAuthError
if err != nil && errors.As(err, &authErr) && authErr.serverCert != "" && authErr.serverCert != serverCert {
if b.promptBroker == nil {
return nil, fmt.Errorf("VPN server certificate is untrusted: %s", authErr.serverCert)
}
reason := "server-certificate"
if serverCert != "" {
reason = "server-certificate-changed"
}
token, promptErr := b.promptBroker.Ask(ctx, PromptRequest{
Name: connName,
ConnType: "vpn",
VpnService: vpnServiceType,
SettingName: "vpn",
Hints: []string{authErr.serverCert},
Reason: reason,
ConnectionId: connName,
ConnectionUuid: targetUUID,
ConnectionPath: string(targetConn.GetPath()),
})
if promptErr != nil {
return nil, fmt.Errorf("failed to request certificate confirmation: %w", promptErr)
}
if _, promptErr = b.promptBroker.Wait(ctx, token); promptErr != nil {
return nil, fmt.Errorf("certificate confirmation failed: %w", promptErr)
}
auth, err = runOpenConnectPasswordAuth(ctx, data, username, password, authErr.serverCert)
if err == nil {
persistentSecrets["certificate:"+data["gateway"]] = authErr.serverCert
}
}
if err != nil {
return nil, err
}
if len(reply.Secrets) > 0 || len(persistentSecrets) > 0 {
creds := &pendingVPNCredentials{
ConnectionPath: string(targetConn.GetPath()),
PersistentSecrets: persistentSecrets,
}
if _, ok := reply.Secrets["username"]; ok {
creds.Username = username
}
if reply.Save {
creds.Username = username
creds.Password = password
creds.Secrets = map[string]string{"password": password}
creds.SavePassword = true
}
b.pendingVPNSaveMu.Lock()
b.pendingVPNSave = creds
b.pendingVPNSaveMu.Unlock()
}
return auth, nil
}
func suggestedOpenConnectServerCert(output string) string {
for _, field := range strings.Fields(output) {
field = strings.Trim(field, "'\".,")
if strings.HasPrefix(field, "pin-sha256:") {
return field
}
}
return ""
}
func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkmanager.Connection, connName, targetUUID, vpnServiceType string) error {
log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
@@ -758,13 +979,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.VPNErrorUuid = ""
b.stateMutex.Unlock()
// Clear cached PKCS11 PIN and SAML cookie on success
// Clear cached one-shot authentication values on success.
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
b.cachedGPSamlMu.Lock()
b.cachedGPSamlCookie = nil
b.cachedGPSamlMu.Unlock()
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = nil
b.cachedOpenConnectMu.Unlock()
b.pendingVPNSaveMu.Lock()
pending := b.pendingVPNSave
@@ -787,13 +1008,16 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.VPNErrorUuid = connectingVPNUUID
b.stateMutex.Unlock()
// Clear cached PKCS11 PIN and SAML cookie on failure
// Clear cached one-shot authentication values on failure.
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
b.cachedGPSamlMu.Lock()
b.cachedGPSamlCookie = nil
b.cachedGPSamlMu.Unlock()
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = nil
b.cachedOpenConnectMu.Unlock()
b.pendingVPNSaveMu.Lock()
b.pendingVPNSave = nil
b.pendingVPNSaveMu.Unlock()
return
}
}
@@ -811,13 +1035,16 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.VPNErrorUuid = connectingVPNUUID
b.stateMutex.Unlock()
// Clear cached PKCS11 PIN and SAML cookie
// Clear cached one-shot authentication values.
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
b.cachedGPSamlMu.Lock()
b.cachedGPSamlCookie = nil
b.cachedGPSamlMu.Unlock()
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = nil
b.cachedOpenConnectMu.Unlock()
b.pendingVPNSaveMu.Lock()
b.pendingVPNSave = nil
b.pendingVPNSaveMu.Unlock()
}
}
@@ -863,17 +1090,40 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
log.Infof("[saveVPNCredentials] Saving username")
}
// Save secrets if requested
if creds.SavePassword {
secs := creds.Secrets
if len(secs) == 0 {
secs = map[string]string{"password": creds.Password}
secs := map[string]string{}
if len(creds.PersistentSecrets) > 0 {
var stored map[string]map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSecrets", 0, "vpn").Store(&stored); err != nil {
log.Warnf("[saveVPNCredentials] GetSecrets failed: %v", err)
return
}
for field := range secs {
if storedVPN, ok := stored["vpn"]; ok {
if storedSecrets, ok := storedVPN["secrets"]; ok {
saved, _ := storedSecrets.Value().(map[string]string)
for field, value := range saved {
secs[field] = value
}
}
}
for field, value := range creds.PersistentSecrets {
secs[field] = value
data[field+"-flags"] = "0"
}
}
if creds.SavePassword {
toSave := creds.Secrets
if len(toSave) == 0 {
toSave = map[string]string{"password": creds.Password}
}
for field, value := range toSave {
secs[field] = value
data[field+"-flags"] = "0"
}
}
if len(secs) > 0 {
vpn["secrets"] = dbus.MakeVariant(secs)
log.Infof("[saveVPNCredentials] Saving %d secret field(s) with flags=0", len(secs))
log.Infof("[saveVPNCredentials] Saving %d secret field(s)", len(secs))
}
vpn["data"] = dbus.MakeVariant(data)
@@ -1,10 +1,14 @@
package network
import (
"context"
"os"
"path/filepath"
"testing"
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
"github.com/Wifx/gonetworkmanager/v2"
"github.com/godbus/dbus/v5"
"github.com/stretchr/testify/assert"
)
@@ -136,3 +140,137 @@ func TestNetworkManagerBackend_UpdateVPNConnectionState_EmptyUUID(t *testing.T)
backend.updateVPNConnectionState()
})
}
func TestDetectVPNAuthAction_FortinetPasswordOnly(t *testing.T) {
service := "org.freedesktop.NetworkManager.openconnect"
assert.Equal(t, "openconnect_password", detectVPNAuthAction(service, map[string]string{
"protocol": "fortinet",
"authtype": "password",
}))
assert.Empty(t, detectVPNAuthAction(service, map[string]string{
"protocol": "anyconnect",
"authtype": "password",
}))
assert.Empty(t, detectVPNAuthAction(service, map[string]string{
"protocol": "fortinet",
"authtype": "saml",
}))
}
func TestEnsureOpenConnectAgentFlags(t *testing.T) {
data := map[string]string{"protocol": "fortinet"}
assert.True(t, setOpenConnectAgentFlags(data))
assert.Equal(t, "2", data["cookie-flags"])
assert.Equal(t, "2", data["gateway-flags"])
assert.Equal(t, "2", data["gwcert-flags"])
assert.False(t, setOpenConnectAgentFlags(data))
}
func TestOpenConnectCertificateConfirmation(t *testing.T) {
binDir := t.TempDir()
openConnectPath := filepath.Join(binDir, "openconnect")
script := `#!/bin/sh
case "$*" in
*--servercert=pin-sha256:TEST-FINGERPRINT*)
printf '%s\n' "COOKIE='SVPNCOOKIE=test'" "HOST='vpn.example.test'" "FINGERPRINT='pin-sha256:TEST-FINGERPRINT'"
exit 0
;;
esac
printf '%s\n' 'Add --servercert pin-sha256:TEST-FINGERPRINT' >&2
exit 1
`
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
t.Setenv("PATH", binDir)
conn := mock_gonetworkmanager.NewMockConnection(t)
connPath := dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/999")
conn.EXPECT().GetSecrets("vpn").Return(gonetworkmanager.ConnectionSettings{
"vpn": {"secrets": map[string]string{"password": "test-password"}},
}, nil)
conn.EXPECT().GetPath().Return(connPath).Twice()
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{},
}
backend := &NetworkManagerBackend{promptBroker: broker}
data := map[string]string{
"gateway": "vpn.example.test:443",
"protocol": "fortinet",
"authtype": "password",
"username": "test-user",
}
result, err := backend.handleOpenConnectPasswordAuth(
context.Background(), conn, "Test VPN", "test-uuid",
"org.freedesktop.NetworkManager.openconnect", data,
)
assert.NoError(t, err)
assert.Equal(t, "SVPNCOOKIE=test", result.Cookie)
assert.Equal(t, "vpn.example.test:443", result.Host)
prompt := <-broker.asked
assert.Equal(t, "server-certificate", prompt.Reason)
assert.Equal(t, []string{"pin-sha256:TEST-FINGERPRINT"}, prompt.Hints)
assert.Equal(t, map[string]string{
"certificate:vpn.example.test:443": "pin-sha256:TEST-FINGERPRINT",
}, backend.pendingVPNSave.PersistentSecrets)
}
func TestOpenConnectCertificateRotationReprompts(t *testing.T) {
binDir := t.TempDir()
openConnectPath := filepath.Join(binDir, "openconnect")
script := `#!/bin/sh
case "$*" in
*--servercert=pin-sha256:NEW-FINGERPRINT*)
printf '%s\n' "COOKIE='SVPNCOOKIE=test'" "HOST='vpn.example.test'" "FINGERPRINT='pin-sha256:NEW-FINGERPRINT'"
exit 0
;;
esac
printf '%s\n' 'Add --servercert pin-sha256:NEW-FINGERPRINT' >&2
exit 1
`
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
t.Setenv("PATH", binDir)
conn := mock_gonetworkmanager.NewMockConnection(t)
connPath := dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/999")
conn.EXPECT().GetSecrets("vpn").Return(gonetworkmanager.ConnectionSettings{
"vpn": {"secrets": map[string]string{
"password": "test-password",
"certificate:vpn.example.test:443": "pin-sha256:OLD-FINGERPRINT",
}},
}, nil)
conn.EXPECT().GetPath().Return(connPath).Twice()
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{},
}
backend := &NetworkManagerBackend{promptBroker: broker}
data := map[string]string{
"gateway": "vpn.example.test:443",
"protocol": "fortinet",
"authtype": "password",
"username": "test-user",
}
result, err := backend.handleOpenConnectPasswordAuth(
context.Background(), conn, "Test VPN", "test-uuid",
"org.freedesktop.NetworkManager.openconnect", data,
)
assert.NoError(t, err)
assert.Equal(t, "SVPNCOOKIE=test", result.Cookie)
prompt := <-broker.asked
assert.Equal(t, "server-certificate-changed", prompt.Reason)
assert.Equal(t, []string{"pin-sha256:NEW-FINGERPRINT"}, prompt.Hints)
assert.Equal(t, map[string]string{
"certificate:vpn.example.test:443": "pin-sha256:NEW-FINGERPRINT",
}, backend.pendingVPNSave.PersistentSecrets)
assert.False(t, backend.pendingVPNSave.SavePassword)
assert.Empty(t, backend.pendingVPNSave.Secrets)
}
+18
View File
@@ -43,6 +43,8 @@ func HandleRequest(conn *models.Conn, req models.Request, manager *Manager) {
handleGetNetworkQRCode(conn, req, manager)
case "network.qrcode-content":
handleGetNetworkQRCodeContent(conn, req, manager)
case "network.generate-qrcode":
handleGenerateQRCode(conn, req)
case "network.delete-qrcode":
handleDeleteQRCode(conn, req, manager)
case "network.ethernet.info":
@@ -365,6 +367,22 @@ func handleGetNetworkQRCodeContent(conn *models.Conn, req models.Request, manage
models.Respond(conn, req.ID, content)
}
func handleGenerateQRCode(conn *models.Conn, req models.Request) {
text, err := params.String(req.Params, "text")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
paths, err := generateTextQRCode(text)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, paths)
}
func handleDeleteQRCode(conn *models.Conn, req models.Request, _ *Manager) {
path, err := params.String(req.Params, "path")
if err != nil {
@@ -0,0 +1,64 @@
package network
import (
"crypto/sha256"
"fmt"
"os"
"time"
"github.com/yeqown/go-qrcode/v2"
"github.com/yeqown/go-qrcode/writer/standard"
)
const textQRCodeTmpPrefix = "/tmp/dank-text-qrcode-"
func generateTextQRCode(text string) ([2]string, error) {
qrc, err := qrcode.New(text)
if err != nil {
return [2]string{}, fmt.Errorf("failed to create QR code for text: %w", err)
}
pathThemed, pathNormal := textQRCodePaths(text)
if err := saveQRCodePNG(qrc, pathThemed, standard.WithBgTransparent(), standard.WithFgColorRGBHex("#ffffff")); err != nil {
return [2]string{}, err
}
if err := saveQRCodePNG(qrc, pathNormal); err != nil {
return [2]string{}, err
}
return [2]string{pathThemed, pathNormal}, nil
}
// Write to a temp file and rename into place so the shell's Image never
// observes a partially written PNG.
func saveQRCodePNG(qrc *qrcode.QRCode, path string, opts ...standard.ImageOption) error {
tmpPath := path + ".tmp"
opts = append(opts, standard.WithBuiltinImageEncoder(standard.PNG_FORMAT))
w, err := standard.New(tmpPath, opts...)
if err != nil {
return fmt.Errorf("failed to create QR code writer: %w", err)
}
if err := qrc.Save(w); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to save QR code: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to move QR code into place: %w", err)
}
return nil
}
// Paths are unique per generation, not per text: the library's mask selection
// is non-deterministic, so regenerating the same text produces different
// bytes, and reusing a path lets the shell's URL-keyed pixmap cache serve a
// stale pattern over the new file.
func textQRCodePaths(text string) (themed, normal string) {
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(text)))[:8]
nonce := time.Now().UnixNano()
themed = fmt.Sprintf("%s%s-%d-themed.png", textQRCodeTmpPrefix, hash, nonce)
normal = fmt.Sprintf("%s%s-%d-normal.png", textQRCodeTmpPrefix, hash, nonce)
return
}
+1 -1
View File
@@ -24,7 +24,7 @@ func qrCodePaths(ssid string) (themed, normal string) {
func isValidQRCodePath(path string) bool {
clean := filepath.Clean(path)
return strings.HasPrefix(clean, qrCodeTmpPrefix) && strings.HasSuffix(clean, ".png")
return (strings.HasPrefix(clean, qrCodeTmpPrefix) || strings.HasPrefix(clean, textQRCodeTmpPrefix)) && strings.HasSuffix(clean, ".png")
}
var safePathChar = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
@@ -0,0 +1,83 @@
package registries
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/afero"
)
type RegistryInfo struct {
Name string `json:"name"`
URL string `json:"url"`
Official bool `json:"official"`
}
type SuccessResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
func HandleRequest(conn *models.Conn, req models.Request) {
switch req.Method {
case "registries.list":
HandleList(conn, req)
case "registries.add":
HandleAdd(conn, req)
case "registries.remove":
HandleRemove(conn, req)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func HandleList(conn *models.Conn, req models.Request) {
sources := registries.Load(afero.NewOsFs())
result := make([]RegistryInfo, len(sources))
for i, s := range sources {
result[i] = RegistryInfo{Name: s.Name, URL: s.URL, Official: s.Official()}
}
models.Respond(conn, req.ID, result)
}
func HandleAdd(conn *models.Conn, req models.Request) {
name, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return
}
url, ok := models.Get[string](req, "url")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'url' parameter")
return
}
if err := registries.Add(afero.NewOsFs(), name, url); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{
Success: true,
Message: fmt.Sprintf("registry added: %s", name),
})
}
func HandleRemove(conn *models.Conn, req models.Request) {
name, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return
}
if err := registries.Remove(afero.NewOsFs(), name); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{
Success: true,
Message: fmt.Sprintf("registry removed: %s", name),
})
}
+6
View File
@@ -18,6 +18,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
serverRegistries "github.com/AvengeMedia/DankMaterialShell/core/internal/server/registries"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
@@ -47,6 +48,11 @@ func RouteRequest(conn *models.Conn, req models.Request) {
return
}
if strings.HasPrefix(req.Method, "registries.") {
serverRegistries.HandleRequest(conn, req)
return
}
if strings.HasPrefix(req.Method, "theme.auto.") {
if themeModeManager == nil {
models.RespondError(conn, req.ID, "theme mode manager not initialized")
+33 -1
View File
@@ -36,7 +36,7 @@ import (
"github.com/AvengeMedia/dankgo/syncmap"
)
const APIVersion = 28
const APIVersion = 29
var CLIVersion = "dev"
@@ -1183,6 +1183,38 @@ func handleSubscribe(conn *models.Conn, req models.Request) {
}()
}
if shouldSubscribe("location") && locationManager != nil {
wg.Add(1)
locationChan := locationManager.Subscribe(clientID + "-location")
go func() {
defer wg.Done()
defer locationManager.Unsubscribe(clientID + "-location")
initialState := locationManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "location", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-locationChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "location", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
wg.Add(1)
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
+31 -20
View File
@@ -759,6 +759,9 @@ func (m *Manager) schedulerLoop() {
now := time.Now()
m.recalcSchedule(now)
// publish independent of output readiness so night status never
// presents a stale schedule while applies are blocked (#2967)
m.updateStateFromSchedule()
waitDur := 24 * time.Hour
if enabled {
@@ -1104,13 +1107,15 @@ func (m *Manager) SetTemperature(low, high int) error {
m.configMutex.Unlock()
return nil
}
m.config.LowTemp = low
m.config.HighTemp = high
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
updated := m.config
updated.LowTemp = low
updated.HighTemp = high
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1122,14 +1127,16 @@ func (m *Manager) SetLocation(lat, lon float64) error {
m.configMutex.Unlock()
return nil
}
m.config.Latitude = &lat
m.config.Longitude = &lon
m.config.UseIPLocation = false
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
updated := m.config
updated.Latitude = &lat
updated.Longitude = &lon
updated.UseIPLocation = false
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1164,13 +1171,15 @@ func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
m.configMutex.Unlock()
return nil
}
m.config.ManualSunrise = &sunrise
m.config.ManualSunset = &sunset
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
updated := m.config
updated.ManualSunrise = &sunrise
updated.ManualSunset = &sunset
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1193,12 +1202,14 @@ func (m *Manager) SetGamma(gamma float64) error {
m.configMutex.Unlock()
return nil
}
m.config.Gamma = gamma
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
updated := m.config
updated.Gamma = gamma
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
)
func TestManager_ActorSerializesOutputStateAccess(t *testing.T) {
@@ -412,3 +413,75 @@ func TestNewManager_InvalidConfig(t *testing.T) {
_, err := NewManager(mockDisplay, config)
assert.Error(t, err)
}
func TestSetters_RejectedValuesLeaveConfigUntouched(t *testing.T) {
newManager := func() *Manager {
return &Manager{
config: DefaultConfig(),
updateTrigger: make(chan struct{}, 1),
}
}
t.Run("SetTemperature", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetTemperature(3200, 2500)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
t.Run("SetLocation", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetLocation(120.0, 10.0)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
t.Run("SetGamma", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetGamma(-1.0)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
}
func TestSetters_ValidValuesCommitAndTrigger(t *testing.T) {
m := &Manager{
config: DefaultConfig(),
updateTrigger: make(chan struct{}, 1),
}
err := m.SetTemperature(3000, 6000)
assert.NoError(t, err)
assert.Equal(t, 3000, m.config.LowTemp)
assert.Equal(t, 6000, m.config.HighTemp)
assert.Len(t, m.updateTrigger, 1)
}
func TestApplyGamma_SkipsUnchangedTempAndGamma(t *testing.T) {
m := &Manager{config: DefaultConfig()}
m.controlsInitialized = true
out := &outputState{
id: 1,
rampSize: 256,
gammaControl: &wlr_gamma_control.ZwlrGammaControlV1{},
lastTemp: 5000,
lastGamma: m.config.Gamma,
}
m.outputs.Store(out.id, out)
m.applyGamma(5000)
assert.False(t, out.failed, "unchanged temp must not reach the compositor write path")
assert.Equal(t, 5000, out.lastTemp)
assert.Equal(t, uint32(256), out.rampSize)
}
+113 -44
View File
@@ -7,12 +7,11 @@ import (
"os"
"path/filepath"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/go-git/go-git/v6"
"github.com/spf13/afero"
)
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
type ColorScheme struct {
Primary string `json:"primary,omitempty"`
PrimaryText string `json:"primaryText,omitempty"`
@@ -151,6 +150,7 @@ type Theme struct {
type GitClient interface {
PlainClone(path string, url string) error
Pull(path string) error
OriginURL(path string) (string, error)
}
type realGitClient struct{}
@@ -182,11 +182,28 @@ func (g *realGitClient) Pull(path string) error {
return nil
}
func (g *realGitClient) OriginURL(path string) (string, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return "", err
}
remote, err := repo.Remote("origin")
if err != nil {
return "", err
}
urls := remote.Config().URLs
if len(urls) == 0 {
return "", errors.New("origin remote has no URL")
}
return urls[0], nil
}
type Registry struct {
fs afero.Fs
cacheDir string
themes []Theme
git GitClient
fs afero.Fs
cacheDir string
registries []registries.Source
themes []Theme
git GitClient
}
func NewRegistry() (*Registry, error) {
@@ -194,61 +211,63 @@ func NewRegistry() (*Registry, error) {
}
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
cacheDir := getCacheDir()
return &Registry{
fs: fs,
cacheDir: cacheDir,
git: &realGitClient{},
fs: fs,
cacheDir: getCacheDir(),
registries: registries.Load(fs),
git: &realGitClient{},
}, nil
}
func (r *Registry) cacheDirFor(src registries.Source) string {
return filepath.Join(r.cacheDir, src.Name)
}
func getCacheDir() string {
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
}
func (r *Registry) Update() error {
exists, err := afero.DirExists(r.fs, r.cacheDir)
// A cached clone is reused only when its origin still matches the configured
// URL; renamed or re-pointed registries re-clone instead of pulling from the
// stale remote.
func (r *Registry) updateOne(src registries.Source) error {
dir := r.cacheDirFor(src)
exists, err := afero.DirExists(r.fs, dir)
if err != nil {
return fmt.Errorf("failed to check cache directory: %w", err)
}
if !exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
if exists {
origin, originErr := r.git.OriginURL(dir)
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
return nil
}
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to clone registry: %w", err)
}
} else {
if err := r.git.Pull(r.cacheDir); err != nil {
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
return fmt.Errorf("failed to remove corrupted registry: %w", err)
}
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to re-clone registry: %w", err)
}
if err := r.fs.RemoveAll(dir); err != nil {
return fmt.Errorf("failed to remove stale registry cache: %w", err)
}
}
return r.loadThemes()
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(dir, src.URL); err != nil {
return fmt.Errorf("failed to clone: %w", err)
}
return nil
}
func (r *Registry) loadThemes() error {
themesDir := filepath.Join(r.cacheDir, "themes")
// A registry without a themes/ directory is a valid plugins-only registry.
func (r *Registry) loadThemesFrom(dir string) ([]Theme, error) {
themesDir := filepath.Join(dir, "themes")
entries, err := afero.ReadDir(r.fs, themesDir)
if err != nil {
return fmt.Errorf("failed to read themes directory: %w", err)
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read themes directory: %w", err)
}
r.themes = []Theme{}
var themes []Theme
for _, entry := range entries {
if !entry.IsDir() {
continue
@@ -278,10 +297,46 @@ func (r *Registry) loadThemes() error {
theme.PreviewPath = previewPath
}
r.themes = append(r.themes, theme)
themes = append(themes, theme)
}
return themes, nil
}
return nil
// Pre-multi-registry caches were a single clone at the base dir; the per-name
// layout nests under it, so a leftover clone is deleted wholesale first.
func (r *Registry) resetLegacyCache() {
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
_ = r.fs.RemoveAll(r.cacheDir)
}
}
// Update refreshes every configured registry, aggregating themes in
// declaration order (first occurrence of an ID wins). A failing registry is
// reported in the joined error but does not block the others.
func (r *Registry) Update() error {
r.resetLegacyCache()
r.themes = []Theme{}
seen := make(map[string]struct{})
var errs []error
for _, src := range r.registries {
if err := r.updateOne(src); err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
themes, err := r.loadThemesFrom(r.cacheDirFor(src))
if err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
for _, t := range themes {
if _, dup := seen[t.ID]; dup {
continue
}
seen[t.ID] = struct{}{}
r.themes = append(r.themes, t)
}
}
return errors.Join(errs...)
}
func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
@@ -300,7 +355,7 @@ func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
func (r *Registry) List() ([]Theme, error) {
if len(r.themes) == 0 {
if err := r.Update(); err != nil {
if err := r.Update(); err != nil && len(r.themes) == 0 {
return nil, err
}
}
@@ -343,11 +398,25 @@ func (r *Registry) Get(idOrName string) (*Theme, error) {
}
func (r *Registry) GetThemeSourcePath(themeID string) string {
return filepath.Join(r.cacheDir, "themes", themeID, "theme.json")
// Themes may live under any registry's subdir. Search them all; first hit wins.
for _, cfg := range r.registries {
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID, "theme.json")
if exists, _ := afero.Exists(r.fs, candidate); exists {
return candidate
}
}
// Fallback to first registry (legacy path semantics).
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID, "theme.json")
}
func (r *Registry) GetThemeDir(themeID string) string {
return filepath.Join(r.cacheDir, "themes", themeID)
for _, cfg := range r.registries {
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID)
if exists, _ := afero.DirExists(r.fs, candidate); exists {
return candidate
}
}
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID)
}
func SortByFirstParty(themes []Theme) []Theme {
+72
View File
@@ -1,8 +1,10 @@
package themes
import (
"os"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero"
)
@@ -64,3 +66,73 @@ func TestLoadThemeWCAGInvalidJSON(t *testing.T) {
t.Fatalf("expected nil for invalid wcag.json, got %+v", wcag)
}
}
type stubGitClient struct {
cloneFunc func(path string, url string) error
}
func (s *stubGitClient) PlainClone(path string, url string) error {
if s.cloneFunc != nil {
return s.cloneFunc(path, url)
}
return nil
}
func (s *stubGitClient) Pull(path string) error { return nil }
func (s *stubGitClient) OriginURL(path string) (string, error) { return "", os.ErrNotExist }
func writeTestTheme(t *testing.T, fs afero.Fs, registryDir, themeID, name string) {
dir := registryDir + "/themes/" + themeID
if err := fs.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
themeJSON := `{"id":"` + themeID + `","name":"` + name + `","version":"1.0","author":"a","description":"d"}`
if err := afero.WriteFile(fs, dir+"/theme.json", []byte(themeJSON), 0o644); err != nil {
t.Fatal(err)
}
}
func TestUpdateMultiRegistry(t *testing.T) {
fs := afero.NewMemMapFs()
base := "/test-cache"
r := &Registry{
fs: fs,
cacheDir: base,
registries: []registries.Source{
{Name: "official", URL: "https://example.com/official.git"},
{Name: "extra", URL: "https://example.com/extra.git"},
},
themes: []Theme{},
}
r.git = &stubGitClient{
cloneFunc: func(path string, url string) error {
switch path {
case base + "/official":
writeTestTheme(t, fs, path, "shared", "OfficialShared")
writeTestTheme(t, fs, path, "one", "One")
case base + "/extra":
writeTestTheme(t, fs, path, "shared", "ExtraShared")
writeTestTheme(t, fs, path, "two", "Two")
}
return nil
},
}
if err := r.Update(); err != nil {
t.Fatalf("Update: %v", err)
}
if len(r.themes) != 3 {
t.Fatalf("expected 3 themes after dedupe, got %d", len(r.themes))
}
for _, theme := range r.themes {
if theme.ID == "shared" && theme.Name != "OfficialShared" {
t.Fatalf("first registry should win for duplicate ID, got %q", theme.Name)
}
}
if dir := r.GetThemeDir("two"); dir != base+"/extra/themes/two" {
t.Fatalf("expected theme dir under extra registry, got %q", dir)
}
if path := r.GetThemeSourcePath("one"); path != base+"/official/themes/one/theme.json" {
t.Fatalf("expected theme source under official registry, got %q", path)
}
}
Generated
+3 -3
View File
@@ -3,11 +3,11 @@
"dank-qml-common": {
"flake": false,
"locked": {
"lastModified": 1785121997,
"narHash": "sha256-/MslqFCpjxws8DZqipEKCCTBa0m/SCV3+v1NRT6EuvQ=",
"lastModified": 1786062187,
"narHash": "sha256-i9tB3+PoM2d5NHfb7bvNc5IHvymp088OW3ThMAL+39I=",
"owner": "AvengeMedia",
"repo": "dank-qml-common",
"rev": "7cc4564e5903a2955fe7da76969f20252cacf9bf",
"rev": "3513b348ba425eab27d35b27ce924a0e324dc359",
"type": "github"
},
"original": {
+1 -1
View File
@@ -111,7 +111,7 @@
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-ZvaOPC92ZFRPqSyLJa2TA9OUKQ3QnWCIMxrnYLGnC58=";
vendorHash = "sha256-pjaRyB6E2TZvVd5a4xcdGSRVr9Dg9wEG/5e+HdtZJCg=";
subPackages = [ "cmd/dms" ];
+58
View File
@@ -271,3 +271,61 @@ function getConflictingBinds(keyCombo, currentAction, allBinds, modKey) {
}
return conflicts;
}
function qtKeyFromName(name) {
var n = (name || "").toUpperCase();
if (n.length === 1 && n >= "A" && n <= "Z")
return Qt.Key_A + (n.charCodeAt(0) - 65);
if (n.length === 1 && n >= "0" && n <= "9")
return Qt.Key_0 + (n.charCodeAt(0) - 48);
if (n.length >= 2 && n[0] === "F") {
var f = parseInt(n.slice(1), 10);
if (f >= 1 && f <= 12)
return Qt.Key_F1 + (f - 1);
}
var named = {
"SPACE": Qt.Key_Space,
"TAB": Qt.Key_Tab,
"RETURN": Qt.Key_Return,
"ENTER": Qt.Key_Enter,
"BACKSPACE": Qt.Key_Backspace,
"DELETE": Qt.Key_Delete,
"HOME": Qt.Key_Home,
"END": Qt.Key_End,
"UP": Qt.Key_Up,
"DOWN": Qt.Key_Down,
"LEFT": Qt.Key_Left,
"RIGHT": Qt.Key_Right
};
return named[n] || 0;
}
function isModifierKey(qk) {
return qk === Qt.Key_Control || qk === Qt.Key_Shift || qk === Qt.Key_Alt || qk === Qt.Key_Meta
|| qk === Qt.Key_NumLock || qk === Qt.Key_CapsLock || qk === Qt.Key_ScrollLock;
}
function eventMatchesCombo(event, combo) {
if (!combo)
return false;
var parts = combo.split("+");
var keyName = parts[parts.length - 1].trim().toUpperCase();
var wantsShift = false;
var hasCtrl = false;
for (var i = 0; i < parts.length - 1; i++) {
var mod = parts[i].trim().toLowerCase();
if (mod === "shift")
wantsShift = true;
else if (mod === "ctrl" || mod === "control")
hasCtrl = true;
else
return false;
}
if (hasCtrl && !(event.modifiers & Qt.ControlModifier))
return false;
if (event.modifiers & (Qt.AltModifier | Qt.MetaModifier))
return false;
if (((event.modifiers & Qt.ShiftModifier) !== 0) !== wantsShift)
return false;
return event.key === qtKeyFromName(keyName);
}
+11 -2
View File
@@ -15,7 +15,11 @@ Singleton {
function openModal(modal) {
PopoutManager.screenshotActive = false;
const screenName = modal.effectiveScreen?.name ?? "unknown";
currentModalsByScreen[screenName] = modal;
var next = {};
for (var k in currentModalsByScreen)
next[k] = currentModalsByScreen[k];
next[screenName] = modal;
currentModalsByScreen = next;
modalChanged();
Qt.callLater(() => {
if (!modal.allowStacking)
@@ -34,7 +38,12 @@ Singleton {
function closeModal(modal) {
const screenName = modal.effectiveScreen?.name ?? "unknown";
if (currentModalsByScreen[screenName] === modal) {
delete currentModalsByScreen[screenName];
var next = {};
for (var k in currentModalsByScreen) {
if (k !== screenName)
next[k] = currentModalsByScreen[k];
}
currentModalsByScreen = next;
modalChanged();
}
}
+38
View File
@@ -148,6 +148,7 @@ Singleton {
property var includedTransitions: availableWallpaperTransitions.filter(t => t !== "none")
property bool wallpaperCyclingEnabled: false
property bool wallpaperCyclingRandom: false
property string wallpaperCyclingMode: "interval"
property int wallpaperCyclingInterval: 300
property string wallpaperCyclingTime: "06:00"
@@ -646,6 +647,11 @@ Singleton {
saveSettings();
}
function setWallpaperCyclingRandom(random) {
wallpaperCyclingRandom = random;
saveSettings();
}
function setWallpaperCyclingMode(mode) {
wallpaperCyclingMode = mode;
saveSettings();
@@ -692,6 +698,37 @@ Singleton {
saveSettings();
}
function setMonitorCyclingRandom(screenName, random) {
var screen = null;
var screens = Quickshell.screens;
for (var i = 0; i < screens.length; i++) {
if (screens[i].name === screenName) {
screen = screens[i];
break;
}
}
if (!screen) {
log.warn("Screen not found");
return;
}
var identifier = typeof SettingsData !== "undefined" ? SettingsData.getScreenDisplayName(screen) : screen.name;
var newSettings = {};
for (var key in monitorCyclingSettings) {
var isThisScreen = key === screen.name || (screen.model && key === screen.model);
if (!isThisScreen) {
newSettings[key] = monitorCyclingSettings[key];
}
}
newSettings[identifier] = getMonitorCyclingSettings(screenName);
newSettings[identifier].random = random;
monitorCyclingSettings = newSettings;
saveSettings();
}
function setMonitorCyclingMode(screenName, mode) {
var screen = null;
var screens = Quickshell.screens;
@@ -1322,6 +1359,7 @@ Singleton {
function getMonitorCyclingSettings(screenName) {
var defaults = {
"enabled": false,
"random": false,
"mode": "interval",
"interval": 300,
"time": "06:00"
+20 -6
View File
@@ -164,6 +164,11 @@ Singleton {
property string matugenTargetMonitor: ""
property real popupTransparency: 1.0
property real dockTransparency: 1
property bool floatingWindowSyncGlobal: true
property real floatingWindowTransparency: 1.0
property bool floatingWindowForegroundLayers: true
property real floatingWindowForegroundTransparency: 1.0
property bool dmsWindowsFloating: true
property string widgetBackgroundColor: "sch"
property string widgetBackgroundCustomColor: "#6750A4"
property real widgetBackgroundCustomStrength: 0.50
@@ -203,6 +208,16 @@ Singleton {
property bool touchpadTapAndDrag: true
property bool touchpadTapToClick: true
property string keyboardLayouts: ""
property string keyboardVariants: ""
property string keyboardModel: ""
property string keyboardOptions: ""
property string keyboardKeymapFile: ""
property string keyboardTrackLayout: ""
property int keyboardRepeatDelay: 0
property int keyboardRepeatRate: 0
property bool keyboardNumlock: false
property int firstDayOfWeek: -1
property bool showWeekNumber: false
property string calendarBackend: "auto"
@@ -254,6 +269,7 @@ Singleton {
onBlurEnabledChanged: saveSettings()
property bool blurForegroundLayers: true
onBlurForegroundLayersChanged: saveSettings()
property real foregroundLayerTransparency: 1.0
property real blurLayerOutlineOpacity: 0.12
onBlurLayerOutlineOpacityChanged: saveSettings()
property bool blurBorderEnabled: true
@@ -479,6 +495,7 @@ Singleton {
property bool mediaAdaptiveWidthEnabled: true
property bool audioVisualizerEnabled: true
property bool mediaUseAlbumArtAccent: false
property bool appleMusicAnimatedArtEnabled: false
property string audioScrollMode: "volume"
property int audioWheelScrollAmount: 5
property bool audioDeviceScrollVolumeEnabled: false
@@ -659,9 +676,6 @@ Singleton {
readonly property string iconTheme: resolveIconTheme()
property var availableIconThemes: ["System Default"]
property string systemDefaultIconTheme: ""
property bool qt5ctAvailable: false
property bool qt6ctAvailable: false
property bool gtkAvailable: false
property var cursorSettings: ({
"theme": "System Default",
@@ -798,8 +812,6 @@ Singleton {
property int fadeToDpmsGracePeriod: 5
property string launchPrefix: ""
property bool gtkThemingEnabled: false
property bool qtThemingEnabled: false
property bool syncModeWithPortal: true
property bool terminalsAlwaysDark: false
@@ -922,6 +934,8 @@ Singleton {
property bool lockPamInlineU2f: false
property bool lockPamExternallyManaged: false
property string lockU2fPamPath: ""
property string lockScreenSecurityKeyShortcut: "Ctrl+Q"
property bool lockScreenSecurityKeyShortcutEnabled: false
property bool greeterPamExternallyManaged: false
property string lockScreenInactiveColor: "#000000"
property int lockScreenNotificationMode: 0
@@ -976,6 +990,7 @@ Singleton {
property string customPowerActionHibernate: ""
property string customPowerActionReboot: ""
property string customPowerActionPowerOff: ""
property var customPowerButtons: []
property bool updaterHideWidget: false
property bool updaterCheckOnStart: false
@@ -1683,7 +1698,6 @@ Singleton {
_hasLoaded = true;
applyStoredTheme();
updateCompositorCursor();
Processes.detectQtTools();
Qt.callLater(checkIconThemeDrift);
_checkSettingsWritable();
+33 -4
View File
@@ -96,8 +96,6 @@ Singleton {
}
property bool matugenAvailable: false
property bool gtkThemingEnabled: typeof SettingsData !== "undefined" ? SettingsData.gtkAvailable : false
property bool qtThemingEnabled: typeof SettingsData !== "undefined" ? (SettingsData.qt5ctAvailable || SettingsData.qt6ctAvailable) : false
property var workerRunning: false
property var pendingThemeRequest: null
@@ -347,11 +345,28 @@ Singleton {
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
readonly property bool blurForegroundLayers: blurLayersActive && foregroundLayers
readonly property bool transparentBlurLayers: blurLayersActive && !foregroundLayers
readonly property real foregroundLayerTransparency: typeof SettingsData === "undefined" ? 1.0 : (SettingsData.foregroundLayerTransparency ?? 1.0)
readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true)
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
readonly property color floatingSurfaceHigh: foregroundLayers ? withAlpha(surfaceContainerHigh, foregroundLayerTransparency) : withAlpha(surfaceContainerHigh, 0)
readonly property bool floatingWindowSynced: typeof SettingsData === "undefined" || (SettingsData.floatingWindowSyncGlobal ?? true)
readonly property real floatingWindowTransparency: {
if (typeof SettingsData === "undefined" || floatingWindowSynced)
return popupTransparency;
return SettingsData.floatingWindowTransparency ?? 1.0;
}
readonly property bool floatingWindowForegroundLayers: floatingWindowSynced ? foregroundLayers : (SettingsData.floatingWindowForegroundLayers ?? true)
readonly property real floatingWindowForegroundTransparency: {
if (typeof SettingsData === "undefined" || floatingWindowSynced)
return foregroundLayerTransparency;
return SettingsData.floatingWindowForegroundTransparency ?? 1.0;
}
readonly property color floatingWindowSurface: withAlpha(surfaceContainer, floatingWindowTransparency)
readonly property color floatingWindowSurfaceHigh: floatingWindowForegroundLayers ? withAlpha(surfaceContainerHigh, floatingWindowForegroundTransparency) : withAlpha(surfaceContainerHigh, 0)
readonly property color floatingWindowNestedSurface: floatingWindowSurfaceHigh
readonly property color notepadWindowSurface: withAlpha(surfaceContainer, notepadTransparency)
readonly property color nestedSurface: floatingSurfaceHigh
readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
@@ -359,6 +374,20 @@ Singleton {
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
readonly property real floatingWindowFieldAlpha: floatingWindowTransparency
readonly property color floatingWindowFieldColor: withAlpha(surfaceContainerHigh, floatingWindowFieldAlpha)
readonly property real popupFieldAlpha: {
if (transparentBlurLayers)
return 0.28;
if (blurForegroundLayers)
return Math.max(foregroundLayerTransparency, 0.62);
return popupTransparency;
}
readonly property color popupFieldColor: withAlpha(surfaceContainerHigh, popupFieldAlpha)
readonly property color popupFieldBorderColor: withAlpha(outline, blurLayersActive ? 0.16 : layerOutlineOpacity)
readonly property color popupFieldFocusedBorderColor: withAlpha(primary, blurLayersActive ? 0.72 : 1.0)
readonly property color floatingWindowFieldBorderColor: popupFieldBorderColor
readonly property color floatingWindowFieldFocusedBorderColor: popupFieldFocusedBorderColor
property color surfaceTextHover: withAlpha(surfaceText, 0.08)
property color surfaceTextAlpha: withAlpha(surfaceText, 0.3)
@@ -1196,7 +1225,7 @@ Singleton {
readonly property var _availableThemeNames: StockThemes.getAllThemeNames()
property string currentThemeName: currentTheme
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : popupTransparency
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : floatingWindowTransparency
property bool widgetBackgroundHasAlpha: {
const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch";
+61 -10
View File
@@ -1,31 +1,69 @@
pragma Singleton
import Quickshell
import Quickshell.Services.SystemTray
import QtQuick
Singleton {
id: root
property var activeTrayMenus: ({})
property var _pendingMenuRequest: null
signal openTrayMenuRequested
function requestOpenMenu(itemId, screenName) {
_pendingMenuRequest = {
"itemId": itemId,
"screenName": screenName
};
openTrayMenuRequested();
}
// Every SystemTrayBar instance receives the signal; the claim ensures
// exactly one opens the menu, preferring the requested screen
function claimMenuRequest(instanceScreenName) {
if (!_pendingMenuRequest)
return null;
if (_pendingMenuRequest.screenName && _pendingMenuRequest.screenName !== instanceScreenName)
return null;
const request = _pendingMenuRequest;
_pendingMenuRequest = null;
return request;
}
function findTrayItem(itemId: string): var {
if (!itemId)
return null;
return SystemTray.items.values.find(item => {
const id = item?.id || "";
const title = item?.tooltipTitle || "";
const fullKey = title ? `${id}::${title}` : id;
return fullKey === itemId || id === itemId;
});
}
function registerMenu(screenName, menu) {
if (!screenName || !menu) return
const newMenus = Object.assign({}, activeTrayMenus)
newMenus[screenName] = menu
activeTrayMenus = newMenus
if (!screenName || !menu)
return;
const newMenus = Object.assign({}, activeTrayMenus);
newMenus[screenName] = menu;
activeTrayMenus = newMenus;
}
function unregisterMenu(screenName) {
if (!screenName) return
const newMenus = Object.assign({}, activeTrayMenus)
delete newMenus[screenName]
activeTrayMenus = newMenus
if (!screenName)
return;
const newMenus = Object.assign({}, activeTrayMenus);
delete newMenus[screenName];
activeTrayMenus = newMenus;
}
function closeAllMenus() {
function closeHoverMenus() {
for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName]
if (!menu) continue
if (!menu || menu.openedByHover !== true) continue
if (typeof menu.close === "function") {
menu.close()
} else if (menu.showMenu !== undefined) {
@@ -33,4 +71,17 @@ Singleton {
}
}
}
function closeAllMenus() {
for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName];
if (!menu)
continue;
if (typeof menu.close === "function") {
menu.close();
} else if (menu.showMenu !== undefined) {
menu.showMenu = false;
}
}
}
}
-31
View File
@@ -487,41 +487,10 @@ Singleton {
return pamFprintDetected ? "probe_failed" : "missing_pam_support";
}
// --- Qt tools detection ---
function detectQtTools() {
qtToolsDetectionProcess.running = true;
}
function checkPluginSettings() {
pluginSettingsCheckProcess.running = true;
}
property var qtToolsDetectionProcess: Process {
command: ["sh", "-c", "echo -n 'qt5ct:'; command -v qt5ct >/dev/null && echo 'true' || echo 'false'; echo -n 'qt6ct:'; command -v qt6ct >/dev/null && echo 'true' || echo 'false'; echo -n 'gtk:'; (command -v gsettings >/dev/null || command -v dconf >/dev/null) && echo 'true' || echo 'false'"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if (!settingsRoot)
return;
if (text && text.trim()) {
const lines = text.trim().split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("qt5ct:")) {
settingsRoot.qt5ctAvailable = line.split(":")[1] === "true";
} else if (line.startsWith("qt6ct:")) {
settingsRoot.qt6ctAvailable = line.split(":")[1] === "true";
} else if (line.startsWith("gtk:")) {
settingsRoot.gtkAvailable = line.split(":")[1] === "true";
}
}
}
}
}
}
Timer {
id: authApplyDebounce
interval: 300
@@ -19,6 +19,7 @@ var SPEC = {
includedTransitions: { def: ["fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"] },
wallpaperCyclingEnabled: { def: false },
wallpaperCyclingRandom: { def: false },
wallpaperCyclingMode: { def: "interval" },
wallpaperCyclingInterval: { def: 300 },
wallpaperCyclingTime: { def: "06:00" },
+20 -5
View File
@@ -17,6 +17,11 @@ var SPEC = {
popupTransparency: { def: 1.0, coerce: percentToUnit },
dockTransparency: { def: 1.0, coerce: percentToUnit },
floatingWindowSyncGlobal: { def: true },
floatingWindowTransparency: { def: 1.0, coerce: percentToUnit },
floatingWindowForegroundLayers: { def: true },
floatingWindowForegroundTransparency: { def: 1.0, coerce: percentToUnit },
dmsWindowsFloating: { def: true },
widgetBackgroundColor: { def: "sch" },
widgetBackgroundCustomColor: { def: "#6750A4" },
@@ -57,6 +62,16 @@ var SPEC = {
touchpadTapAndDrag: { def: true, onChange: "updateCompositorInput" },
touchpadTapToClick: { def: true, onChange: "updateCompositorInput" },
keyboardLayouts: { def: "", onChange: "updateCompositorInput" },
keyboardVariants: { def: "", onChange: "updateCompositorInput" },
keyboardModel: { def: "", onChange: "updateCompositorInput" },
keyboardOptions: { def: "", onChange: "updateCompositorInput" },
keyboardKeymapFile: { def: "", onChange: "updateCompositorInput" },
keyboardTrackLayout: { def: "", onChange: "updateCompositorInput" },
keyboardRepeatDelay: { def: 0, onChange: "updateCompositorInput" },
keyboardRepeatRate: { def: 0, onChange: "updateCompositorInput" },
keyboardNumlock: { def: false, onChange: "updateCompositorInput" },
firstDayOfWeek: { def: -1 },
showWeekNumber: { def: false },
calendarBackend: { def: "auto" },
@@ -87,6 +102,7 @@ var SPEC = {
barElevationEnabled: { def: true },
blurEnabled: { def: false },
blurForegroundLayers: { def: true },
foregroundLayerTransparency: { def: 1.0, coerce: percentToUnit },
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
blurBorderEnabled: { def: true },
blurBorderColor: { def: "outline" },
@@ -205,6 +221,7 @@ var SPEC = {
mediaAdaptiveWidthEnabled: { def: true },
audioVisualizerEnabled: { def: true },
mediaUseAlbumArtAccent: { def: false },
appleMusicAnimatedArtEnabled: { def: false },
audioScrollMode: { def: "volume" },
audioWheelScrollAmount: { def: 5 },
audioDeviceScrollVolumeEnabled: { def: false },
@@ -298,9 +315,6 @@ var SPEC = {
lastAppliedIconTheme: { def: "" },
availableIconThemes: { def: ["System Default"], persist: false },
systemDefaultIconTheme: { def: "", persist: false },
qt5ctAvailable: { def: false, persist: false },
qt6ctAvailable: { def: false, persist: false },
gtkAvailable: { def: false, persist: false },
cursorSettings: { def: { theme: "System Default", size: 24, niri: { hideWhenTyping: false, hideAfterInactiveMs: 0 }, hyprland: { hideOnKeyPress: false, hideOnTouch: false, inactiveTimeout: 0 }, dwl: { cursorHideTimeout: 0 }, mango: { cursorHideTimeout: 0 } }, onChange: "updateCompositorCursor" },
availableCursorThemes: { def: ["System Default"], persist: false },
@@ -374,8 +388,6 @@ var SPEC = {
fadeToDpmsGracePeriod: { def: 5 },
launchPrefix: { def: "" },
gtkThemingEnabled: { def: false, onChange: "regenSystemThemes" },
qtThemingEnabled: { def: false, onChange: "regenSystemThemes" },
syncModeWithPortal: { def: true },
terminalsAlwaysDark: { def: false, onChange: "regenSystemThemes" },
@@ -473,6 +485,8 @@ var SPEC = {
enableU2f: { def: false, onChange: "scheduleAuthApply" },
u2fMode: { def: "or" },
lockPamPath: { def: "" },
lockScreenSecurityKeyShortcut: { def: "Ctrl+Q" },
lockScreenSecurityKeyShortcutEnabled: { def: false },
lockPamInlineFprint: { def: false },
lockPamInlineU2f: { def: false },
lockPamExternallyManaged: { def: false },
@@ -529,6 +543,7 @@ var SPEC = {
customPowerActionHibernate: { def: "" },
customPowerActionReboot: { def: "" },
customPowerActionPowerOff: { def: "" },
customPowerButtons: { def: [] },
updaterHideWidget: { def: false },
updaterCheckOnStart: { def: false },
+24 -1
View File
@@ -31,6 +31,8 @@ Item {
id: root
readonly property var log: Log.scoped("DMSShell")
readonly property var _sessionsServiceRef: SessionsService
// Keep the optional integration service alive
readonly property var _dankConnectServiceRef: DankConnectService
property var core: null
@@ -445,6 +447,23 @@ Item {
}
}
LazyLoader {
id: qrGeneratorModalLoader
active: false
Component.onCompleted: {
PopoutService.qrGeneratorModalLoader = qrGeneratorModalLoader;
}
QRGeneratorModal {
id: qrGeneratorModalItem
Component.onCompleted: {
PopoutService.qrGeneratorModal = qrGeneratorModalItem;
}
}
}
LazyLoader {
id: polkitAuthModalLoader
active: false
@@ -618,7 +637,7 @@ Item {
if (visible) {
wasShown = true;
} else if (wasShown) {
PopoutService.unloadSettings();
Qt.callLater(() => PopoutService.unloadSettingsNow());
}
}
}
@@ -923,6 +942,7 @@ Item {
expandedWidthValue: 960
edgeGap: SettingsData.notepadEffectiveEdgeGap
slideEdge: SettingsData.notepadSlideoutSide
customTransparency: Theme.notepadTransparency
onIsVisibleChanged: {
if (isVisible)
@@ -998,6 +1018,9 @@ Item {
case "reboot":
SessionService.reboot();
break;
case "softreboot":
SessionService.softReboot();
break;
case "poweroff":
SessionService.poweroff();
break;
+31 -33
View File
@@ -25,32 +25,30 @@ Item {
required property var windowRuleModalLoader
function getPreferredBar(refPropertyName) {
if (!root.dankBarRepeater || root.dankBarRepeater.count === 0)
return null;
const focusedScreenName = BarWidgetService.getFocusedScreenName();
const loaders = Array.from({
length: root.dankBarRepeater.count
}, (_, i) => root.dankBarRepeater.itemAt(i));
const bars = [];
if (root.dankBarRepeater) {
for (let i = 0; i < root.dankBarRepeater.count; i++)
bars.push(...(root.dankBarRepeater.itemAt(i)?.item?.barVariants?.instances || []));
}
const frameBars = BarWidgetService.frameHostedBars;
for (const screenName in frameBars)
bars.push(frameBars[screenName]);
let currentBar = null;
for (const bar of bars) {
if (!bar)
continue;
for (const loader of loaders) {
const instances = loader?.item?.barVariants?.instances || [];
for (const bar of instances) {
if (!bar)
continue;
const onFocusedScreen = focusedScreenName && bar.modelData?.name === focusedScreenName;
const hasRef = !refPropertyName || !!bar[refPropertyName];
const onFocusedScreen = focusedScreenName && bar.modelData?.name === focusedScreenName;
const hasRef = !refPropertyName || !!bar[refPropertyName];
if (hasRef) {
currentBar = bar;
if (hasRef) {
currentBar = bar;
if (onFocusedScreen)
break;
}
if (onFocusedScreen)
break;
}
}
@@ -2025,18 +2023,6 @@ Item {
}
IpcHandler {
function findTrayItem(itemId: string): var {
if (!itemId)
return null;
return SystemTray.items.values.find(item => {
const id = item?.id || "";
const title = item?.tooltipTitle || "";
const fullKey = title ? `${id}::${title}` : id;
return fullKey === itemId || id === itemId;
});
}
function list(): string {
const items = SystemTray.items.values;
if (items.length === 0)
@@ -2052,7 +2038,7 @@ Item {
}
function activate(itemId: string): string {
const item = findTrayItem(itemId);
const item = TrayMenuManager.findTrayItem(itemId);
if (!item)
return `ERROR: Tray item not found: ${itemId}`;
@@ -2060,8 +2046,20 @@ Item {
return `SUCCESS: Activated ${itemId}`;
}
function menu(itemId: string): string {
const item = TrayMenuManager.findTrayItem(itemId);
if (!item)
return `ERROR: Tray item not found: ${itemId}`;
if (!item.hasMenu)
return `ERROR: Tray item has no menu: ${itemId}`;
TrayMenuManager.requestOpenMenu(itemId, BarWidgetService.getFocusedScreenName());
return `SUCCESS: Requested menu ${itemId}`;
}
function status(itemId: string): string {
const item = findTrayItem(itemId);
const item = TrayMenuManager.findTrayItem(itemId);
if (!item)
return `ERROR: Tray item not found: ${itemId}`;
-4
View File
@@ -318,10 +318,6 @@ DankModal {
width: parent.width - Theme.spacingS * 2
anchors.horizontalCenter: parent.horizontalCenter
height: 52
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search"
leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText
@@ -15,7 +15,7 @@ Rectangle {
height: Math.round(Theme.fontSizeMedium * 4.2)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowNestedSurface
Rectangle {
anchors.fill: parent
@@ -4,10 +4,9 @@ import qs.Common
import qs.Services
import qs.Widgets
FloatingWindow {
DankFloatingWindow {
id: root
property bool disablePopupTransparency: true
readonly property int modalWidth: 680
readonly property int modalHeight: screen ? Math.min(720, screen.height - 80) : 720
@@ -21,7 +20,6 @@ FloatingWindow {
title: i18n("What's New")
minimumSize: Qt.size(modalWidth, modalHeight)
maximumSize: Qt.size(modalWidth, modalHeight)
color: Theme.surfaceContainer
visible: false
onClosed: visible = false
@@ -112,7 +110,7 @@ FloatingWindow {
anchors.right: parent.right
anchors.bottom: parent.bottom
height: Math.round(Theme.fontSizeMedium * 4.5)
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowNestedSurface
Rectangle {
anchors.top: parent.top
+19 -11
View File
@@ -819,16 +819,24 @@ Item {
}
if (isCategoryFiltered) {
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 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]));
}
}
} else {
var apps = searchApps(searchQuery);
@@ -839,7 +847,7 @@ Item {
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
var sortAlpha = !searchQuery && SettingsData.sortAppsAlphabetically;
var newSections = Scorer.groupBySection(scoredItems, sectionDefinitions, sortAlpha, searchQuery ? 50 : 500);
var newSections = Scorer.groupBySection(scoredItems, buildDynamicSectionDefs(allItems), sortAlpha, searchQuery ? 50 : 500);
for (var sid in collapsedSections) {
for (var i = 0; i < newSections.length; i++) {
@@ -23,17 +23,6 @@ FocusScope {
property bool editMode: false
property var editingApp: null
property string editAppId: ""
readonly property bool _blurActive: Theme.blurForegroundLayers || Theme.transparentBlurLayers
readonly property real _launcherFieldAlpha: {
if (Theme.transparentBlurLayers)
return 0.28;
if (Theme.blurForegroundLayers)
return Math.max(Theme.popupTransparency, 0.62);
return Theme.popupTransparency;
}
readonly property color _launcherSearchFieldColor: Theme.withAlpha(Theme.surfaceContainerHigh, _launcherFieldAlpha)
readonly property color _launcherSearchBorderColor: Theme.withAlpha(Theme.outline, _blurActive ? 0.16 : Theme.layerOutlineOpacity)
readonly property color _launcherSearchFocusedBorderColor: Theme.withAlpha(Theme.primary, _blurActive ? 0.72 : 1.0)
function resetScroll() {
resultsList.resetScroll();
@@ -314,7 +303,7 @@ FocusScope {
anchors.fill: parent
anchors.topMargin: -Theme.cornerRadius
// In connected mode the launcher provides the surface so update the toolbar for arcs
visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false) && !root._blurActive
visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false) && !Theme.blurLayersActive
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
radius: Theme.cornerRadius
}
@@ -472,12 +461,6 @@ FocusScope {
DankTextField {
id: searchField
width: parent.width - (pluginBadge.visible ? pluginBadge.width + Theme.spacingS : 0)
cornerRadius: Theme.cornerRadius
backgroundColor: root._launcherSearchFieldColor
normalBorderColor: root._launcherSearchBorderColor
focusedBorderColor: root._launcherSearchFocusedBorderColor
borderWidth: 1
focusedBorderWidth: 2
leftIconName: controller.activePluginId ? "extension" : controller.searchQuery.startsWith("/") ? "folder" : "search"
leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText
@@ -7,6 +7,7 @@ Item {
property string source: ""
property int glyphSize: 14
property bool badgeVisible: true
readonly property var sourceAsset: ({
"flatpak": "../../assets/package-sources/flatpak.svg",
@@ -17,7 +18,7 @@ Item {
readonly property string assetPath: sourceAsset[source] || ""
visible: SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0
visible: badgeVisible && SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0
implicitWidth: glyphSize
implicitHeight: glyphSize
@@ -178,7 +178,7 @@ Rectangle {
anchors.margins: Theme.spacingXS
source: root.item?.type === "app" ? (root.item.source || "") : ""
glyphSize: 16
visible: !root.isSelected && !!source
badgeVisible: !root.isSelected
}
}
}
@@ -146,7 +146,7 @@ Item {
width: parent.width
height: keybindsGrid.height + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowNestedSurface
readonly property bool useTwoColumns: width > 500
readonly property int columnCount: useTwoColumns ? 2 : 1
@@ -240,7 +240,7 @@ Item {
width: parent.width
height: noKeybindsColumn.height + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowNestedSurface
visible: !root.hasKeybinds
Column {
@@ -291,7 +291,7 @@ Item {
anchors.leftMargin: Theme.spacingXL
anchors.rightMargin: Theme.spacingXL
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowNestedSurface
clip: true
Column {
@@ -15,7 +15,7 @@ Rectangle {
height: Math.round(Theme.fontSizeMedium * 6.4)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowNestedSurface
Rectangle {
anchors.fill: parent
+3 -5
View File
@@ -5,11 +5,10 @@ import qs.Common
import qs.Services
import qs.Widgets
FloatingWindow {
DankFloatingWindow {
id: root
readonly property var log: Log.scoped("GreeterModal")
property bool disablePopupTransparency: true
property int currentPage: 0
readonly property int totalPages: 3
readonly property var pageComponents: [welcomePage, doctorPage, completePage]
@@ -90,7 +89,6 @@ FloatingWindow {
title: I18n.tr("Welcome", "greeter modal window title")
minimumSize: Qt.size(modalWidth, modalHeight)
maximumSize: Qt.size(modalWidth, modalHeight)
color: Theme.surfaceContainer
visible: false
onClosed: visible = false
@@ -173,7 +171,7 @@ FloatingWindow {
width: pageIndicatorRow.width + Theme.spacingM * 2
height: indicatorHeight
radius: indicatorHeight / 2
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowNestedSurface
Row {
id: pageIndicatorRow
@@ -259,7 +257,7 @@ FloatingWindow {
anchors.right: parent.right
anchors.bottom: parent.bottom
height: Math.round(Theme.fontSizeMedium * 4.5)
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowNestedSurface
Rectangle {
anchors.top: parent.top
@@ -13,7 +13,7 @@ Rectangle {
height: Math.round(Theme.fontSizeMedium * 3.1)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowNestedSurface
Rectangle {
anchors.fill: parent
@@ -15,7 +15,7 @@ Rectangle {
height: Math.round(Theme.fontSizeMedium * 4.5)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowNestedSurface
Rectangle {
anchors.fill: parent
@@ -9,7 +9,7 @@ Rectangle {
property string label: ""
property string iconName: ""
property color iconColor: Theme.surfaceText
property color bgColor: Theme.surfaceContainerHigh
property color bgColor: Theme.floatingWindowNestedSurface
property bool selected: false
signal clicked
+1 -9
View File
@@ -5,10 +5,9 @@ import qs.Modals
import qs.Services
import qs.Widgets
FloatingWindow {
DankFloatingWindow {
id: win
property bool disablePopupTransparency: true
property alias shouldBeVisible: win.visible
signal floatingToggleRequested
@@ -30,7 +29,6 @@ FloatingWindow {
minimumSize: Qt.size(Math.min(560, Screen.width), Math.min(400, Screen.height))
implicitWidth: 1000
implicitHeight: screen ? Math.min(820, screen.height - 100) : 820
color: Theme.surfaceContainer
visible: false
onVisibleChanged: {
@@ -61,12 +59,6 @@ FloatingWindow {
onDoubleClicked: windowControls.tryToggleMaximize()
}
Rectangle {
anchors.fill: parent
color: Theme.surfaceContainer
opacity: 0.5
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingL
-4
View File
@@ -313,10 +313,6 @@ DankModal {
width: parent.width
height: 48
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.surfaceContainerHigh
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search"
leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText
+2 -7
View File
@@ -202,7 +202,7 @@ FocusScope {
StyledText {
text: root.currentFlow?.message ?? ""
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium
color: Theme.surfaceText
width: parent.width
wrapMode: Text.Wrap
maximumLineCount: 2
@@ -272,11 +272,6 @@ FocusScope {
width: parent.width
height: root.inputFieldHeight
backgroundColor: Theme.surfaceHover
normalBorderColor: Theme.outlineStrong
focusedBorderColor: Theme.primary
borderWidth: 1
focusedBorderWidth: 2
leftIconName: root.polkitPamHasFprint ? "fingerprint" : ""
leftIconSize: 20
leftIconColor: Theme.primary
@@ -352,7 +347,7 @@ FocusScope {
anchors.centerIn: parent
text: I18n.tr("Authenticate")
font.pixelSize: Theme.fontSizeMedium
color: Theme.background
color: Theme.primaryText
font.weight: Font.Medium
}
+1 -4
View File
@@ -4,11 +4,9 @@ import qs.Common
import qs.Services
import qs.Widgets
FloatingWindow {
DankFloatingWindow {
id: root
property bool disablePopupTransparency: true
function show() {
if (contentLoader.item)
contentLoader.item.reset();
@@ -29,7 +27,6 @@ FloatingWindow {
title: I18n.tr("Authentication")
minimumSize: Qt.size(460, 220)
maximumSize: Qt.size(460, 220)
color: Theme.surfaceContainer
visible: false
onClosed: hide()
+45 -7
View File
@@ -129,6 +129,13 @@ DankModal {
switchUserRequested();
return;
}
if (action.startsWith("custom:")) {
const button = (SettingsData.customPowerButtons || [])[parseInt(action.slice(7), 10)];
close();
if (button?.command)
Quickshell.execDetached(["sh", "-c", button.command]);
return;
}
close();
root.powerActionRequested(action, "", "");
}
@@ -172,11 +179,14 @@ DankModal {
function updateVisibleActions() {
const allActions = SettingsData.powerMenuActions || ["reboot", "logout", "poweroff", "lock", "suspend", "restart"];
const customButtons = SettingsData.customPowerButtons || [];
visibleActions = allActions.filter(action => {
if (action === "hibernate" && !SessionService.hibernateSupported)
return false;
if (action === "softreboot" && !SessionService.softRebootSupported)
return false;
return true;
});
}).concat(customButtons.map((button, i) => "custom:" + i));
if (!SettingsData.powerMenuGridLayout)
return;
@@ -216,6 +226,14 @@ DankModal {
}
function getActionData(action) {
if (action.startsWith("custom:")) {
const button = (SettingsData.customPowerButtons || [])[parseInt(action.slice(7), 10)];
return {
"icon": button?.icon || "terminal",
"label": button?.label || button?.command || "",
"key": ""
};
}
switch (action) {
case "reboot":
return {
@@ -223,6 +241,12 @@ DankModal {
"label": I18n.tr("Reboot"),
"key": "R"
};
case "softreboot":
return {
"icon": "autorenew",
"label": I18n.tr("Soft Reboot"),
"key": "B"
};
case "logout":
return {
"icon": "logout",
@@ -370,7 +394,7 @@ DankModal {
function handleListNavigation(event, isPressed) {
if (!isPressed) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_B || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
cancelHold();
event.accepted = true;
}
@@ -429,6 +453,12 @@ DankModal {
event.accepted = true;
}
break;
case Qt.Key_B:
if (visibleActions.includes("softreboot")) {
startHold("softreboot", visibleActions.indexOf("softreboot"));
event.accepted = true;
}
break;
case Qt.Key_X:
if (visibleActions.includes("logout")) {
startHold("logout", visibleActions.indexOf("logout"));
@@ -464,7 +494,7 @@ DankModal {
function handleGridNavigation(event, isPressed) {
if (!isPressed) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_B || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
cancelHold();
event.accepted = true;
}
@@ -539,6 +569,12 @@ DankModal {
event.accepted = true;
}
break;
case Qt.Key_B:
if (visibleActions.includes("softreboot")) {
startHold("softreboot", visibleActions.indexOf("softreboot"));
event.accepted = true;
}
break;
case Qt.Key_X:
if (visibleActions.includes("logout")) {
startHold("logout", visibleActions.indexOf("logout"));
@@ -597,7 +633,7 @@ DankModal {
readonly property var actionData: root.getActionData(modelData)
readonly property bool isSelected: root.selectedIndex === index
readonly property bool showWarning: modelData === "reboot" || modelData === "poweroff"
readonly property bool showWarning: modelData === "reboot" || modelData === "softreboot" || modelData === "poweroff"
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
width: (root.modalWidth - Theme.spacingL * 2 - Theme.spacingS * (root.gridColumns - 1)) / root.gridColumns
@@ -627,7 +663,7 @@ DankModal {
color: {
if (gridButtonRect.modelData === "poweroff")
return Theme.errorSelected;
if (gridButtonRect.modelData === "reboot")
if (gridButtonRect.modelData === "reboot" || gridButtonRect.modelData === "softreboot")
return Theme.withAlpha(Theme.warning, 0.3);
return Theme.primarySelected;
}
@@ -668,6 +704,7 @@ DankModal {
height: 16
radius: 4
color: Theme.onSurface_12
visible: gridButtonRect.actionData.key !== ""
anchors.horizontalCenter: parent.horizontalCenter
StyledText {
@@ -722,7 +759,7 @@ DankModal {
readonly property var actionData: root.getActionData(modelData)
readonly property bool isSelected: root.selectedIndex === index
readonly property bool showWarning: modelData === "reboot" || modelData === "poweroff"
readonly property bool showWarning: modelData === "reboot" || modelData === "softreboot" || modelData === "poweroff"
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
width: parent.width
@@ -752,7 +789,7 @@ DankModal {
color: {
if (listButtonRect.modelData === "poweroff")
return Theme.errorSelected;
if (listButtonRect.modelData === "reboot")
if (listButtonRect.modelData === "reboot" || listButtonRect.modelData === "softreboot")
return Theme.withAlpha(Theme.warning, 0.3);
return Theme.primarySelected;
}
@@ -800,6 +837,7 @@ DankModal {
height: 20
radius: 4
color: Theme.onSurface_12
visible: listButtonRect.actionData.key !== ""
anchors {
right: parent.right
rightMargin: Theme.spacingM
+2 -4
View File
@@ -8,11 +8,10 @@ import qs.Services
import qs.Widgets
import "../Common/Format.js" as Format
FloatingWindow {
DankFloatingWindow {
id: processListModal
readonly property var log: Log.scoped("ProcessListModal")
property bool disablePopupTransparency: true
property int currentTab: 0
property string searchText: ""
property string expandedPid: ""
@@ -81,7 +80,6 @@ FloatingWindow {
minimumSize: Qt.size(Math.min(Math.round(Theme.fontSizeMedium * 48), Screen.width), Math.min(Math.round(Theme.fontSizeMedium * 34), Screen.height))
implicitWidth: Math.round(Theme.fontSizeMedium * 71)
implicitHeight: Math.round(Theme.fontSizeMedium * 51)
color: Theme.surfaceContainer
visible: false
onClosed: hide()
@@ -420,7 +418,7 @@ FloatingWindow {
Layout.margins: Theme.spacingL
Layout.topMargin: Theme.spacingM
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
color: Theme.floatingWindowNestedSurface
border.color: Theme.outlineLight
border.width: 1
clip: true
+285
View File
@@ -0,0 +1,285 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell
import Quickshell.Io
import qs.Modals.Common
import qs.Modals.FileBrowser
import qs.Common
import qs.Services
import qs.Widgets
DankModal {
id: root
visible: false
layerNamespace: "dms:qr-generator"
property bool disablePopupTransparency: true
property bool generating: false
property string themedQrCodePath: ""
property string normalQrCodePath: ""
property string initialText: ""
property string _pendingPayload: ""
property string _generatingPayload: ""
property string _displayedPayload: ""
modalWidth: 420
modalHeight: 440
onBackgroundClicked: hide()
onOpened: {
Qt.callLater(() => {
modalFocusScope.forceActiveFocus();
const item = contentLoader.item;
if (!item)
return;
item.saveBrowserLoader = saveBrowserLoader;
if (item.textInput) {
item.textInput.text = initialText;
item.textInput.forceActiveFocus();
}
if (initialText.length > 0) {
_pendingPayload = initialText;
generateQR(initialText);
}
});
}
function show(text) {
generating = false;
initialText = text || "";
_pendingPayload = "";
_generatingPayload = "";
_displayedPayload = "";
themedQrCodePath = "";
normalQrCodePath = "";
open();
}
function hide() {
deleteQrCodeFiles(themedQrCodePath, normalQrCodePath);
themedQrCodePath = "";
normalQrCodePath = "";
close();
}
function deleteQrCodeFiles(themed, normal) {
if (themed.length > 0)
DMSService.sendRequest("network.delete-qrcode", {
path: themed
});
if (normal.length > 0)
DMSService.sendRequest("network.delete-qrcode", {
path: normal
});
}
Timer {
id: genTimer
interval: 200
repeat: false
onTriggered: root.generateQR(root._pendingPayload)
}
function generateQR(text) {
const trimmed = (text || "").trim();
if (trimmed.length === 0 || trimmed === _displayedPayload || generating)
return;
_generatingPayload = trimmed;
generating = true;
DMSService.sendRequest("network.generate-qrcode", {
text: trimmed
}, response => {
root.generating = false;
if (response.error) {
ToastService.showError(I18n.tr("Failed to generate QR code: %1").arg(JSON.stringify(response.error)));
return;
}
if (!response.result)
return;
if (root._generatingPayload !== root._pendingPayload.trim()) {
root.deleteQrCodeFiles(response.result[0], response.result[1]);
genTimer.restart();
return;
}
const oldThemed = root.themedQrCodePath;
const oldNormal = root.normalQrCodePath;
root._displayedPayload = root._generatingPayload;
root.themedQrCodePath = response.result[0];
root.normalQrCodePath = response.result[1];
root.deleteQrCodeFiles(oldThemed, oldNormal);
});
}
function onTextChanged(text) {
_pendingPayload = text;
const trimmed = text.trim();
if (trimmed.length === 0 || trimmed === _displayedPayload) {
genTimer.stop();
return;
}
genTimer.restart();
}
LazyLoader {
id: saveBrowserLoader
active: false
FileBrowserSurfaceModal {
id: saveBrowser
browserTitle: I18n.tr("Save QR Code")
browserIcon: "qr_code"
browserType: "default"
fileExtensions: ["*.png"]
allowStacking: true
saveMode: true
defaultFileName: "qrcode.png"
onFileSelected: path => {
const cleanPath = decodeURI(path.toString().replace(/^file:\/\//, ''));
copyQrCodeProcess.exec(["cp", "-f", root.normalQrCodePath, cleanPath]);
}
Process {
id: copyQrCodeProcess
stdout: StdioCollector {
onStreamFinished: {
saveBrowser.close();
}
}
}
}
}
content: Component {
Item {
id: contentItem
property alias textInput: textInput
property var saveBrowserLoader: null
anchors.fill: parent
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingL
RowLayout {
id: modalTitle
width: parent.width
StyledText {
text: I18n.tr("QR Generator")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Bold
Layout.alignment: Qt.AlignLeft
Layout.fillWidth: true
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: root.hide()
Layout.alignment: Qt.AlignRight
}
}
DankTextField {
id: textInput
width: parent.width
placeholderText: I18n.tr("Enter text to encode")
showClearButton: true
focus: true
onTextEdited: root.onTextChanged(text)
Keys.onEscapePressed: event => {
event.accepted = true;
root.hide();
}
}
Item {
id: qrContainer
height: Math.min(parent.height - parent.spacing - modalTitle.height - textInput.height - parent.spacing * 4, 260)
width: height
anchors.horizontalCenter: parent.horizontalCenter
opacity: 1
Behavior on opacity {
NumberAnimation {
duration: 80
easing.type: Easing.OutCubic
}
}
Image {
id: qrCodeImg
anchors.fill: parent
source: root.themedQrCodePath
fillMode: Image.PreserveAspectFit
asynchronous: true
cache: false
visible: false
onSourceChanged: qrContainer.opacity = 0
onStatusChanged: {
if (status === Image.Ready)
qrContainer.opacity = 1;
}
}
MultiEffect {
source: qrCodeImg
anchors.fill: qrCodeImg
colorization: 1.0
colorizationColor: Theme.primary
}
}
RowLayout {
width: parent.width
visible: root.themedQrCodePath.length > 0
Layout.alignment: Qt.AlignHCenter
Item {
Layout.fillWidth: true
}
DankButton {
text: I18n.tr("Save")
iconName: "save"
backgroundColor: Theme.surfaceContainer
textColor: Theme.surfaceText
onClicked: {
contentItem.saveBrowserLoader.active = true;
if (contentItem.saveBrowserLoader.item) {
contentItem.saveBrowserLoader.item.open();
}
}
}
DankButton {
text: I18n.tr("Copy")
iconName: "content_copy"
backgroundColor: Theme.primary
textColor: Theme.onPrimary
onClicked: {
if (root.normalQrCodePath.length > 0)
DMSService.sendRequest("clipboard.copyFile", {
filePath: root.normalQrCodePath
});
}
}
Item {
Layout.fillWidth: true
}
}
}
}
}
}
@@ -733,5 +733,20 @@ FocusScope {
Qt.callLater(() => item.forceActiveFocus());
}
}
Loader {
id: keyboardLoader
anchors.fill: parent
active: root.currentIndex === 45
visible: active
focus: active
sourceComponent: KeyboardTab {}
onActiveChanged: {
if (active && item)
Qt.callLater(() => item.forceActiveFocus());
}
}
}
}
+2 -11
View File
@@ -5,7 +5,7 @@ import qs.Modals.FileBrowser
import qs.Services
import qs.Widgets
FloatingWindow {
DankFloatingWindow {
id: settingsModal
property var profileBrowser: profileBrowserLoader.item
@@ -95,7 +95,6 @@ FloatingWindow {
minimumSize: Qt.size(500, 400)
implicitWidth: 900
implicitHeight: screen ? Math.min(940, screen.height - 100) : 940
color: Theme.surfaceContainer
visible: false
onClosed: hide()
@@ -180,8 +179,6 @@ FloatingWindow {
FocusScope {
id: contentFocusScope
property bool disablePopupTransparency: true
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
@@ -203,12 +200,6 @@ FloatingWindow {
onDoubleClicked: windowControls.tryToggleMaximize()
}
Rectangle {
anchors.fill: parent
color: Theme.surfaceContainer
opacity: 0.5
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingL
@@ -276,7 +267,7 @@ FloatingWindow {
width: parent.width
height: showBanner ? bannerContent.implicitHeight + Theme.spacingM * 2 : 0
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowNestedSurface
visible: showBanner
clip: true
@@ -326,6 +326,13 @@ Rectangle {
"tabIndex": 44,
"niriOnly": true
},
{
"id": "keyboard",
"text": I18n.tr("Keyboard"),
"icon": "keyboard",
"tabIndex": 45,
"niriOnly": true
},
{
"id": "locale",
"text": I18n.tr("Locale"),
@@ -641,7 +648,7 @@ Rectangle {
implicitWidth: __calculatedWidth
width: __calculatedWidth
height: parent.height
color: Theme.surfaceContainer
color: "transparent"
radius: Theme.cornerRadius
Component.onCompleted: {
@@ -742,12 +749,9 @@ Rectangle {
id: searchField
width: parent.width - parent.leftPadding - parent.rightPadding
placeholderText: I18n.tr("Search...")
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search"
leftIconSize: Theme.iconSize - 4
showClearButton: text.length > 0
usePopupTransparency: false
onTextChanged: {
SettingsSearchService.search(text);
root.searchSelectedIndex = 0;
+83 -3
View File
@@ -31,6 +31,7 @@ DankModal {
property string promptToken: ""
property string promptReason: ""
property var promptFields: []
property var promptHints: []
property string promptSetting: ""
property bool isVpnPrompt: false
@@ -40,17 +41,21 @@ DankModal {
property var fieldsInfo: []
property var secretValues: ({})
readonly property bool isCertificateChangedPrompt: promptReason === "server-certificate-changed"
readonly property bool isCertificatePrompt: promptReason === "server-certificate" || isCertificateChangedPrompt
readonly property string serverCertificateFingerprint: promptHints.length > 0 ? promptHints[0] : ""
readonly property bool showUsernameField: requiresEnterprise && !isVpnPrompt && fieldsInfo.length === 0
readonly property bool showPasswordField: fieldsInfo.length === 0
readonly property bool showPasswordField: fieldsInfo.length === 0 && !isCertificatePrompt
readonly property bool showAnonField: requiresEnterprise && !isVpnPrompt
readonly property bool showDomainField: requiresEnterprise && !isVpnPrompt
readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11"
readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11" && !isCertificatePrompt
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
readonly property int inputFieldWithSpacing: inputFieldHeight + Theme.spacingM
readonly property int checkboxRowHeight: Theme.fontSizeMedium + Theme.spacingS
readonly property int headerHeight: Theme.fontSizeLarge + Theme.fontSizeMedium + Theme.spacingM * 2
readonly property int buttonRowHeight: 36 + Theme.spacingM
readonly property int certificateWarningHeight: certificateWarningColumn.implicitHeight + Theme.spacingM * 2
property int calculatedHeight: {
let h = headerHeight + buttonRowHeight + Theme.spacingL * 2;
@@ -67,10 +72,16 @@ DankModal {
h += inputFieldWithSpacing;
if (showSavePasswordCheckbox)
h += checkboxRowHeight;
if (isCertificatePrompt)
h += certificateWarningHeight + Theme.spacingM;
return h;
}
function focusFirstField() {
if (isCertificatePrompt) {
connectButton.forceActiveFocus();
return;
}
if (fieldsInfo.length > 0) {
if (dynamicFieldsRepeater.count > 0) {
const firstItem = dynamicFieldsRepeater.itemAt(0);
@@ -101,6 +112,7 @@ DankModal {
promptToken = "";
promptReason = "";
promptFields = [];
promptHints = [];
promptSetting = "";
isVpnPrompt = false;
connectionName = "";
@@ -127,6 +139,7 @@ DankModal {
promptToken = "";
promptReason = "";
promptFields = [];
promptHints = [];
promptSetting = "";
isVpnPrompt = false;
connectionName = "";
@@ -145,6 +158,7 @@ DankModal {
promptToken = token;
promptReason = reason;
promptFields = fields || [];
promptHints = hints || [];
promptSetting = setting || "802-11-wireless-security";
connectionType = connType || "802-11-wireless";
connectionName = connName || ssid || "";
@@ -324,6 +338,8 @@ DankModal {
text: {
if (promptReason === "pkcs11")
return I18n.tr("Smartcard Authentication");
if (isCertificatePrompt)
return I18n.tr("Untrusted VPN certificate", "Title for VPN server certificate trust confirmation");
if (isVpnPrompt)
return I18n.tr("Connect to VPN");
if (isHiddenNetwork)
@@ -343,6 +359,8 @@ DankModal {
text: {
if (promptReason === "pkcs11")
return I18n.tr("Enter PIN for ") + wifiPasswordSSID;
if (isCertificatePrompt)
return wifiPasswordSSID;
if (fieldsInfo.length > 0)
return I18n.tr("Enter credentials for ") + wifiPasswordSSID;
if (isVpnPrompt)
@@ -383,6 +401,45 @@ DankModal {
}
}
Rectangle {
id: certificateWarningBox
readonly property color warningTone: isCertificateChangedPrompt ? Theme.error : Theme.warning
width: parent.width
height: certificateWarningHeight
radius: Theme.cornerRadius
color: Theme.withAlpha(warningTone, 0.12)
border.color: Theme.withAlpha(warningTone, 0.5)
border.width: 1
visible: isCertificatePrompt
Column {
id: certificateWarningColumn
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
StyledText {
width: parent.width
text: isCertificateChangedPrompt ? I18n.tr("The server certificate has changed since it was last trusted. Only continue if you recognize the new fingerprint.", "Warning shown when a trusted VPN server certificate no longer matches") : I18n.tr("Only continue if you recognize this server certificate fingerprint.", "Warning shown before trusting an unverified VPN server certificate")
wrapMode: Text.Wrap
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
}
StyledText {
width: parent.width
text: serverCertificateFingerprint
wrapMode: Text.WrapAnywhere
font.family: SettingsData.monoFontFamily
font.pixelSize: Theme.fontSizeSmall
color: certificateWarningBox.warningTone
}
}
}
Rectangle {
width: parent.width
height: inputFieldHeight
@@ -690,10 +747,15 @@ DankModal {
}
Rectangle {
id: connectButton
width: Math.max(80, connectText.contentWidth + Theme.spacingM * 2)
height: 36
radius: Theme.cornerRadius
color: connectArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
border.color: activeFocus ? Theme.surfaceText : "transparent"
border.width: activeFocus ? 2 : 0
activeFocusOnTab: true
enabled: {
if (fieldsInfo.length > 0) {
for (var i = 0; i < fieldsInfo.length; i++) {
@@ -705,6 +767,8 @@ DankModal {
}
return true;
}
if (isCertificatePrompt)
return serverCertificateFingerprint.length > 0;
if (isVpnPrompt)
return passwordInput.text.length > 0;
if (isHiddenNetwork)
@@ -716,7 +780,7 @@ DankModal {
StyledText {
id: connectText
anchors.centerIn: parent
text: I18n.tr("Connect")
text: isCertificatePrompt ? I18n.tr("Trust", "Button that approves a VPN server certificate fingerprint") : I18n.tr("Connect")
font.pixelSize: Theme.fontSizeMedium
color: Theme.background
font.weight: Font.Medium
@@ -731,6 +795,22 @@ DankModal {
onClicked: submitCredentialsAndClose()
}
Keys.onReturnPressed: event => {
if (enabled)
submitCredentialsAndClose();
event.accepted = true;
}
Keys.onEnterPressed: event => {
if (enabled)
submitCredentialsAndClose();
event.accepted = true;
}
Keys.onSpacePressed: event => {
if (enabled)
submitCredentialsAndClose();
event.accepted = true;
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
+1 -3
View File
@@ -4,10 +4,9 @@ import qs.Common
import qs.Services
import qs.Widgets
FloatingWindow {
DankFloatingWindow {
id: root
property bool disablePopupTransparency: true
property var editingRule: null
property bool isEditMode: editingRule !== null
property bool isNiri: CompositorService.isNiri
@@ -29,7 +28,6 @@ FloatingWindow {
title: isEditMode ? I18n.tr("Edit Window Rule") : I18n.tr("Create Window Rule")
minimumSize: Qt.size(500, 600)
maximumSize: Qt.size(500, 600)
color: Theme.surfaceContainer
visible: false
onClosed: hide()
+1 -3
View File
@@ -4,18 +4,16 @@ import qs.Common
import qs.Services
import qs.Widgets
FloatingWindow {
DankFloatingWindow {
id: root
readonly property var log: Log.scoped("WorkspaceRenameModal")
property bool disablePopupTransparency: true
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
objectName: "workspaceRenameModal"
title: I18n.tr("Rename Workspace")
minimumSize: Qt.size(400, 160)
maximumSize: Qt.size(400, 160)
color: Theme.surfaceContainer
visible: false
onClosed: hide()
@@ -469,6 +469,8 @@ PluginComponent {
}
MouseArea {
id: peerMouseArea
z: -1
anchors.fill: parent
hoverEnabled: true
@@ -41,7 +41,11 @@ Rectangle {
property bool hasWifiAvailable: (NetworkService.wifiDevices?.length ?? 0) > 0
property bool hasBothConnectionTypes: hasEthernetAvailable && hasWifiAvailable
property int maxPinnedNetworks: 3
readonly property int hotspotContentHeight: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable ? 56 + Theme.spacingS : 0
// Hosting on the only wifi adapter with no ethernet uplink just drops connectivity,
// so the hotspot row only shows where sharing can actually work (or is already on).
readonly property bool hotspotRelevant: NetworkService.hotspotEnabled || NetworkService.hotspotActivating || NetworkService.hotspotBusy || NetworkService.ethernetConnected || (NetworkService.wifiDevices?.length ?? 0) > 1
readonly property bool showHotspotRow: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable && hotspotRelevant
readonly property int hotspotContentHeight: showHotspotRow ? 56 + Theme.spacingS : 0
property var hotspotStartConfirm: ConfirmModal {}
@@ -184,7 +188,7 @@ Rectangle {
anchors.right: parent.right
anchors.margins: Theme.spacingM
anchors.topMargin: Theme.spacingM
visible: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable
visible: root.showHotspotRow
height: visible ? 56 : 0
Rectangle {
+10 -1
View File
@@ -201,7 +201,16 @@ Item {
direction: root.effectiveShadowDirection
fallbackOffset: 4
targetRadius: root.rt
targetColor: barWindow._bgColor
// wing-side body corners are square where the gothic fillets attach;
// rounding the shadow there leaves a shadow-filled notch under the
// translucent bar fill (#2975)
topLeftRadius: root.gothEnabled && (root.isBottom || root.isRight) ? 0 : root.rt
topRightRadius: root.gothEnabled && (root.isBottom || root.isLeft) ? 0 : root.rt
bottomLeftRadius: root.gothEnabled && (root.isTop || root.isRight) ? 0 : root.rt
bottomRightRadius: root.gothEnabled && (root.isTop || root.isLeft) ? 0 : root.rt
// barShape below is the sole painter of the bar; a fill here doubles
// the alpha of translucent bars while the wings stay single-painted
targetColor: "transparent"
shadowBlurPx: root.shadowBlurPx
shadowOffsetX: root.shadowOffsetX
@@ -609,7 +609,7 @@ Item {
_closeHoverNotepad();
activeHoverTrigger = "";
PopoutManager.dismissHoverPopoutForScreen(barWindow?.screen);
TrayMenuManager.closeAllMenus();
TrayMenuManager.closeHoverMenus();
}
function _beginSupersededCloseForActive() {
@@ -83,6 +83,22 @@ BasePill {
root.showForTrayItem(trayItem, anchorItem, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
}
Connections {
target: TrayMenuManager
function onOpenTrayMenuRequested() {
const request = TrayMenuManager.claimMenuRequest(root.parentScreen?.name);
if (!request)
return;
const item = TrayMenuManager.findTrayItem(request.itemId);
if (!item || !item.hasMenu)
return;
root.showForTrayItem(item, root, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
}
}
function openInlineTrayContextMenu(trayItem, areaItem, mouse, anchorItem) {
if (!trayItem) {
return;
@@ -1484,6 +1500,7 @@ BasePill {
property bool isVertical: false
property var axis: null
property bool showMenu: false
property bool openedByHover: false
property var menuHandle: null
ListModel {
@@ -1493,7 +1510,8 @@ BasePill {
return entryStack.count ? entryStack.get(entryStack.count - 1).handle : null;
}
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) {
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj, byHover) {
openedByHover = byHover === true;
trayItem = item;
anchorItem = anchor;
parentScreen = screen;
@@ -2084,7 +2102,7 @@ BasePill {
}
}
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) {
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj, byHover) {
if (!screen)
return;
if (currentTrayMenu) {
@@ -2099,7 +2117,7 @@ BasePill {
currentTrayMenu = trayMenuComponent.createObject(null);
if (!currentTrayMenu)
return;
currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj);
currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj, byHover === true);
}
function _trayLayoutRoot() {
@@ -2147,7 +2165,7 @@ BasePill {
if (!hit?.trayItem?.hasMenu)
return false;
const anchor = hit.children?.length > 0 ? hit.children[0] : hit;
showForTrayItem(hit.trayItem, anchor, parentScreen, isAtBottom, isVerticalOrientation, axis);
showForTrayItem(hit.trayItem, anchor, parentScreen, isAtBottom, isVerticalOrientation, axis, true);
return true;
}
}
@@ -493,7 +493,7 @@ Item {
}
StyledText {
text: activePlayer?.trackAlbum || ""
text: MprisController.stableAlbum
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextSecondary
width: parent.width
@@ -0,0 +1,209 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
Card {
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
readonly property var device: DankConnectService.primaryDevice
readonly property var pairingRequest: DankConnectService.pairingRequest
readonly property bool hasBattery: !!device && typeof device.batteryCharge === "number" && device.batteryCharge >= 0
function statusText() {
if (!DankConnectService.connected)
return I18n.tr("Disconnected");
if (!device)
return I18n.tr("No devices");
const connection = device.isReachable ? I18n.tr("Connected") : I18n.tr("Disconnected");
const pairing = device.isPaired ? I18n.tr("Paired") : I18n.tr("Not paired");
return connection + " • " + pairing;
}
function batteryText() {
if (!hasBattery)
return "";
const charge = Math.round(device.batteryCharge) + "%";
return device.batteryCharging ? charge + " • " + I18n.tr("Charging") : charge;
}
function reportError(title, response) {
if (response?.error)
ToastService.showError(title, response.error);
}
Row {
id: contentRow
anchors.fill: parent
spacing: Theme.spacingL
Item {
width: Math.max(180, contentRow.width - actionRow.width - trailingPanel.width - contentRow.spacing * 2)
height: parent.height
Row {
anchors.fill: parent
spacing: Theme.spacingM
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: "smartphone"
size: 36
color: root.device?.isReachable ? Theme.primary : Theme.surfaceTextSecondary
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 36 - parent.spacing
spacing: Theme.spacingXS
StyledText {
width: parent.width
text: root.device?.name || "DankConnect"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
elide: Text.ElideRight
maximumLineCount: 1
}
StyledText {
width: parent.width
text: root.statusText()
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
elide: Text.ElideRight
maximumLineCount: 1
}
StyledText {
width: parent.width
visible: root.hasBattery
text: root.batteryText()
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
elide: Text.ElideRight
maximumLineCount: 1
}
}
}
}
Row {
id: actionRow
anchors.verticalCenter: parent.verticalCenter
width: implicitWidth
spacing: Theme.spacingXS
DankActionButton {
buttonSize: 34
iconSize: 17
iconName: "ring_volume"
iconColor: Theme.primary
tooltipText: I18n.tr("Ring")
enabled: DankConnectService.canRing(root.device)
onClicked: DankConnectService.ringDevice(root.device.id, response => root.reportError(I18n.tr("Ring"), response))
}
DankActionButton {
buttonSize: 34
iconSize: 17
iconName: "network_ping"
iconColor: Theme.primary
tooltipText: I18n.tr("Ping")
enabled: DankConnectService.canPing(root.device)
onClicked: DankConnectService.pingDevice(root.device.id, response => root.reportError(I18n.tr("Ping"), response))
}
DankActionButton {
buttonSize: 34
iconSize: 17
iconName: "content_paste_go"
iconColor: Theme.primary
tooltipText: I18n.tr("Send Clipboard")
enabled: DankConnectService.canSendClipboard(root.device)
onClicked: DankConnectService.sendClipboard(root.device.id, response => root.reportError(I18n.tr("Send Clipboard"), response))
}
DankActionButton {
buttonSize: 34
iconSize: 17
iconName: "open_in_new"
iconColor: Theme.primary
tooltipText: I18n.tr("Open App")
onClicked: DankConnectService.openApp()
}
}
Item {
id: trailingPanel
width: root.pairingRequest ? 270 : 52
height: parent.height
DankIcon {
anchors.centerIn: parent
visible: !root.pairingRequest && root.hasBattery
name: root.hasBattery ? Theme.getBatteryIcon(root.device.batteryCharge, root.device.batteryCharging, true) : "battery_std"
size: 28
color: Theme.primary
}
Column {
anchors.centerIn: parent
width: parent.width
spacing: Theme.spacingXS
visible: root.pairingRequest !== null
StyledText {
width: parent.width
text: I18n.tr("Pairing request from %1").arg(root.pairingRequest?.name || I18n.tr("Device"))
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
elide: Text.ElideRight
maximumLineCount: 1
}
StyledText {
width: parent.width
text: root.pairingRequest?.verificationKey || I18n.tr("Unknown")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Bold
color: Theme.primary
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
maximumLineCount: 1
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingS
DankButton {
text: I18n.tr("Reject")
buttonHeight: 30
horizontalPadding: Theme.spacingM
onClicked: DankConnectService.rejectPairing(root.pairingRequest.deviceId, response => root.reportError(I18n.tr("Failed to reject pairing"), response))
}
DankButton {
text: I18n.tr("Accept")
buttonHeight: 30
horizontalPadding: Theme.spacingM
backgroundColor: Theme.primary
textColor: Theme.primaryText
onClicked: DankConnectService.acceptPairing(root.pairingRequest.deviceId, response => root.reportError(I18n.tr("Failed to accept pairing"), response))
}
}
}
}
}
}
+11 -1
View File
@@ -9,8 +9,11 @@ Item {
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
readonly property int overviewGridHeight: 410
readonly property int dankConnectCardHeight: 124
implicitWidth: SettingsData.showWeekNumber ? 736 : 700
implicitHeight: 410
implicitHeight: overviewGridHeight + Theme.spacingM + dankConnectCardHeight
signal switchToWeatherTab
signal switchToMediaTab
@@ -89,5 +92,12 @@ Item {
onClicked: root.switchToMediaTab()
}
DankConnectOverviewCard {
x: 0
y: root.overviewGridHeight + Theme.spacingM
width: parent.width
height: root.dankConnectCardHeight
}
}
}
+9 -1
View File
@@ -66,9 +66,17 @@ Scope {
IdleService.lockPowerOffRequested = false;
}
// Avoid startup lock when using dms-greeter (#2952)
function freshGreeterLogin() {
const authTime = Number(Quickshell.env("DMS_GREETER_AUTH_TIME") || 0);
if (!authTime)
return false;
return (Date.now() / 1000 - authTime) < 120;
}
Component.onCompleted: {
IdleService.lockComponent = this;
if (SettingsData.lockAtStartup)
if (SettingsData.lockAtStartup && !freshGreeterLogin())
lock();
}
+55 -5
View File
@@ -14,6 +14,7 @@ import qs.Services
import qs.Widgets
import qs.DankCommon.Session
import "../../DankCommon/Common/LayoutCodes.js" as LayoutCodes
import "../../Common/KeyUtils.js" as KeyUtils
Item {
id: root
@@ -98,6 +99,18 @@ Item {
return !demoMode && pam && pam.u2f && pam.u2f.available && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !pam.passwd.active && !pam.u2f.active && !pam.u2fPending && !root.unlocking;
}
function triggerSecurityKeyUnlock() {
if (!canStartSecurityKeyUnlock())
return;
passwordField.clear();
pam.u2f.startForAlternativeAuth();
}
function securityKeyShortcutMatches(event) {
return SettingsData.lockScreenSecurityKeyShortcutEnabled
&& KeyUtils.eventMatchesCombo(event, SettingsData.lockScreenSecurityKeyShortcut);
}
Component.onCompleted: {
WeatherService.addRef();
UserInfoService.getUserInfo();
@@ -932,7 +945,9 @@ Item {
pam.passwd.start();
}
}
Keys.onPressed: event => {
Keys.onPressed: event => handleKey(event)
function handleKey(event) {
if (demoMode) {
return;
}
@@ -960,6 +975,12 @@ Item {
}
if ((event.modifiers & Qt.ControlModifier) && !(event.modifiers & (Qt.AltModifier | Qt.MetaModifier))) {
if (securityKeyShortcutMatches(event) && canStartSecurityKeyUnlock()) {
triggerSecurityKeyUnlock();
event.accepted = true;
return;
}
switch (event.key) {
case Qt.Key_A:
cursorPosition = 0;
@@ -1042,6 +1063,36 @@ Item {
}
}
// Wayland IMEs commit unconsumed printable keys as text-input text
// (ibus ibuswaylandim.c) instead of forwarding raw keys, so an active
// text input must exist to receive them; the hidden-text hints put
// fcitx5 into plain keyboard passthrough (CapabilityFlag::Password).
// Raw keys stay in handleKey (#2950).
TextInput {
id: imeCommitSink
focus: true
width: 1
height: 1
opacity: 0
echoMode: TextInput.Password
inputMethodHints: Qt.ImhHiddenText | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase
Keys.onPressed: event => {
passwordField.handleKey(event);
if (!event.accepted && (event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier)))
event.accepted = true;
}
onTextChanged: {
if (text.length === 0)
return;
const committed = text;
text = "";
if (demoMode || root.unlocking || pam.passwd.active)
return;
passwordField.insertText(committed);
}
}
Component.onCompleted: {
if (!demoMode) {
forceActiveFocus();
@@ -1206,10 +1257,8 @@ Item {
buttonSize: 32
visible: root.canStartSecurityKeyUnlock()
enabled: visible
onClicked: {
passwordField.clear();
pam.u2f.startForAlternativeAuth();
}
tooltipText: SettingsData.lockScreenSecurityKeyShortcutEnabled ? I18n.tr("Security key (%1)", "lock screen security key button tooltip with shortcut").arg(SettingsData.lockScreenSecurityKeyShortcut) : I18n.tr("Security key", "lock screen security key button tooltip")
onClicked: root.triggerSecurityKeyUnlock()
}
DankActionButton {
id: virtualKeyboardButton
@@ -1297,6 +1346,7 @@ Item {
width: parent.width
height: parent.height / 2
anchors.top: parent.top
anchors.topMargin: -1
anchors.horizontalCenter: parent.horizontalCenter
color: Theme.withAlpha(Theme.surfaceContainer, 0.9)
}
@@ -5,7 +5,7 @@ import qs.Services
import qs.Widgets
import qs.Modules.Notepad
FloatingWindow {
DankFloatingWindow {
id: win
property alias shouldBeVisible: win.visible
@@ -27,7 +27,7 @@ FloatingWindow {
minimumSize: Qt.size(360, 320)
implicitWidth: 640
implicitHeight: 760
color: Theme.surfaceContainer
surfaceColor: Theme.notepadWindowSurface
visible: false
onVisibleChanged: {
@@ -38,7 +38,6 @@ FloatingWindow {
}
}
// A compositor close (e.g. niri close-window)
onClosed: win.visible = false
Item {
@@ -58,12 +57,6 @@ FloatingWindow {
onDoubleClicked: windowControls.tryToggleMaximize()
}
Rectangle {
anchors.fill: parent
color: Theme.surfaceContainerHigh
opacity: 0.5
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
@@ -353,7 +353,7 @@ Item {
anchors.leftMargin: -Theme.spacingM
width: parent.width + Theme.spacingM
text: I18n.tr("Surface Opacity")
description: I18n.tr("Override global transparency for Notepad")
description: I18n.tr("Override floating window transparency for Notepad")
checked: SettingsData.notepadTransparencyOverride >= 0
onToggled: checked => {
if (checked) {
@@ -370,7 +370,7 @@ Item {
width: parent.width + Theme.spacingM
height: 24
visible: SettingsData.notepadTransparencyOverride >= 0
value: Math.round((SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : SettingsData.popupTransparency) * 100)
value: Math.round(Theme.notepadTransparency * 100)
minimum: 0
maximum: 100
unit: ""
@@ -364,7 +364,7 @@ Column {
height: 48
visible: searchVisible
opacity: searchVisible ? 1 : 0
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
color: Theme.floatingWindowNestedSurface
border.color: searchField.activeFocus ? Theme.primary : Theme.outlineMedium
border.width: searchField.activeFocus ? 2 : 1
radius: Theme.cornerRadius
@@ -941,7 +941,7 @@ Column {
width: Math.min(root.width, 360)
height: pathInfoRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
color: Theme.floatingWindowNestedSurface
border.color: Theme.outlineMedium
border.width: 1
z: 10
+89 -23
View File
@@ -156,9 +156,10 @@ DankOSD {
if (MprisController.isFirefoxYoutubeHoverPreview(player))
return;
const newTitle = player.trackTitle || "";
const newArtist = player.trackArtist || "";
const newAlbum = player.trackAlbum || "";
const metaPlayer = MprisController.bestMetadataPlayer(player);
const newTitle = MprisController.displayTrackTitle(metaPlayer);
const newArtist = metaPlayer.trackArtist || "";
const newAlbum = metaPlayer.trackAlbum || "";
const trackChanged = newTitle !== root._displayTitle || newArtist !== root._displayArtist || newAlbum !== root._displayAlbum;
root._displayTitle = newTitle;
@@ -263,37 +264,102 @@ DankOSD {
}
}
Rectangle {
width: Theme.iconSize
height: Theme.iconSize
radius: Theme.iconSize / 2
color: "transparent"
Row {
id: transportControls
x: parent.gap
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXXS
DankIcon {
anchors.centerIn: parent
name: root._displayIcon
size: Theme.iconSize
color: playPauseButton.containsMouse ? Theme.primary : Theme.surfaceText
Rectangle {
width: Theme.iconSize - 4
height: Theme.iconSize - 4
radius: (Theme.iconSize - 4) / 2
anchors.verticalCenter: parent.verticalCenter
color: prevButton.containsMouse ? Theme.surfaceTextHover : "transparent"
opacity: (root.player?.canGoPrevious ?? false) ? 1 : 0.3
DankIcon {
anchors.centerIn: parent
name: "skip_previous"
size: Theme.iconSize - 10
color: prevButton.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: prevButton
anchors.fill: parent
hoverEnabled: true
enabled: root.player?.canGoPrevious ?? false
cursorShape: Qt.PointingHandCursor
onClicked: {
MprisController.previousOrRewind();
root.hide();
}
}
}
MouseArea {
id: playPauseButton
Rectangle {
width: Theme.iconSize
height: Theme.iconSize
radius: Theme.iconSize / 2
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
togglePlaying();
root.hide();
DankIcon {
anchors.centerIn: parent
name: root._displayIcon
size: Theme.iconSize
color: playPauseButton.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: playPauseButton
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
togglePlaying();
root.hide();
}
}
}
Rectangle {
width: Theme.iconSize - 4
height: Theme.iconSize - 4
radius: (Theme.iconSize - 4) / 2
anchors.verticalCenter: parent.verticalCenter
color: nextButton.containsMouse ? Theme.surfaceTextHover : "transparent"
opacity: (root.player?.canGoNext ?? false) ? 1 : 0.3
DankIcon {
anchors.centerIn: parent
name: "skip_next"
size: Theme.iconSize - 10
color: nextButton.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: nextButton
anchors.fill: parent
hoverEnabled: true
enabled: root.player?.canGoNext ?? false
cursorShape: Qt.PointingHandCursor
onClicked: {
MprisController.next();
root.hide();
}
}
}
}
Column {
x: parent.gap * 2 + Theme.iconSize
width: parent.width - Theme.iconSize - parent.gap * 3
x: parent.gap * 2 + transportControls.width
width: parent.width - transportControls.width - parent.gap * 3
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXXS
@@ -29,6 +29,28 @@ Item {
readonly property bool clickThrough: instanceData?.config?.clickThrough ?? false
readonly property bool syncPositionAcrossScreens: instanceData?.config?.syncPositionAcrossScreens ?? false
// Unmapping with the widget still in the last buffer leaves a stale image in
// Hyprland's blur cache behind transparent tiled windows (#2955), so hide the
// content, present a transparent frame, then unmap.
readonly property bool contentShowing: widgetEnabled && activeComponent !== null && (!showOnOverviewOnly || overviewActive)
property bool surfaceLingering: false
onContentShowingChanged: {
if (contentShowing) {
lingerTimer.stop();
surfaceLingering = false;
return;
}
surfaceLingering = true;
lingerTimer.restart();
}
Timer {
id: lingerTimer
interval: 64
onTriggered: root.surfaceLingering = false
}
Connections {
target: PluginService
enabled: !root.isBuiltin
@@ -273,13 +295,7 @@ Item {
PanelWindow {
id: widgetWindow
screen: root.screen
visible: {
if (!root.widgetEnabled || root.activeComponent === null)
return false;
if (root.showOnOverviewOnly)
return root.overviewActive;
return true;
}
visible: root.contentShowing || root.surfaceLingering
color: "transparent"
Region {
@@ -358,6 +374,7 @@ Item {
id: contentLoader
anchors.fill: parent
active: root.widgetEnabled && root.activeComponent !== null
visible: root.contentShowing
sourceComponent: root.activeComponent
opacity: 0
@@ -118,7 +118,9 @@ Column {
width: parent.width
height: 56
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
color: Theme.floatingWindowFieldColor
border.color: Theme.floatingWindowFieldBorderColor
border.width: 1
Row {
anchors.fill: parent
@@ -6,10 +6,9 @@ import qs.Common
import qs.Services
import qs.Widgets
FloatingWindow {
DankFloatingWindow {
id: root
property bool disablePopupTransparency: true
property string searchQuery: ""
property var filteredWidgets: []
property int selectedIndex: -1
@@ -107,7 +106,6 @@ FloatingWindow {
minimumSize: Qt.size(400, 350)
implicitWidth: 500
implicitHeight: 550
color: Theme.surfaceContainer
visible: false
onClosed: hide()
@@ -276,10 +274,6 @@ FloatingWindow {
id: searchField
width: parent.width
height: 48
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.surfaceContainerHigh
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search"
leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText
@@ -1111,15 +1111,50 @@ Singleton {
}
}
function extractNiriOutputBlocks(content) {
const blocks = [];
const headerRegex = /output\s+"([^"]+)"\s*\{/g;
let match;
while ((match = headerRegex.exec(content)) !== null) {
const start = headerRegex.lastIndex;
let depth = 1;
let i = start;
while (i < content.length && depth > 0) {
const ch = content[i];
if (ch === '{')
depth++;
else if (ch === '}')
depth--;
i++;
}
blocks.push({
"name": match[1],
"body": content.slice(start, i - 1)
});
headerRegex.lastIndex = i;
}
return blocks;
}
function stripNestedBlocks(body) {
let stripped = body;
let prev;
do {
prev = stripped;
stripped = stripped.replace(/[\w-]+\s*\{[^{}]*\}/g, "");
} while (stripped !== prev)
return stripped;
}
function parseNiriOutputs(content) {
const result = {};
const outputRegex = /output\s+"([^"]+)"\s*\{([^}]*)\}/g;
let match;
while ((match = outputRegex.exec(content)) !== null) {
const name = match[1];
const body = match[2];
for (const block of extractNiriOutputBlocks(content)) {
const name = block.name;
const body = block.body;
const disabled = /^\s*off\s*$/m.test(body);
// off marks the output disabled only at the top level of its block;
// nested sections like hot-corners { off } must not count (#2966)
const disabled = /^\s*off\s*$/m.test(stripNestedBlocks(body));
const modeMatch = body.match(/mode\s+"(\d+)x(\d+)@([\d.]+)"/);
const posMatch = body.match(/position\s+x=(-?\d+)\s+y=(-?\d+)/);
const scaleMatch = body.match(/scale\s+([\d.]+)/);
+1 -1
View File
@@ -869,7 +869,7 @@ Item {
readonly property var tooltipTexts: ({
"dms": I18n.tr("DMS shell actions (launcher, clipboard, etc.)"),
"compositor": I18n.tr("Niri compositor actions (focus, move, etc.)"),
"compositor": I18n.tr("Compositor actions (focus, move, etc.)", "keybind action type tooltip"),
"spawn": I18n.tr("Run a program (e.g., firefox, kitty)"),
"shell": I18n.tr("Run a shell command (e.g., notify-send)")
})
+267
View File
@@ -0,0 +1,267 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
Item {
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
DankFlickable {
anchors.fill: parent
clip: true
contentHeight: settingsColumn.height + Theme.spacingXL
contentWidth: width
Column {
id: settingsColumn
topPadding: 4
width: Math.min(550, parent.width - Theme.spacingL * 2)
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
NiriInputSetupBanner {
width: parent.width
}
SettingsCard {
width: parent.width
tags: ["keyboard", "layout", "language", "input", "xkb"]
title: I18n.tr("Keyboard Layouts")
settingKey: "keyboardLayoutsSettings"
iconName: "keyboard"
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
StyledText {
width: parent.width
text: I18n.tr("Keyboard Layouts")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
width: parent.width
text: I18n.tr("Comma-separated list of layout names. Leave empty to use the system keyboard settings.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
DankTextField {
width: parent.width
text: SettingsData.keyboardLayouts
placeholderText: "us,de"
onTextEdited: SettingsData.set("keyboardLayouts", text)
}
}
SettingsDropdownRow {
tags: ["keyboard", "layout", "switch", "shortcut", "xkb"]
settingKey: "keyboardOptions"
text: I18n.tr("Switch Layout Shortcut")
description: I18n.tr("Choose a shortcut key to cycle between keyboard layouts")
options: [I18n.tr("Alt + Shift"), I18n.tr("Ctrl + Shift"), I18n.tr("Caps Lock"), I18n.tr("Super + Space"), I18n.tr("Custom / None")]
currentValue: {
const opt = SettingsData.keyboardOptions;
if (opt.includes("grp:alt_shift_toggle"))
return options[0];
if (opt.includes("grp:ctrl_shift_toggle"))
return options[1];
if (opt.includes("grp:caps_toggle"))
return options[2];
if (opt.includes("grp:win_space_toggle"))
return options[3];
return options[4];
}
onValueChanged: value => {
const idx = options.indexOf(value);
let opt = SettingsData.keyboardOptions.split(",").filter(o => !o.startsWith("grp:")).join(",");
let newGrp = "";
switch (idx) {
case 0:
newGrp = "grp:alt_shift_toggle";
break;
case 1:
newGrp = "grp:ctrl_shift_toggle";
break;
case 2:
newGrp = "grp:caps_toggle";
break;
case 3:
newGrp = "grp:win_space_toggle";
break;
}
if (newGrp)
opt = opt ? opt + "," + newGrp : newGrp;
SettingsData.set("keyboardOptions", opt);
}
}
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
StyledText {
width: parent.width
text: I18n.tr("XKB Options")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
width: parent.width
text: I18n.tr("Advanced comma-separated libxkbcommon options.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
DankTextField {
width: parent.width
text: SettingsData.keyboardOptions
placeholderText: "compose:ralt,ctrl:nocaps"
onTextEdited: SettingsData.set("keyboardOptions", text)
}
}
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
StyledText {
width: parent.width
text: I18n.tr("Keyboard Variant")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
DankTextField {
width: parent.width
text: SettingsData.keyboardVariants
placeholderText: "colemak"
onTextEdited: SettingsData.set("keyboardVariants", text)
}
}
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
StyledText {
width: parent.width
text: I18n.tr("Keyboard Model")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
DankTextField {
width: parent.width
text: SettingsData.keyboardModel
placeholderText: "pc104"
onTextEdited: SettingsData.set("keyboardModel", text)
}
}
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
StyledText {
width: parent.width
text: I18n.tr("Keymap File Path")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
width: parent.width
text: I18n.tr("Direct path to a .xkb keymap file. Overrides layouts/variants above.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
DankTextField {
width: parent.width
text: SettingsData.keyboardKeymapFile
placeholderText: "~/.config/keymap.xkb"
onTextEdited: SettingsData.set("keyboardKeymapFile", text)
}
}
}
SettingsCard {
width: parent.width
tags: ["keyboard", "repeat", "delay", "rate", "numlock", "behavior"]
title: I18n.tr("Keyboard Behavior")
settingKey: "keyboardBehaviorSettings"
iconName: "settings"
SettingsButtonGroupRow {
tags: ["keyboard", "track", "layout"]
settingKey: "keyboardTrackLayout"
text: I18n.tr("Remember Layout")
description: I18n.tr("How layout changes are remembered across apps")
model: [I18n.tr("Globally"), I18n.tr("Per Window")]
currentIndex: SettingsData.keyboardTrackLayout === "window" ? 1 : 0
onSelectionChanged: (index, selected) => {
if (!selected)
return;
SettingsData.set("keyboardTrackLayout", index === 1 ? "window" : "global");
}
}
SettingsToggleRow {
tags: ["keyboard", "numlock", "startup"]
settingKey: "keyboardNumlock"
text: I18n.tr("Enable Num Lock")
description: I18n.tr("Automatically turn on Num Lock at startup")
checked: SettingsData.keyboardNumlock
onToggled: checked => SettingsData.set("keyboardNumlock", checked)
}
SettingsSliderRow {
tags: ["keyboard", "repeat", "delay", "speed"]
settingKey: "keyboardRepeatDelay"
text: I18n.tr("Repeat Delay")
description: I18n.tr("Delay before characters start repeating")
value: SettingsData.keyboardRepeatDelay || 600
minimum: 100
maximum: 2000
step: 50
defaultValue: 600
unit: "ms"
onSliderValueChanged: newValue => SettingsData.set("keyboardRepeatDelay", newValue)
}
SettingsSliderRow {
tags: ["keyboard", "repeat", "rate", "speed"]
settingKey: "keyboardRepeatRate"
text: I18n.tr("Repeat Rate")
description: I18n.tr("Characters per second while holding down key")
value: SettingsData.keyboardRepeatRate || 25
minimum: 1
maximum: 100
step: 1
defaultValue: 25
unit: ""
onSliderValueChanged: newValue => SettingsData.set("keyboardRepeatRate", newValue)
}
}
}
}
}
+1 -1
View File
@@ -822,7 +822,7 @@ Item {
spacing: Theme.spacingS
Repeater {
model: ["dms_settings", "dms_notepad", "dms_sysmon", "dms_settings_search", "dms_clipboard_search", "dms_colorpicker"]
model: ["dms_settings", "dms_notepad", "dms_sysmon", "dms_settings_search", "dms_clipboard_search", "dms_colorpicker", "dms_qr_generator"]
delegate: Rectangle {
id: pluginDelegate
@@ -5,6 +5,7 @@ import qs.Modals.FileBrowser
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
import "../../Common/KeyUtils.js" as KeyUtils
Item {
id: root
@@ -518,6 +519,117 @@ Item {
}
}
SettingsToggleRow {
settingKey: "lockScreenSecurityKeyShortcutEnabled"
tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "shortcut", "keybind", "authentication"]
text: I18n.tr("Security key shortcut", "lock screen security key shortcut toggle")
description: I18n.tr("Keyboard shortcut to start security key unlock", "lock screen security key shortcut setting")
checked: SettingsData.lockScreenSecurityKeyShortcutEnabled
visible: SettingsData.enableU2f && SettingsData.u2fMode === "or" && !root.lockU2fControlledByPrimary
onToggled: checked => SettingsData.set("lockScreenSecurityKeyShortcutEnabled", checked)
}
Row {
width: parent.width - Theme.spacingM * 2
x: Theme.spacingM
spacing: Theme.spacingM
visible: SettingsData.lockScreenSecurityKeyShortcutEnabled && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !root.lockU2fControlledByPrimary
Column {
width: parent.width - securityKeyCapture.width - parent.spacing
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Key combination", "lock screen security key shortcut key combination setting")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: securityKeyCapture.captureError !== "" ? securityKeyCapture.captureError : I18n.tr("Press Ctrl+key to set. Esc cancels.", "lock screen security key shortcut key combination capture hint")
font.pixelSize: Theme.fontSizeSmall
color: securityKeyCapture.captureError !== "" ? Theme.warning : Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
DankButton {
id: securityKeyCapture
width: 200
anchors.verticalCenter: parent.verticalCenter
focus: capturing
text: capturing ? I18n.tr("Press key...", "lock screen security key shortcut key combination capture prompt") : SettingsData.lockScreenSecurityKeyShortcut
backgroundColor: capturing ? Theme.primaryContainer : Theme.surfaceContainer
textColor: Theme.surfaceText
property bool capturing: false
property string captureError: ""
readonly property var reservedKeys: ["A", "E", "B", "F", "U", "K", "W", "H", "D"]
function startCapture() {
captureError = "";
capturing = true;
securityKeyCapture.forceActiveFocus();
}
function stopCapture() {
capturing = false;
}
onClicked: {
if (capturing)
stopCapture();
else
startCapture();
}
Keys.onPressed: event => {
if (!securityKeyCapture.capturing)
return;
if (KeyUtils.isModifierKey(event.key))
return;
if (event.key === Qt.Key_Escape) {
securityKeyCapture.stopCapture();
event.accepted = true;
return;
}
const mods = KeyUtils.modsFromEvent(event.modifiers);
const hasCtrl = mods.includes("Ctrl");
const hasAlt = mods.includes("Alt") || mods.includes("Super");
if (!hasCtrl || hasAlt)
return;
const hasShift = mods.includes("Shift");
const key = KeyUtils.xkbKeyFromQtKey(event.key, !!(event.modifiers & Qt.KeypadModifier), hasShift);
if (!key)
return;
if (!KeyUtils.qtKeyFromName(key))
return;
if (!hasShift && securityKeyCapture.reservedKeys.indexOf(key.toUpperCase()) !== -1) {
securityKeyCapture.captureError = I18n.tr("Ctrl+%1 is used for password editing", "lock screen security key shortcut reserved key warning").arg(key.toUpperCase());
event.accepted = true;
return;
}
SettingsData.set("lockScreenSecurityKeyShortcut", KeyUtils.formatToken(mods, key));
securityKeyCapture.captureError = "";
securityKeyCapture.stopCapture();
event.accepted = true;
}
}
}
SettingsDropdownRow {
settingKey: "lockU2fPamPath"
tags: ["lock", "screen", "pam", "u2f", "security", "key", "source", "service"]
@@ -82,6 +82,15 @@ Item {
onToggled: checked => SettingsData.set("mediaUseAlbumArtAccent", checked)
}
SettingsToggleRow {
settingKey: "appleMusicAnimatedArtEnabled"
tags: ["apple", "animated", "video", "artwork", "cover"]
text: I18n.tr("Apple Music animated covers")
description: I18n.tr("Show looping video album artwork from Apple Music when available. Sends the playing artist and album name to Apple.")
checked: SettingsData.appleMusicAnimatedArtEnabled
onToggled: checked => SettingsData.set("appleMusicAnimatedArtEnabled", checked)
}
SettingsDropdownRow {
property var scrollOptsInternal: ["volume", "song", "nothing"]
property var scrollOptsDisplay: [I18n.tr("Change Volume", "media scroll wheel option"), I18n.tr("Change Song", "media scroll wheel option"), I18n.tr("Nothing", "media scroll wheel option")]
+29 -163
View File
@@ -1,10 +1,8 @@
import QtCore
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
import "../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
Item {
id: root
@@ -12,93 +10,6 @@ Item {
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property var inputIncludeStatus: ({
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
})
property bool checkingInclude: false
property bool fixingInclude: false
function getInputConfigPaths() {
const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation));
if (CompositorService.compositor !== "niri")
return null;
return {
"configFile": configDir + "/niri/config.kdl",
"layoutFile": configDir + "/niri/dms/input.kdl",
"grepPattern": 'include.*"dms/input.kdl"',
"includeLine": 'include "dms/input.kdl"'
};
}
function checkInputIncludeStatus() {
if (CompositorService.compositor !== "niri") {
inputIncludeStatus = {
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
};
return;
}
checkingInclude = true;
Proc.runCommand("check-input-include", [Proc.dmsBin, "config", "resolve-include", "niri", "input.kdl"], (output, exitCode) => {
checkingInclude = false;
if (exitCode !== 0) {
inputIncludeStatus = {
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
};
return;
}
try {
inputIncludeStatus = JSON.parse(output.trim());
} catch (e) {
inputIncludeStatus = {
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
};
}
});
}
function fixInputInclude() {
const paths = getInputConfigPaths();
if (!paths)
return;
fixingInclude = true;
const unixTime = Math.floor(Date.now() / 1000);
const backupFile = paths.configFile + ".backup" + unixTime;
const script = ConfigIncludeResolve.buildRepairScript({
configFile: paths.configFile,
backupFile: backupFile,
fragmentFile: paths.layoutFile,
grepPattern: paths.grepPattern,
includeLine: paths.includeLine
});
Proc.runCommand("fix-input-include", ["sh", "-c", script], (output, exitCode) => {
fixingInclude = false;
if (exitCode !== 0)
return;
checkInputIncludeStatus();
SettingsData.updateCompositorInput();
});
}
Component.onCompleted: {
if (CompositorService.isNiri) {
checkInputIncludeStatus();
}
}
DankFlickable {
anchors.fill: parent
clip: true
@@ -113,67 +24,8 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
StyledRect {
id: warningBox
NiriInputSetupBanner {
width: parent.width
height: warningContent.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
readonly property bool showSetup: !root.inputIncludeStatus.included
color: showSetup ? Theme.withAlpha(Theme.primary, 0.15) : Theme.withAlpha(Theme.primary, 0)
border.color: showSetup ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.primary, 0)
border.width: 1
visible: showSetup && !root.checkingInclude && CompositorService.isNiri
Row {
id: warningContent
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
DankIcon {
name: "warning"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - (fixButton.visible ? fixButton.width + Theme.spacingM : 0) - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("First Time Setup")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.primary
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg("dms/input")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
DankButton {
id: fixButton
visible: warningBox.showSetup
text: root.fixingInclude ? I18n.tr("Setting up...") : I18n.tr("Setup")
backgroundColor: Theme.primary
textColor: Theme.primaryText
enabled: !root.fixingInclude
anchors.verticalCenter: parent.verticalCenter
onClicked: root.fixInputInclude()
}
}
}
SettingsCard {
@@ -212,12 +64,15 @@ Item {
description: I18n.tr("Flat uses constant speed; Adaptive scales with movement speed")
model: [I18n.tr("Default"), I18n.tr("Flat"), I18n.tr("Adaptive")]
currentIndex: {
if (SettingsData.mouseAccelProfile === "flat") return 1;
if (SettingsData.mouseAccelProfile === "adaptive") return 2;
if (SettingsData.mouseAccelProfile === "flat")
return 1;
if (SettingsData.mouseAccelProfile === "adaptive")
return 2;
return 0;
}
onSelectionChanged: (index, selected) => {
if (!selected) return;
if (!selected)
return;
const profiles = ["default", "flat", "adaptive"];
SettingsData.set("mouseAccelProfile", profiles[index]);
}
@@ -252,12 +107,15 @@ Item {
description: I18n.tr("Choose when to generate scrolling events")
model: [I18n.tr("Default"), I18n.tr("No Scroll"), I18n.tr("On Button Down")]
currentIndex: {
if (SettingsData.mouseScrollMethod === "no-scroll") return 1;
if (SettingsData.mouseScrollMethod === "on-button-down") return 2;
if (SettingsData.mouseScrollMethod === "no-scroll")
return 1;
if (SettingsData.mouseScrollMethod === "on-button-down")
return 2;
return 0;
}
onSelectionChanged: (index, selected) => {
if (!selected) return;
if (!selected)
return;
const methods = ["default", "no-scroll", "on-button-down"];
SettingsData.set("mouseScrollMethod", methods[index]);
}
@@ -327,12 +185,15 @@ Item {
description: I18n.tr("Flat uses constant speed; Adaptive scales with movement speed")
model: [I18n.tr("Default"), I18n.tr("Flat"), I18n.tr("Adaptive")]
currentIndex: {
if (SettingsData.touchpadAccelProfile === "flat") return 1;
if (SettingsData.touchpadAccelProfile === "adaptive") return 2;
if (SettingsData.touchpadAccelProfile === "flat")
return 1;
if (SettingsData.touchpadAccelProfile === "adaptive")
return 2;
return 0;
}
onSelectionChanged: (index, selected) => {
if (!selected) return;
if (!selected)
return;
const profiles = ["default", "flat", "adaptive"];
SettingsData.set("touchpadAccelProfile", profiles[index]);
}
@@ -367,14 +228,19 @@ Item {
description: I18n.tr("Choose when to generate scrolling events")
model: [I18n.tr("Default"), I18n.tr("Two Finger"), I18n.tr("Edge"), I18n.tr("No Scroll"), I18n.tr("On Button Down")]
currentIndex: {
if (SettingsData.touchpadScrollMethod === "two-finger") return 1;
if (SettingsData.touchpadScrollMethod === "edge") return 2;
if (SettingsData.touchpadScrollMethod === "no-scroll") return 3;
if (SettingsData.touchpadScrollMethod === "on-button-down") return 4;
if (SettingsData.touchpadScrollMethod === "two-finger")
return 1;
if (SettingsData.touchpadScrollMethod === "edge")
return 2;
if (SettingsData.touchpadScrollMethod === "no-scroll")
return 3;
if (SettingsData.touchpadScrollMethod === "on-button-down")
return 4;
return 0;
}
onSelectionChanged: (index, selected) => {
if (!selected) return;
if (!selected)
return;
const methods = ["default", "two-finger", "edge", "no-scroll", "on-button-down"];
SettingsData.set("touchpadScrollMethod", methods[index]);
}

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