1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 03:28:28 -04:00

Compare commits

..

3 Commits

Author SHA1 Message Date
bbedward cc307aff98 restore overlay option and tweak inset padding behavior 2026-07-22 14:48:58 -04:00
bbedward cee7957e15 fix hover popouts in frame mode 2026-07-22 13:21:43 -04:00
bbedward 602697ef51 frame: host bar and dock inside the frame surface in connected mode
Previously the bar-in-frame was rendered on the overlay layer always.
Now they are rendered inside of the frames top-layer surface, which lets
the compositor handle layer-shell layering natively.
2026-07-22 13:21:43 -04:00
227 changed files with 9207 additions and 14034 deletions
-1
View File
@@ -40,7 +40,6 @@ var resolveIncludeCmd = &cobra.Command{
"layout.kdl",
"outputs.kdl",
"binds.kdl",
"input.kdl",
"cursor.conf",
"layout.conf",
"outputs.conf",
-4
View File
@@ -143,10 +143,6 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
niriFile: "alttab.kdl",
niriContent: func(_ string) string { return config.NiriAlttabConfig },
},
"input": {
niriFile: "input.kdl",
niriContent: func(_ string) string { return config.NiriInputConfig },
},
"outputs": {
niriFile: "outputs.kdl",
hyprFile: "outputs.lua",
+1 -1
View File
@@ -69,7 +69,7 @@ require (
)
require (
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b
github.com/AvengeMedia/dankgo v0.0.0-20260721162324-38d48943054d
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-20260730184236-239485829b0b h1:UwX1H4BkzazL7ips9ljnHzBXGttYARcIi5Njj9aqIt4=
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b/go.mod h1:xt8RldAfti0QCWidwYIzsSSoJWsE61WgEhTu2H9UpD4=
github.com/AvengeMedia/dankgo v0.0.0-20260721162324-38d48943054d h1:EZN2x2uAX975Uvw4MByqWaaVTp2fOJrNTl4V4EQbSn8=
github.com/AvengeMedia/dankgo v0.0.0-20260721162324-38d48943054d/go.mod h1:7p7cfydr4WM1G6eOPFlANXF3IV5du3FoA4CbDPprHAo=
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=
-1
View File
@@ -264,7 +264,6 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
{"layout.kdl", NiriLayoutConfig},
{"alttab.kdl", NiriAlttabConfig},
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"input.kdl", NiriInputConfig},
{"outputs.kdl", ""},
{"cursor.kdl", ""},
{"windowrules.kdl", ""},
@@ -1,15 +0,0 @@
// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
input {
mouse {
accel-speed 0.0
}
touchpad {
tap
accel-speed 0.0
natural-scroll
}
}
+25 -2
View File
@@ -34,6 +34,31 @@ input {
numlock
}
// Next sections include libinput settings.
// Omitting settings disables them, or leaves them at their default values.
// All commented-out settings here are examples, not defaults.
touchpad {
// off
tap
// dwt
// dwtp
// drag false
// drag-lock
natural-scroll
// accel-speed 0.2
// accel-profile "flat"
// scroll-method "two-finger"
// disabled-on-external-mouse
}
mouse {
// off
// natural-scroll
// accel-speed 0.2
// accel-profile "flat"
// scroll-method "no-scroll"
}
trackpoint {
// off
// natural-scroll
@@ -52,7 +77,6 @@ input {
// Setting max-scroll-amount="0%" makes it work only on windows already fully on screen.
// focus-follows-mouse max-scroll-amount="0%"
}
// You can configure outputs by their name, which you can find
// by running `niri msg outputs` while inside a niri instance.
// The built-in laptop monitor is usually called "eDP-1".
@@ -247,4 +271,3 @@ include optional=true "dms/alttab.kdl"
include optional=true "dms/binds.kdl"
include optional=true "dms/outputs.kdl"
include optional=true "dms/cursor.kdl"
include optional=true "dms/input.kdl"
-3
View File
@@ -16,6 +16,3 @@ var NiriAlttabConfig string
//go:embed embedded/niri-binds.kdl
var NiriBindsConfig string
//go:embed embedded/niri-input.kdl
var NiriInputConfig string
+5 -45
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@@ -225,44 +224,6 @@ 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
@@ -293,12 +254,11 @@ 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,
RawLuaAction: isRawLuaActionText(action),
Key: canonicalKey,
Action: action,
Description: description,
Flags: flags,
Options: options,
}
return h.writeOverrideBinds(existingBinds)
@@ -463,56 +463,6 @@ 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")
+26 -53
View File
@@ -386,8 +386,8 @@ func buildOnce(opts *Options) (bool, error) {
default:
syncAccentColor(primaryDark)
}
refreshGTKTheme(opts.Mode)
refreshGTKColorScheme()
refreshGTK(opts.Mode)
refreshGTK4()
}
if isDMSKDEColorSchemeActive(opts.ConfigDir) {
@@ -471,9 +471,12 @@ output_path = '%s'
case TemplateKindTerminal:
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
case TemplateKindVSCode:
for _, editor := range vscodeEditors {
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
}
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)
case TemplateKindEmacs:
if utils.EmacsConfigDir() != "" {
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
@@ -630,23 +633,6 @@ 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)
@@ -925,7 +911,7 @@ func generateDank16Variants(primaryDark, primaryLight, surface string, mode Colo
}
func isDMSGTKActive(configDir string) bool {
gtkCSS := filepath.Join(configDir, "gtk-4.0", "gtk.css")
gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
info, err := os.Lstat(gtkCSS)
if err != nil {
@@ -982,32 +968,11 @@ func applyKDEColorScheme(mode ColorMode) {
}
}
func gtkThemeInstalled(theme string) bool {
home, _ := os.UserHomeDir()
candidates := []string{
filepath.Join(home, ".local/share/themes", theme),
filepath.Join(home, ".themes", theme),
filepath.Join("/usr/share/themes", theme),
filepath.Join("/usr/local/share/themes", theme),
}
for _, dir := range candidates {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return true
}
}
return false
}
func refreshGTKTheme(mode ColorMode) {
theme := mode.GTKTheme()
if !gtkThemeInstalled(theme) {
log.Infof("Skipping gtk-theme refresh: %s is not installed", theme)
return
}
func refreshGTK(mode ColorMode) {
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", ""); err != nil {
log.Warnf("Failed to reset gtk-theme: %v", err)
}
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", theme); err != nil {
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()); err != nil {
log.Warnf("Failed to set gtk-theme: %v", err)
}
}
@@ -1028,8 +993,8 @@ func expectColorSchemeEcho(scheme string) {
// apps reload ~/.config/gtk-4.0 CSS (a gtk-theme flip does not). But apps
// following the portal color-scheme (Chromium) can drop the restore signal
// mid-repaint and latch the wrong mode, so this is opt-in.
func refreshGTKColorScheme() {
if os.Getenv("DMS_ENABLE_GTK_REFRESH") != "1" {
func refreshGTK4() {
if os.Getenv("DMS_ENABLE_GTK4_REFRESH") != "1" {
return
}
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
@@ -1047,13 +1012,13 @@ func refreshGTKColorScheme() {
expectColorSchemeEcho(toggle)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
log.Warnf("Failed to toggle color-scheme for GTK refresh: %v", err)
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
return
}
time.Sleep(50 * time.Millisecond)
time.Sleep(400 * time.Millisecond)
expectColorSchemeEcho(current)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
log.Warnf("Failed to restore color-scheme for GTK refresh: %v", err)
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
}
}
@@ -1182,8 +1147,16 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
}
func checkVSCodeExtension(homeDir string) bool {
for _, editor := range vscodeEditors {
pattern := filepath.Join(editor.extensionsDir(homeDir), "danklinux.dms-theme-*")
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-*")
if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
return true
}
@@ -22,16 +22,6 @@ 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) {
+6 -11
View File
@@ -43,10 +43,10 @@ func NewManager() (*Manager, error) {
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
m.promptBroker = broker
adapter, err := findAdapter(conn)
adapter, err := m.findAdapter()
if err != nil {
conn.Close()
return nil, err
return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
}
m.adapterPath = adapter
@@ -74,12 +74,12 @@ func NewManager() (*Manager, error) {
return m, nil
}
func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
obj := conn.Object(bluezService, dbus.ObjectPath("/"))
func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
return "", fmt.Errorf("%w: %v", ErrNoAdapter, err)
return "", err
}
for path, interfaces := range objects {
@@ -89,7 +89,7 @@ func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
}
}
return "", ErrNoAdapter
return "", fmt.Errorf("no adapter found")
}
func (m *Manager) initialize() error {
@@ -487,11 +487,6 @@ func (m *Manager) StopDiscovery() error {
}
func (m *Manager) SetPowered(powered bool) error {
if powered {
if err := rfkillUnblockBluetooth(); err != nil {
log.Debugf("[BluezManager] rfkill unblock failed: %v", err)
}
}
obj := m.dbusConn.Object(bluezService, m.adapterPath)
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
}
@@ -1,5 +0,0 @@
package bluez
func rfkillUnblockBluetooth() error {
return nil
}
@@ -1,19 +0,0 @@
package bluez
import "os"
// linux/rfkill.h: struct rfkill_event { __u32 idx; __u8 type; __u8 op; __u8 soft; __u8 hard; },
// RFKILL_TYPE_BLUETOOTH=2, RFKILL_OP_CHANGE_ALL=3
func rfkillUnblockBluetooth() error {
f, err := os.OpenFile("/dev/rfkill", os.O_WRONLY, 0)
if err != nil {
return err
}
defer f.Close()
var event [8]byte
event[4] = 2
event[5] = 3
_, err = f.Write(event[:])
return err
}
-46
View File
@@ -1,46 +0,0 @@
package bluez
import (
"errors"
"fmt"
"github.com/godbus/dbus/v5"
)
var ErrNoAdapter = errors.New("no bluetooth adapter found")
func WaitForAdapter() error {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return err
}
defer conn.Close()
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(objectMgrIface),
dbus.WithMatchMember("InterfacesAdded"),
); err != nil {
return err
}
signals := make(chan *dbus.Signal, 64)
conn.Signal(signals)
if _, err := findAdapter(conn); err == nil {
return nil
}
for sig := range signals {
if sig == nil || sig.Name != objectMgrIface+".InterfacesAdded" || len(sig.Body) < 2 {
continue
}
ifaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
if !ok {
continue
}
if _, ok := ifaces[adapter1Iface]; ok {
return nil
}
}
return fmt.Errorf("dbus signal stream closed")
}
+10 -23
View File
@@ -229,9 +229,6 @@ func (m *Manager) snapshotState() CUPSState {
func (m *Manager) Subscribe(id string) chan CUPSState {
ch := make(chan CUPSState, 64)
m.subLifecycleMu.Lock()
defer m.subLifecycleMu.Unlock()
wasEmpty := true
m.subscribers.Range(func(key string, ch chan CUPSState) bool {
wasEmpty = false
@@ -240,25 +237,19 @@ func (m *Manager) Subscribe(id string) chan CUPSState {
m.subscribers.Store(id, ch)
if !wasEmpty || m.subscription == nil {
return ch
if wasEmpty && m.subscription != nil {
if err := m.subscription.Start(); err != nil {
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
} else {
m.eventWG.Add(1)
go m.eventHandler()
}
}
if err := m.subscription.Start(); err != nil {
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
return ch
}
m.eventWG.Add(1)
go m.eventHandler()
return ch
}
func (m *Manager) Unsubscribe(id string) {
m.subLifecycleMu.Lock()
defer m.subLifecycleMu.Unlock()
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
@@ -269,22 +260,18 @@ func (m *Manager) Unsubscribe(id string) {
return false
})
if !isEmpty || m.subscription == nil {
return
if isEmpty && m.subscription != nil {
m.subscription.Stop()
m.eventWG.Wait()
}
m.subscription.Stop()
m.eventWG.Wait()
}
func (m *Manager) Close() {
close(m.stopChan)
m.subLifecycleMu.Lock()
if m.subscription != nil {
m.subscription.Stop()
}
m.subLifecycleMu.Unlock()
m.eventWG.Wait()
m.notifierWg.Wait()
-64
View File
@@ -1,9 +1,6 @@
package cups
import (
"errors"
"fmt"
"sync"
"testing"
mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
@@ -78,67 +75,6 @@ func TestManager_Subscribe(t *testing.T) {
assert.Equal(t, 0, count)
}
// mirrors the real managers: eventChan guarded by mu, conn/running deliberately
// unsynchronized so overlapping Start/Stop trips the race detector
type stubSubscription struct {
mu sync.Mutex
events chan SubscriptionEvent
conn *int
running bool
}
func (s *stubSubscription) Start() error {
if s.running {
return errors.New("already running")
}
s.running = true
s.mu.Lock()
s.events = make(chan SubscriptionEvent)
s.mu.Unlock()
v := 0
s.conn = &v
*s.conn++
return nil
}
func (s *stubSubscription) Stop() {
if !s.running {
return
}
s.running = false
s.conn = nil
s.mu.Lock()
close(s.events)
s.mu.Unlock()
}
func (s *stubSubscription) Events() <-chan SubscriptionEvent {
s.mu.Lock()
defer s.mu.Unlock()
return s.events
}
func TestManager_SubscribeUnsubscribeRace(t *testing.T) {
m := NewTestManager(mocks_cups.NewMockCUPSClientInterface(t), nil)
m.subscription = &stubSubscription{}
var wg sync.WaitGroup
for i := range 8 {
wg.Go(func() {
id := fmt.Sprintf("client-%d", i)
for range 50 {
m.Subscribe(id)
m.Unsubscribe(id)
}
})
}
wg.Wait()
}
func TestManager_Close(t *testing.T) {
mockClient := mocks_cups.NewMockCUPSClientInterface(t)
-1
View File
@@ -79,7 +79,6 @@ type Manager struct {
client CUPSClientInterface
pkHelper PkHelper
subscription SubscriptionManagerInterface
subLifecycleMu sync.Mutex
stateMutex sync.RWMutex
subscribers syncmap.Map[string, chan CUPSState]
stopChan chan struct{}
@@ -302,17 +302,17 @@ func (a *SecretAgent) GetSecrets(
}
a.backend.cachedVPNCredsMu.Unlock()
a.backend.cachedOpenConnectMu.Lock()
cachedOpenConnect := a.backend.cachedOpenConnectAuth
if cachedOpenConnect != nil && cachedOpenConnect.ConnectionUUID == connUuid {
a.backend.cachedOpenConnectAuth = nil
a.backend.cachedOpenConnectMu.Unlock()
a.backend.cachedGPSamlMu.Lock()
cachedGPSaml := a.backend.cachedGPSamlCookie
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
a.backend.cachedGPSamlCookie = nil
a.backend.cachedGPSamlMu.Unlock()
log.Infof("[SecretAgent] Using cached OpenConnect authentication for %s", connUuid)
log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
return buildOpenConnectSecretsResponse(settingName, cachedOpenConnect.Cookie, cachedOpenConnect.Host, cachedOpenConnect.Fingerprint), nil
return buildGPSamlSecretsResponse(settingName, cachedGPSaml.Cookie, cachedGPSaml.Host, cachedGPSaml.Fingerprint), nil
}
a.backend.cachedOpenConnectMu.Unlock()
a.backend.cachedGPSamlMu.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.cachedOpenConnectMu.Lock()
a.backend.cachedOpenConnectAuth = &cachedOpenConnectAuth{
a.backend.cachedGPSamlMu.Lock()
a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{
ConnectionUUID: connUuid,
Cookie: authResult.Cookie,
Host: authResult.Host,
User: authResult.User,
Fingerprint: authResult.Fingerprint,
}
a.backend.cachedOpenConnectMu.Unlock()
a.backend.cachedGPSamlMu.Unlock()
return buildOpenConnectSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
return buildGPSamlSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
}
}
@@ -987,7 +987,7 @@ func buildWiFiSecretsResponse(settingName string, secrets map[string]string) nmS
return out
}
func buildOpenConnectSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
out := nmSettingMap{}
vpnSec := nmVariantMap{}
@@ -122,7 +122,7 @@ func TestNeedsExternalBrowserAuth(t *testing.T) {
}
}
func TestBuildOpenConnectSecretsResponse(t *testing.T) {
func TestBuildGPSamlSecretsResponse(t *testing.T) {
tests := []struct {
name string
settingName string
@@ -155,7 +155,7 @@ func TestBuildOpenConnectSecretsResponse(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildOpenConnectSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
result := buildGPSamlSecretsResponse(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
cachedOpenConnectAuth *cachedOpenConnectAuth
cachedOpenConnectMu sync.Mutex
cachedWiFiSecret *cachedWiFiSecret
cachedWiFiSecretMu sync.Mutex
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
onStateChange func()
}
@@ -100,9 +100,8 @@ 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
PersistentSecrets map[string]string
SavePassword bool
Secrets map[string]string
SavePassword bool
}
type cachedVPNCredentials struct {
@@ -125,7 +124,7 @@ type cachedWiFiSecret struct {
Secrets map[string]string
}
type cachedOpenConnectAuth struct {
type cachedGPSamlCookie struct {
ConnectionUUID string
Cookie string
Host string
@@ -3,7 +3,6 @@ package network
import (
"bufio"
"context"
"errors"
"fmt"
"os/exec"
"strings"
@@ -11,29 +10,16 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
type openConnectAuthResult struct {
type gpSamlAuthResult 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) (*openConnectAuthResult, error) {
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*gpSamlAuthResult, error) {
if gateway == "" {
return nil, fmt.Errorf("GP SAML auth: gateway is empty")
}
@@ -77,7 +63,7 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
}
}()
result := &openConnectAuthResult{Host: gateway}
result := &gpSamlAuthResult{Host: gateway}
var allOutput []string
scanner := bufio.NewScanner(stdout)
@@ -131,8 +117,13 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
return result, nil
}
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*openConnectAuthResult, error) {
return runOpenConnectAuthenticate(ctx, []string{
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) {
ocPath, err := exec.LookPath("openconnect")
if err != nil {
return nil, fmt.Errorf("openconnect not found: %w", err)
}
args := []string{
"--protocol=gp",
"--usergroup=gateway:prelogin-cookie",
"--user=" + user,
@@ -140,83 +131,18 @@ 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(secret + "\n")
cmd.Stdin = strings.NewReader(preloginCookie)
output, err := cmd.CombinedOutput()
result := parseOpenConnectAuthenticateOutput(string(output))
serverCert := suggestedOpenConnectServerCert(string(output))
if err != nil {
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,
}
return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output))
}
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") {
result := &gpSamlAuthResult{}
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "COOKIE="):
@@ -232,7 +158,15 @@ func parseOpenConnectAuthenticateOutput(output string) *openConnectAuthResult {
}
}
}
return result
if result.Cookie == "" {
return nil, fmt.Errorf("no COOKIE in openconnect --authenticate output: %s", string(output))
}
log.Infof("[GP-SAML] openconnect --authenticate: cookie_len=%d, host=%s, fingerprint=%s",
len(result.Cookie), result.Host, result.Fingerprint)
return result, nil
}
func unshellQuote(s string) string {
@@ -245,7 +179,7 @@ func unshellQuote(s string) string {
return s
}
func parseGPSamlFromCommandLine(line string, result *openConnectAuthResult) {
func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) {
if !strings.Contains(line, "openconnect") {
return
}
@@ -1,10 +1,6 @@
package network
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@@ -75,7 +71,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
tests := []struct {
name string
line string
initialResult *openConnectAuthResult
initialResult *gpSamlAuthResult
expectedCookie string
expectedUser string
expectedFP string
@@ -83,7 +79,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "full openconnect command",
line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john",
initialResult: &openConnectAuthResult{},
initialResult: &gpSamlAuthResult{},
expectedCookie: "AUTH123",
expectedUser: "john",
expectedFP: "pin-sha256:ABC",
@@ -91,7 +87,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "with equals signs in cookie",
line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane",
initialResult: &openConnectAuthResult{},
initialResult: &gpSamlAuthResult{},
expectedCookie: "authcookie=xyz123&portal=GATE",
expectedUser: "jane",
expectedFP: "",
@@ -99,7 +95,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "non-openconnect line",
line: "some other output",
initialResult: &openConnectAuthResult{},
initialResult: &gpSamlAuthResult{},
expectedCookie: "",
expectedUser: "",
expectedFP: "",
@@ -107,7 +103,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "preserves existing values",
line: "openconnect --user=newuser",
initialResult: &openConnectAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
initialResult: &gpSamlAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
expectedCookie: "existing",
expectedUser: "newuser",
expectedFP: "existing-fp",
@@ -115,7 +111,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "only updates empty fields",
line: "openconnect --cookie=NEW --user=NEW",
initialResult: &openConnectAuthResult{Cookie: "OLD"},
initialResult: &gpSamlAuthResult{Cookie: "OLD"},
expectedCookie: "OLD",
expectedUser: "NEW",
expectedFP: "",
@@ -123,7 +119,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: &openConnectAuthResult{},
initialResult: &gpSamlAuthResult{},
expectedCookie: "",
expectedUser: "john.doe@example.com",
expectedFP: "",
@@ -131,7 +127,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "with server cert flag",
line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com",
initialResult: &openConnectAuthResult{},
initialResult: &gpSamlAuthResult{},
expectedCookie: "",
expectedUser: "",
expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
@@ -162,7 +158,7 @@ func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
"",
}
result := &openConnectAuthResult{}
result := &gpSamlAuthResult{}
for _, line := range lines {
parseGPSamlFromCommandLine(line, result)
}
@@ -171,19 +167,3 @@ 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,7 +326,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
}
authAction := detectVPNAuthAction(vpnServiceType, vpnData)
var openConnectAuth *openConnectAuthResult
switch authAction {
case "openvpn_username":
@@ -336,19 +335,6 @@ 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"]
@@ -359,7 +345,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)
openConnectAuth, err = b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
authResult, err := b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
samlCancel()
if err != nil {
errMsg := err.Error()
@@ -377,6 +363,16 @@ 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 {
@@ -386,19 +382,6 @@ 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
@@ -411,13 +394,6 @@ 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 = ""
@@ -449,9 +425,6 @@ 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"]
@@ -462,200 +435,6 @@ 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")
@@ -979,13 +758,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.VPNErrorUuid = ""
b.stateMutex.Unlock()
// Clear cached one-shot authentication values on success.
// Clear cached PKCS11 PIN and SAML cookie on success
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = nil
b.cachedOpenConnectMu.Unlock()
b.cachedGPSamlMu.Lock()
b.cachedGPSamlCookie = nil
b.cachedGPSamlMu.Unlock()
b.pendingVPNSaveMu.Lock()
pending := b.pendingVPNSave
@@ -1008,16 +787,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.VPNErrorUuid = connectingVPNUUID
b.stateMutex.Unlock()
// Clear cached one-shot authentication values on failure.
// Clear cached PKCS11 PIN and SAML cookie on failure
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = nil
b.cachedOpenConnectMu.Unlock()
b.pendingVPNSaveMu.Lock()
b.pendingVPNSave = nil
b.pendingVPNSaveMu.Unlock()
b.cachedGPSamlMu.Lock()
b.cachedGPSamlCookie = nil
b.cachedGPSamlMu.Unlock()
return
}
}
@@ -1035,16 +811,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.VPNErrorUuid = connectingVPNUUID
b.stateMutex.Unlock()
// Clear cached one-shot authentication values.
// Clear cached PKCS11 PIN and SAML cookie
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = nil
b.cachedOpenConnectMu.Unlock()
b.pendingVPNSaveMu.Lock()
b.pendingVPNSave = nil
b.pendingVPNSaveMu.Unlock()
b.cachedGPSamlMu.Lock()
b.cachedGPSamlCookie = nil
b.cachedGPSamlMu.Unlock()
}
}
@@ -1090,40 +863,17 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
log.Infof("[saveVPNCredentials] Saving username")
}
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
}
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"
}
}
// Save secrets if requested
if creds.SavePassword {
toSave := creds.Secrets
if len(toSave) == 0 {
toSave = map[string]string{"password": creds.Password}
secs := creds.Secrets
if len(secs) == 0 {
secs = map[string]string{"password": creds.Password}
}
for field, value := range toSave {
secs[field] = value
for field := range secs {
data[field+"-flags"] = "0"
}
}
if len(secs) > 0 {
vpn["secrets"] = dbus.MakeVariant(secs)
log.Infof("[saveVPNCredentials] Saving %d secret field(s)", len(secs))
log.Infof("[saveVPNCredentials] Saving %d secret field(s) with flags=0", len(secs))
}
vpn["data"] = dbus.MakeVariant(data)
@@ -1,14 +1,10 @@
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"
)
@@ -140,137 +136,3 @@ 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,8 +43,6 @@ 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":
@@ -367,22 +365,6 @@ 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 {
@@ -1,64 +0,0 @@
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.HasPrefix(clean, textQRCodeTmpPrefix)) && strings.HasSuffix(clean, ".png")
return strings.HasPrefix(clean, qrCodeTmpPrefix) && strings.HasSuffix(clean, ".png")
}
var safePathChar = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
+2 -3
View File
@@ -116,12 +116,11 @@ func RouteRequest(conn *models.Conn, req models.Request) {
}
if strings.HasPrefix(req.Method, "cups.") {
mgr, err := ensureCupsManager()
if err != nil {
if cupsManager == nil {
models.RespondError(conn, req.ID, "CUPS manager not initialized")
return
}
cups.HandleRequest(conn, req, mgr)
cups.HandleRequest(conn, req, cupsManager)
return
}
+36 -80
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"runtime/debug"
"sync"
"sync/atomic"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
@@ -79,8 +80,8 @@ var geoClientInstance geolocation.Client
const dbusClientID = "dms-dbus-client"
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
var cupsMu sync.Mutex
var cupsSubscriberCount int
var cupsSubscribers syncmap.Map[string, bool]
var cupsSubscriberCount atomic.Int32
var appPaths = paths.New("danklinux")
@@ -173,55 +174,17 @@ func InitializeAppPickerManager() error {
return nil
}
func initializeCupsManagerLocked() (bool, error) {
if cupsManager != nil {
return false, nil
}
func InitializeCupsManager() error {
manager, err := cups.NewManager()
if err != nil {
log.Warnf("Failed to initialize cups manager: %v", err)
return false, err
return err
}
cupsManager = manager
log.Info("CUPS manager initialized")
return true, nil
}
func ensureCupsManager() (*cups.Manager, error) {
cupsMu.Lock()
created, err := initializeCupsManagerLocked()
mgr := cupsManager
cupsMu.Unlock()
if err != nil {
return nil, err
}
if created {
notifyCapabilityChange()
}
return mgr, nil
}
func cupsAvailable() bool {
cupsMu.Lock()
defer cupsMu.Unlock()
return cupsManager != nil
}
func releaseCupsSubscriber() {
cupsMu.Lock()
cupsSubscriberCount--
var mgr *cups.Manager
if cupsSubscriberCount == 0 && cupsManager != nil {
mgr = cupsManager
cupsManager = nil
}
cupsMu.Unlock()
if mgr == nil {
return
}
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
mgr.Close()
notifyCapabilityChange()
return nil
}
func InitializeBrightnessManager() error {
@@ -416,7 +379,7 @@ func getCapabilities() Capabilities {
caps = append(caps, "browser")
}
if cupsAvailable() {
if cupsManager != nil {
caps = append(caps, "cups")
}
@@ -486,7 +449,7 @@ func getServerInfo() ServerInfo {
caps = append(caps, "browser")
}
if cupsAvailable() {
if cupsManager != nil {
caps = append(caps, "cups")
}
@@ -950,31 +913,38 @@ func handleSubscribe(conn *models.Conn, req models.Request) {
}
if shouldSubscribe("cups") {
cupsMu.Lock()
cupsSubscriberCount++
created, err := initializeCupsManagerLocked()
mgr := cupsManager
cupsMu.Unlock()
cupsSubscribers.Store(clientID+"-cups", true)
count := cupsSubscriberCount.Add(1)
if err != nil {
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
} else if created {
notifyCapabilityChange()
if count == 1 {
if err := InitializeCupsManager(); err != nil {
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
} else {
notifyCapabilityChange()
}
}
if mgr == nil {
releaseCupsSubscriber()
} else {
if cupsManager != nil {
wg.Add(1)
cupsChan := mgr.Subscribe(clientID + "-cups")
cupsChan := cupsManager.Subscribe(clientID + "-cups")
go func() {
defer wg.Done()
defer func() {
mgr.Unsubscribe(clientID + "-cups")
releaseCupsSubscriber()
cupsManager.Unsubscribe(clientID + "-cups")
cupsSubscribers.Delete(clientID + "-cups")
count := cupsSubscriberCount.Add(-1)
if count == 0 {
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
if cupsManager != nil {
cupsManager.Close()
cupsManager = nil
notifyCapabilityChange()
}
}
}()
initialState := mgr.GetState()
initialState := cupsManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "cups", Data: initialState}:
case <-stopChan:
@@ -1284,12 +1254,9 @@ func cleanupManagers() {
if appPickerManager != nil {
appPickerManager.Close()
}
cupsMu.Lock()
if cupsManager != nil {
cupsManager.Close()
cupsManager = nil
}
cupsMu.Unlock()
if brightnessManager != nil {
brightnessManager.Close()
}
@@ -1668,21 +1635,10 @@ func (s *Server) Serve(printDocs bool) error {
}()
go func() {
for {
err := InitializeBluezManager()
if err == nil {
notifyCapabilityChange()
return
}
if err := InitializeBluezManager(); err != nil {
log.Warnf("Bluez manager unavailable: %v", err)
if !errors.Is(err, bluez.ErrNoAdapter) {
return
}
if err := bluez.WaitForAdapter(); err != nil {
log.Warnf("Bluetooth adapter watch failed: %v", err)
return
}
log.Info("Bluetooth adapter appeared, initializing bluez manager")
} else {
notifyCapabilityChange()
}
}()
+1 -2
View File
@@ -143,8 +143,7 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
case "konsole":
argv = []string{term, "-p", "tabtitle=" + title}
case "gnome-terminal":
// --wait: the factory process otherwise returns immediately
argv = []string{term, "--wait", "--title=" + title}
argv = []string{term, "--title=" + title}
execFlag = "--"
default:
argv = []string{term}
+9 -14
View File
@@ -412,30 +412,25 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
onLine := func(line string) { m.appendLog(line) }
argv := wrapInTerminal(term, "DMS — System Update (custom)", opts.CustomCommand, opts.TerminalArgs)
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
code := ErrCodeBackendFailed
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
m.failCustomUpgrade(ErrCodeTimeout, err)
return
code = ErrCodeTimeout
case errors.Is(ctx.Err(), context.Canceled):
m.failCustomUpgrade(ErrCodeCancelled, err)
return
code = ErrCodeCancelled
}
// exit status reflects the trailing `read`, not the update command
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
return
}
m.finishSuccessfulUpgrade(false)
m.runRefresh(context.Background(), false)
}
func (m *Manager) failCustomUpgrade(code ErrorCode, err error) {
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
m.appendLog("Upgrade complete.")
@@ -233,27 +233,3 @@ func TestUpgradeBackendsFiltersFlatpakOnly(t *testing.T) {
t.Fatalf("upgradeBackends(mixed) = %#v, want dnf5 then flatpak", got)
}
}
func TestWrapInTerminal(t *testing.T) {
tests := []struct {
term string
wantPrefix []string
}{
{"kitty", []string{"kitty", "--class", "com.danklinux.dms", "-T", "Title"}},
{"gnome-terminal", []string{"gnome-terminal", "--wait", "--title=Title"}},
{"foot", []string{"foot", "--app-id=com.danklinux.dms", "--title=Title"}},
}
for _, tt := range tests {
got := wrapInTerminal(tt.term, "Title", "echo hi", nil)
if len(got) < len(tt.wantPrefix) || !reflect.DeepEqual(got[:len(tt.wantPrefix)], tt.wantPrefix) {
t.Errorf("wrapInTerminal(%q) = %#v, want prefix %#v", tt.term, got, tt.wantPrefix)
}
tail := got[len(got)-3:]
if tail[0] != "sh" || tail[1] != "-c" {
t.Errorf("wrapInTerminal(%q) tail = %#v, want [sh -c <cmd>]", tt.term, tail)
}
if !strings.Contains(tail[2], "echo hi") {
t.Errorf("wrapInTerminal(%q) command %q does not contain shell command", tt.term, tail[2])
}
}
}
+22 -6
View File
@@ -12,7 +12,6 @@ import (
"time"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"github.com/AvengeMedia/dankgo/boottimer"
"github.com/godbus/dbus/v5"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
@@ -24,6 +23,9 @@ import (
const animKelvinStep = 25
// Go timers freeze during suspend; cap sleeps so wall-clock deadlines can't be missed.
const maxScheduleWait = 5 * time.Minute
func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
if err := config.Validate(); err != nil {
return nil, err
@@ -750,8 +752,7 @@ func (m *Manager) schedulerLoop() {
m.post(func() { m.applyCurrentTemp("startup") })
}
timer := boottimer.New(24 * time.Hour)
defer timer.Stop()
var timer *time.Timer
for {
m.configMutex.RLock()
enabled := m.config.Enabled
@@ -760,19 +761,31 @@ func (m *Manager) schedulerLoop() {
now := time.Now()
m.recalcSchedule(now)
waitDur := 24 * time.Hour
var waitDur time.Duration
if enabled {
deadline := m.getNextDeadline(now)
if waitDur = time.Until(deadline); waitDur < time.Second {
waitDur = time.Until(deadline)
switch {
case waitDur < time.Second:
waitDur = time.Second
case waitDur > maxScheduleWait:
waitDur = maxScheduleWait
}
} else {
waitDur = 24 * time.Hour
}
timer.Reset(waitDur)
if timer != nil {
timer.Stop()
}
timer = time.NewTimer(waitDur)
select {
case <-m.stopChan:
timer.Stop()
return
case <-m.updateTrigger:
timer.Stop()
m.scheduleMutex.Lock()
m.schedule.calcDay = time.Time{}
m.scheduleMutex.Unlock()
@@ -1073,11 +1086,14 @@ func (m *Manager) handleResume() {
}
// Compositor gamma state is unknown after resume; force a resend (#1235)
// and re-arm the scheduler timer, which froze during suspend.
m.outputs.Range(func(_ uint32, out *outputState) bool {
out.lastTemp = 0
return true
})
m.recalcSchedule(time.Now())
m.applyCurrentTemp("resume")
m.triggerUpdate()
}
func (m *Manager) triggerUpdate() {
Generated
+3 -3
View File
@@ -3,11 +3,11 @@
"dank-qml-common": {
"flake": false,
"locked": {
"lastModified": 1785445867,
"narHash": "sha256-l9IRsLIVZ7bV+KMuTdCga89tGt4lpdMXhQR7f/2qoe4=",
"lastModified": 1784647395,
"narHash": "sha256-kcLSJC8XBzmWoSBc90Zn81qrNsqWMkwQ1m/zZixOw30=",
"owner": "AvengeMedia",
"repo": "dank-qml-common",
"rev": "3b06bd9372e18bc8086cc3958d4f677d57fbfdd8",
"rev": "1919595f0dde4f5bf5ac0baa1902ff6d070971d5",
"type": "github"
},
"original": {
+1 -1
View File
@@ -111,7 +111,7 @@
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-pjaRyB6E2TZvVd5a4xcdGSRVr9Dg9wEG/5e+HdtZJCg=";
vendorHash = "sha256-G0KCUaNwjWfLcSnb68TMXjpB2xxwheXgrBkFEzcSZTc=";
subPackages = [ "cmd/dms" ];
+6 -64
View File
@@ -11,27 +11,16 @@ Singleton {
id: root
readonly property var log: Log.scoped("CacheData")
readonly property int cacheConfigVersion: 2
readonly property int cacheConfigVersion: 1
readonly property string _stateUrl: StandardPaths.writableLocation(StandardPaths.GenericCacheLocation)
readonly property string _stateDir: Paths.strip(_stateUrl)
property bool _loading: false
property bool _hasLoaded: false
property int _loadedCacheVersion: 0
readonly property var _pinKeys: ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"]
readonly property var _dataKeys: ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings"].concat(_pinKeys)
property string wallpaperLastPath: ""
property string profileLastPath: ""
property var brightnessDevicePins: ({})
property var wifiNetworkPins: ({})
property var bluetoothDevicePins: ({})
property var audioInputDevicePins: ({})
property var audioOutputDevicePins: ({})
property var fileBrowserSettings: ({
"wallpaper": {
"lastPath": "",
@@ -89,46 +78,8 @@ Singleton {
function loadCache() {
_loading = true;
try {
parseCache(cacheFile.text());
} finally {
_loading = false;
_hasLoaded = true;
}
}
function set(key, value) {
if (_dataKeys.indexOf(key) < 0) {
log.warn("Unknown cache key:", key);
return;
}
root[key] = value;
saveCache();
}
function migratePins(pins) {
if (!pins)
return;
if (!_hasLoaded)
loadCache();
if (_loadedCacheVersion >= cacheConfigVersion)
return;
let migrated = false;
for (const key of _pinKeys) {
const legacy = pins[key];
if (!legacy || Object.keys(legacy).length === 0)
continue;
if (Object.keys(root[key] || {}).length > 0)
continue;
root[key] = legacy;
migrated = true;
}
if (!migrated)
return;
log.info("Migrated device pins from settings.json");
saveCache();
parseCache(cacheFile.text());
_loading = false;
}
function parseCache(content) {
@@ -136,7 +87,6 @@ Singleton {
try {
if (content && content.trim()) {
const cache = JSON.parse(content);
_loadedCacheVersion = cache.configVersion || 0;
wallpaperLastPath = cache.wallpaperLastPath !== undefined ? cache.wallpaperLastPath : "";
profileLastPath = cache.profileLastPath !== undefined ? cache.profileLastPath : "";
@@ -172,10 +122,6 @@ Singleton {
};
}
for (const key of _pinKeys) {
root[key] = cache[key] !== undefined ? cache[key] : {};
}
if (cache.configVersion === undefined) {
migrateFromUndefinedToV1(cache);
cleanupUnusedKeys();
@@ -192,16 +138,12 @@ Singleton {
function saveCache() {
if (_loading)
return;
const data = {
cacheFile.setText(JSON.stringify({
"wallpaperLastPath": wallpaperLastPath,
"profileLastPath": profileLastPath,
"fileBrowserSettings": fileBrowserSettings,
"configVersion": cacheConfigVersion
};
for (const key of _pinKeys) {
data[key] = root[key];
}
cacheFile.setText(JSON.stringify(data, null, 2));
}, null, 2));
}
function migrateFromUndefinedToV1(cache) {
@@ -209,7 +151,7 @@ Singleton {
}
function cleanupUnusedKeys() {
const validKeys = _dataKeys.concat(["configVersion"]);
const validKeys = ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings", "configVersion"];
try {
const content = cacheFile.text();
+29
View File
@@ -0,0 +1,29 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
Singleton {
id: root
function clearImageCache() {
Quickshell.execDetached(["rm", "-rf", Paths.stringify(Paths.imagecache)]);
Paths.mkdir(Paths.imagecache);
}
function clearOldCache(ageInMinutes) {
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", "*.png", "-mmin", `+${ageInMinutes}`, "-delete"]);
}
function clearCacheForSize(size) {
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", `*@${size}x${size}.png`, "-delete"]);
}
function getCacheSize(callback) {
Proc.runCommand("cache_size", ["du", "-sm", Paths.stringify(Paths.imagecache)], function (output, exitCode) {
const sizeMB = parseInt(output.split("\t")[0]) || 0;
callback(sizeMB);
});
}
}
@@ -114,6 +114,26 @@ function withRevision(descriptor, revision) {
return next;
}
function withAnimationOffset(descriptor, x, y) {
var next = normalize(descriptor);
next.animationOffset = {
"x": x === undefined ? next.animationOffset.x : _number(x, next.animationOffset.x),
"y": y === undefined ? next.animationOffset.y : _number(y, next.animationOffset.y)
};
return next;
}
function withBodyRect(descriptor, x, y, width, height) {
var next = normalize(descriptor);
next.bodyRect = {
"x": x === undefined ? next.bodyRect.x : _number(x, next.bodyRect.x),
"y": y === undefined ? next.bodyRect.y : _number(y, next.bodyRect.y),
"width": width === undefined ? next.bodyRect.width : Math.max(0, _number(width, next.bodyRect.width)),
"height": height === undefined ? next.bodyRect.height : Math.max(0, _number(height, next.bodyRect.height))
};
return next;
}
function same(a, b, threshold) {
if (!a || !b)
return false;
@@ -162,6 +162,21 @@ function fillBounds(rect, side, seamOverlap, dpr) {
};
}
function clipEnvelope(rect, side, radii, seamOverlap, dpr) {
var fill = fillBounds(rect, side, seamOverlap, dpr);
var chrome = chromeBounds(fill, side, radii.start, radii.end, radii.farExtent, dpr);
return {
"x": chrome.x,
"y": chrome.y,
"width": chrome.width,
"height": chrome.height,
"bodyX": snap(fill.x - chrome.x, dpr),
"bodyY": snap(fill.y - chrome.y, dpr),
"bodyWidth": fill.width,
"bodyHeight": fill.height
};
}
function blurRegions(descriptor, rect, radii, dpr) {
var side = descriptor.barSide;
var regions = [bodyRect(rect, dpr)];
@@ -204,3 +219,14 @@ function unionBounds(rects, padding, dpr) {
"height": Math.max(0, snap(maxY - minY + pad * 2, dpr))
};
}
function shadowSourceBounds(descriptor, rect, radii, padding, dpr) {
return unionBounds(blurRegions(descriptor, rect, radii, dpr), padding, dpr);
}
function stableEqual(a, b, dpr) {
if (!a || !b)
return false;
var threshold = 0.5 / (dpr || 1);
return Math.abs(a.x - b.x) < threshold && Math.abs(a.y - b.y) < threshold && Math.abs(a.width - b.width) < threshold && Math.abs(a.height - b.height) < threshold;
}
+10
View File
@@ -44,6 +44,16 @@ function connectorX(barSide, baseX, bodyWidth, placement, spacing, radius) {
return barSide === "left" ? s : s - w;
}
function connectorY(barSide, baseY, bodyHeight, placement, spacing, radius) {
var s = seamY(barSide, baseY, bodyHeight, placement);
var h = connectorHeight(barSide, spacing, radius);
if (barSide === "top")
return s;
if (barSide === "bottom")
return s - h;
return placement === "left" ? s - h : s;
}
// Which corner of the connector's bounding rect hosts the concave arc that
// carves into the body. Used for arc-sweep orientation.
function arcCorner(barSide, placement) {
-72
View File
@@ -1,72 +0,0 @@
.pragma library
function formatRate(bytesPerSec, gbDecimals) {
if (bytesPerSec < 1024)
return bytesPerSec.toFixed(0) + " B/s";
if (bytesPerSec < 1024 * 1024)
return (bytesPerSec / 1024).toFixed(1) + " KB/s";
if (bytesPerSec < 1024 * 1024 * 1024)
return (bytesPerSec / (1024 * 1024)).toFixed(1) + " MB/s";
return (bytesPerSec / (1024 * 1024 * 1024)).toFixed(gbDecimals ?? 2) + " GB/s";
}
function formatBytes(bytes) {
if (bytes < 1024)
return bytes.toFixed(0) + "B";
if (bytes < 1024 * 1024)
return (bytes / 1024).toFixed(0) + "K";
if (bytes < 1024 * 1024 * 1024)
return (bytes / (1024 * 1024)).toFixed(1) + "M";
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + "G";
}
function formatIsoTime(isoString) {
if (!isoString)
return "";
try {
const date = new Date(isoString);
if (isNaN(date.getTime()))
return "";
return date.toLocaleTimeString(Qt.locale(), "HH:mm");
} catch (e) {
return "";
}
}
function formatRemaining(ms, zeroText, minText, hText, hmText) {
if (ms <= 0)
return zeroText;
const totalMinutes = Math.ceil(ms / 60000);
if (totalMinutes < 60)
return minText.arg(totalMinutes);
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes - hours * 60;
if (mins === 0)
return hText.arg(hours);
return hmText.arg(hours).arg(mins);
}
function pad2(n) {
return n < 10 ? "0" + n : "" + n;
}
function formatUntil(ts, use24h) {
if (!ts)
return "";
const d = new Date(ts);
const hours = d.getHours();
const minutes = d.getMinutes();
if (use24h)
return pad2(hours) + ":" + pad2(minutes);
const suffix = hours >= 12 ? "PM" : "AM";
const h12 = ((hours + 11) % 12) + 1;
return h12 + ":" + pad2(minutes) + " " + suffix;
}
function addToHistory(arr, val, max) {
const newArr = arr.slice();
newArr.push(val);
if (newArr.length > max)
newArr.shift();
return newArr;
}
+7
View File
@@ -1370,3 +1370,10 @@ function buildDmsAction(baseKey, args) {
return parts.join(" ");
}
function getScreenshotOptions() {
return [
{ id: "write-to-disk", label: "Save to disk", type: "bool" },
{ id: "show-pointer", label: "Show pointer", type: "bool" }
];
}
+5 -15
View File
@@ -22,10 +22,6 @@ Singleton {
readonly property url imagecache: `${cache}/imagecache`
property var iconResolver: null
property var desktopIconResolver: null
property var trashHandler: null
Component.onCompleted: mkdir(imagecache)
function stringify(path: url): string {
@@ -92,7 +88,7 @@ Singleton {
function themedIconPath(name: string): string {
if (!name)
return "";
const themed = iconResolver ? iconResolver(name) : "";
const themed = (typeof IconThemeService !== "undefined") ? IconThemeService.resolve(name) : "";
if (themed)
return themed;
return Quickshell.iconPath(name, true);
@@ -109,15 +105,11 @@ Singleton {
return moddedId;
return themedIconPath(moddedId);
}
if (!desktopIconResolver)
return themedIconPath(iconName);
return themedIconPath(iconName) || desktopIconResolver(iconName);
return themedIconPath(iconName) || DesktopService.resolveIconPath(iconName);
}
function trashPath(path: string, callback): void {
if (!trashHandler)
return;
trashHandler(path, callback);
TrashService.trashPath(path, callback);
}
function copyPathToClipboard(path: string): void {
@@ -133,7 +125,7 @@ Singleton {
return toFileUrl(expandTilde(target));
if (target.startsWith("file://"))
return target;
const themed = iconResolver ? iconResolver(target) : "";
const themed = (typeof IconThemeService !== "undefined") ? IconThemeService.resolve(target) : "";
if (themed)
return themed;
return "image://icon/" + target;
@@ -156,9 +148,7 @@ Singleton {
if (icon && icon !== "")
return icon;
if (!desktopIconResolver)
return "";
return desktopIconResolver(appId);
return DesktopService.resolveIconPath(appId);
}
function getAppName(appId: string, desktopEntry: var): string {
-27
View File
@@ -1,27 +0,0 @@
.pragma library
function findParentFlickable(item) {
while (item) {
if (item.hasOwnProperty("contentY") && item.hasOwnProperty("contentItem"))
return item;
item = item.parent;
}
return null;
}
function findSettings(item) {
while (item) {
if (item.saveValue !== undefined && item.loadValue !== undefined)
return item;
item = item.parent;
}
return null;
}
function normalizePinList(value) {
if (Array.isArray(value))
return value.filter(v => v);
if (typeof value === "string" && value.length > 0)
return [value];
return [];
}
+200 -10
View File
@@ -16,10 +16,6 @@ Singleton {
readonly property int sessionConfigVersion: 3
signal loaded
signal brightnessDisplayHintChanged(string deviceName)
signal loadErrorOccurred(string file, string message)
property bool _parseError: false
property bool _hasLoaded: false
property bool _isReadOnly: false
@@ -228,7 +224,6 @@ Singleton {
property string niriOverviewLastMode: "apps"
property string settingsSidebarExpandedIds: ","
property string settingsSidebarCollapsedIds: ","
property bool showConfigReloadToast: true
Component.onCompleted: {
loadSettings();
@@ -280,14 +275,15 @@ Singleton {
if (typeof Theme !== "undefined")
Theme.generateSystemThemesFromCurrentTheme();
loaded();
if (typeof WallpaperCyclingService !== "undefined")
WallpaperCyclingService.updateCyclingState();
_checkSessionWritable();
} catch (e) {
_parseError = true;
const msg = e.message;
log.error("Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => loadErrorOccurred("session.json", msg));
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("session.json"), msg));
}
}
@@ -361,12 +357,13 @@ Singleton {
if (typeof Theme !== "undefined")
Theme.generateSystemThemesFromCurrentTheme();
loaded();
if (typeof WallpaperCyclingService !== "undefined")
WallpaperCyclingService.updateCyclingState();
} catch (e) {
_parseError = true;
const msg = e.message;
log.error("Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => loadErrorOccurred("session.json", msg));
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("session.json"), msg));
}
}
@@ -467,6 +464,11 @@ Singleton {
saveSettings();
}
function setWallpaperPath(path) {
wallpaperPath = path;
saveSettings();
}
function setWallpaper(imagePath) {
wallpaperPath = imagePath;
if (perModeWallpaper) {
@@ -845,6 +847,11 @@ Singleton {
saveSettings();
}
function setNightModeLocationProvider(provider) {
nightModeLocationProvider = provider;
saveSettings();
}
function setThemeModeAutoEnabled(enabled) {
themeModeAutoEnabled = enabled;
saveSettings();
@@ -933,6 +940,10 @@ Singleton {
setBarPinnedApps(currentPinned);
}
function isBarPinnedApp(appId) {
return appId && barPinnedApps.indexOf(appId) !== -1;
}
function hideTrayId(trayId) {
if (!trayId)
return;
@@ -995,6 +1006,11 @@ Singleton {
saveSettings();
}
function setLaunchPrefix(prefix) {
launchPrefix = prefix;
saveSettings();
}
function setLastBrightnessDevice(device) {
lastBrightnessDevice = device;
saveSettings();
@@ -1009,7 +1025,10 @@ Singleton {
}
brightnessExponentialDevices = newSettings;
saveSettings();
brightnessDisplayHintChanged(deviceName);
if (typeof DisplayService !== "undefined") {
DisplayService.updateDeviceBrightnessDisplay(deviceName);
}
}
function getBrightnessExponential(deviceName) {
@@ -1023,6 +1042,10 @@ Singleton {
saveSettings();
}
function getBrightnessUserSetValue(deviceName) {
return brightnessUserSetValues[deviceName];
}
function clearBrightnessUserSetValue(deviceName) {
var newValues = Object.assign({}, brightnessUserSetValues);
delete newValues[deviceName];
@@ -1046,6 +1069,21 @@ Singleton {
return value !== undefined ? value : 1.2;
}
function setSelectedGpuIndex(index) {
selectedGpuIndex = index;
saveSettings();
}
function setNvidiaGpuTempEnabled(enabled) {
nvidiaGpuTempEnabled = enabled;
saveSettings();
}
function setNonNvidiaGpuTempEnabled(enabled) {
nonNvidiaGpuTempEnabled = enabled;
saveSettings();
}
function setEnabledGpuPciIds(pciIds) {
enabledGpuPciIds = pciIds;
saveSettings();
@@ -1161,6 +1199,15 @@ Singleton {
return deviceMaxVolumes[nodeName] ?? 100;
}
function removeDeviceMaxVolume(nodeName) {
if (!nodeName)
return;
const updated = Object.assign({}, deviceMaxVolumes);
delete updated[nodeName];
deviceMaxVolumes = updated;
saveSettings();
}
function updateLocale() {
if (!locale) {
I18n._pickTranslation();
@@ -1224,6 +1271,12 @@ Singleton {
saveSettings();
}
function clearLauncherHistory() {
launcherLastQuery = "";
launcherSearchHistory = [];
saveSettings();
}
function setAppDrawerLastMode(mode) {
appDrawerLastMode = mode;
saveSettings();
@@ -1363,4 +1416,141 @@ Singleton {
}
}
}
IpcHandler {
target: "wallpaper"
function get(): string {
if (root.perMonitorWallpaper) {
return "ERROR: Per-monitor mode enabled. Use getFor(screenName) instead.";
}
return root.wallpaperPath || "";
}
function set(path: string): string {
if (root.perMonitorWallpaper) {
return "ERROR: Per-monitor mode enabled. Use setFor(screenName, path) instead.";
}
if (!path) {
return "ERROR: No path provided";
}
var absolutePath = path.startsWith("/") ? path : StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/" + path;
try {
root.setWallpaper(absolutePath);
return "SUCCESS: Wallpaper set to " + absolutePath;
} catch (e) {
return "ERROR: Failed to set wallpaper: " + e.toString();
}
}
function clear(): string {
root.setWallpaper("");
root.setPerMonitorWallpaper(false);
root.monitorWallpapers = {};
root.saveSettings();
return "SUCCESS: All wallpapers cleared";
}
function next(): string {
if (root.perMonitorWallpaper) {
return "ERROR: Per-monitor mode enabled. Use nextFor(screenName) instead.";
}
if (!root.wallpaperPath) {
return "ERROR: No wallpaper set";
}
try {
WallpaperCyclingService.cycleNextManually();
return "SUCCESS: Cycling to next wallpaper";
} catch (e) {
return "ERROR: Failed to cycle wallpaper: " + e.toString();
}
}
function prev(): string {
if (root.perMonitorWallpaper) {
return "ERROR: Per-monitor mode enabled. Use prevFor(screenName) instead.";
}
if (!root.wallpaperPath) {
return "ERROR: No wallpaper set";
}
try {
WallpaperCyclingService.cyclePrevManually();
return "SUCCESS: Cycling to previous wallpaper";
} catch (e) {
return "ERROR: Failed to cycle wallpaper: " + e.toString();
}
}
function getFor(screenName: string): string {
if (!screenName) {
return "ERROR: No screen name provided";
}
return root.getMonitorWallpaper(screenName) || "";
}
function setFor(screenName: string, path: string): string {
if (!screenName) {
return "ERROR: No screen name provided";
}
if (!path) {
return "ERROR: No path provided";
}
var absolutePath = path.startsWith("/") ? path : StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/" + path;
try {
if (!root.perMonitorWallpaper) {
root.setPerMonitorWallpaper(true);
}
root.setMonitorWallpaper(screenName, absolutePath);
return "SUCCESS: Wallpaper set for " + screenName + " to " + absolutePath;
} catch (e) {
return "ERROR: Failed to set wallpaper for " + screenName + ": " + e.toString();
}
}
function nextFor(screenName: string): string {
if (!screenName) {
return "ERROR: No screen name provided";
}
var currentWallpaper = root.getMonitorWallpaper(screenName);
if (!currentWallpaper) {
return "ERROR: No wallpaper set for " + screenName;
}
try {
WallpaperCyclingService.cycleNextForMonitor(screenName);
return "SUCCESS: Cycling to next wallpaper for " + screenName;
} catch (e) {
return "ERROR: Failed to cycle wallpaper for " + screenName + ": " + e.toString();
}
}
function prevFor(screenName: string): string {
if (!screenName) {
return "ERROR: No screen name provided";
}
var currentWallpaper = root.getMonitorWallpaper(screenName);
if (!currentWallpaper) {
return "ERROR: No wallpaper set for " + screenName;
}
try {
WallpaperCyclingService.cyclePrevForMonitor(screenName);
return "SUCCESS: Cycling to previous wallpaper for " + screenName;
} catch (e) {
return "ERROR: Failed to cycle wallpaper for " + screenName + ": " + e.toString();
}
}
}
}
+469 -56
View File
@@ -15,7 +15,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 13
readonly property int settingsConfigVersion: 12
enum Position {
Top,
@@ -184,24 +184,6 @@ Singleton {
property int mangoLayoutRadiusOverride: -1
property int mangoLayoutBorderSize: -1
property bool mangoTrackpadNaturalScrolling: true
property string mouseAccelProfile: "default"
property real mouseAccelSpeed: 0.0
property bool mouseLeftHanded: false
property bool mouseMiddleEmulation: false
property bool mouseNaturalScroll: false
property real mouseScrollFactor: 1.0
property string mouseScrollMethod: "default"
property string touchpadAccelProfile: "default"
property real touchpadAccelSpeed: 0.0
property bool touchpadDisableOnExternalMouse: false
property bool touchpadDisableWhileTyping: true
property bool touchpadDragLock: false
property bool touchpadMiddleEmulation: false
property bool touchpadNaturalScroll: true
property real touchpadScrollFactor: 1.0
property string touchpadScrollMethod: "default"
property bool touchpadTapAndDrag: true
property bool touchpadTapToClick: true
property int firstDayOfWeek: -1
property bool showWeekNumber: false
@@ -712,23 +694,16 @@ Singleton {
property bool notepadUseCompositorGap: false
property int notepadEdgeGap: 0
property string activeCompositor: ""
// Compositor layout gap when enabled and available, else the manual value.
readonly property int notepadEffectiveEdgeGap: {
if (notepadUseCompositorGap) {
var g = -1;
switch (activeCompositor) {
case "niri":
if (CompositorService.isNiri)
g = niriLayoutGapsOverride;
break;
case "hyprland":
else if (CompositorService.isHyprland)
g = hyprlandLayoutGapsOverride;
break;
case "mango":
else if (CompositorService.isMango)
g = mangoLayoutGapsOverride;
break;
}
if (g >= 0)
return g;
}
@@ -797,6 +772,11 @@ Singleton {
property bool fadeToDpmsEnabled: true
property int fadeToDpmsGracePeriod: 5
property string launchPrefix: ""
property var brightnessDevicePins: ({})
property var wifiNetworkPins: ({})
property var bluetoothDevicePins: ({})
property var audioInputDevicePins: ({})
property var audioOutputDevicePins: ({})
property bool gtkThemingEnabled: false
property bool qtThemingEnabled: false
@@ -936,7 +916,6 @@ Singleton {
property int notificationTimeoutLow: 5000
property int notificationTimeoutNormal: 5000
property int notificationTimeoutCritical: 0
property bool notificationIgnoreAppTimeout: false
property bool notificationCompactMode: false
property bool notificationShowTimeoutBar: false
property bool notificationDedupeEnabled: true
@@ -1133,6 +1112,44 @@ Singleton {
saveSettings();
}
function getSystemMonitorVariants() {
return systemMonitorVariants || [];
}
function createSystemMonitorVariant(name, config) {
const id = "sysmon_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9);
const variant = {
id: id,
name: name,
config: config || getDefaultSystemMonitorConfig()
};
const variants = JSON.parse(JSON.stringify(systemMonitorVariants || []));
variants.push(variant);
systemMonitorVariants = variants;
saveSettings();
return variant;
}
function updateSystemMonitorVariant(variantId, updates) {
const variants = JSON.parse(JSON.stringify(systemMonitorVariants || []));
const idx = variants.findIndex(v => v.id === variantId);
if (idx === -1)
return;
Object.assign(variants[idx], updates);
systemMonitorVariants = variants;
saveSettings();
}
function removeSystemMonitorVariant(variantId) {
const variants = (systemMonitorVariants || []).filter(v => v.id !== variantId);
systemMonitorVariants = variants;
saveSettings();
}
function getSystemMonitorVariant(variantId) {
return (systemMonitorVariants || []).find(v => v.id === variantId) || null;
}
function getDefaultSystemMonitorConfig() {
return {
showHeader: true,
@@ -1273,6 +1290,70 @@ Singleton {
return (desktopWidgetInstances || []).find(inst => inst.id === instanceId) || null;
}
function getDesktopWidgetInstancesOfType(widgetType) {
return (desktopWidgetInstances || []).filter(inst => inst.widgetType === widgetType);
}
function getEnabledDesktopWidgetInstances() {
return (desktopWidgetInstances || []).filter(inst => inst.enabled);
}
function moveDesktopWidgetInstance(instanceId, direction) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const idx = instances.findIndex(inst => inst.id === instanceId);
if (idx === -1)
return false;
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= instances.length)
return false;
const temp = instances[idx];
instances[idx] = instances[targetIdx];
instances[targetIdx] = temp;
desktopWidgetInstances = instances;
saveSettings();
return true;
}
function reorderDesktopWidgetInstance(instanceId, newIndex) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const idx = instances.findIndex(inst => inst.id === instanceId);
if (idx === -1 || newIndex < 0 || newIndex >= instances.length)
return false;
const [item] = instances.splice(idx, 1);
instances.splice(newIndex, 0, item);
desktopWidgetInstances = instances;
saveSettings();
return true;
}
function reorderDesktopWidgetInstanceInGroup(instanceId, groupId, newIndexInGroup) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const groups = desktopWidgetGroups || [];
const groupMatches = inst => {
if (groupId === null)
return !inst.group || !groups.some(g => g.id === inst.group);
return inst.group === groupId;
};
const groupInstances = instances.filter(groupMatches);
const currentGroupIdx = groupInstances.findIndex(inst => inst.id === instanceId);
if (currentGroupIdx === -1 || currentGroupIdx === newIndexInGroup)
return false;
if (newIndexInGroup < 0 || newIndexInGroup >= groupInstances.length)
return false;
const globalIdx = instances.findIndex(inst => inst.id === instanceId);
if (globalIdx === -1)
return false;
const [item] = instances.splice(globalIdx, 1);
const targetInstance = groupInstances[newIndexInGroup];
let targetGlobalIdx = instances.findIndex(inst => inst.id === targetInstance.id);
if (newIndexInGroup > currentGroupIdx)
targetGlobalIdx++;
instances.splice(targetGlobalIdx, 0, item);
desktopWidgetInstances = instances;
saveSettings();
return true;
}
function moveDesktopWidgetInstanceToGroup(instanceId, groupId, newIndexInGroup) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const groups = desktopWidgetGroups || [];
@@ -1338,14 +1419,22 @@ Singleton {
saveSettings();
}
function getDesktopWidgetGroup(groupId) {
return (desktopWidgetGroups || []).find(g => g.id === groupId) || null;
}
function getDesktopWidgetInstancesByGroup(groupId) {
return (desktopWidgetInstances || []).filter(inst => inst.group === groupId);
}
function getUngroupedDesktopWidgetInstances() {
return (desktopWidgetInstances || []).filter(inst => !inst.group);
}
signal forceDankBarLayoutRefresh
signal forceDockLayoutRefresh
signal widgetDataChanged
signal workspaceIconsUpdated
signal compositorLayoutRefreshNeeded(bool frame)
signal compositorInputRefreshNeeded
signal compositorCursorRefreshNeeded
signal notificationPopupsInvalidated
function refreshAuthAvailability() {
Processes.detectAuthCapabilities();
@@ -1380,15 +1469,24 @@ Singleton {
}
function updateCompositorLayout() {
compositorLayoutRefreshNeeded(false);
}
function updateCompositorInput() {
compositorInputRefreshNeeded();
if (typeof CompositorService === "undefined")
return;
if (CompositorService.isNiri && typeof NiriService !== "undefined")
NiriService.generateNiriLayoutConfig();
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined")
HyprlandService.generateLayoutConfig();
if (CompositorService.isMango && typeof MangoService !== "undefined")
MangoService.generateLayoutConfig();
}
function updateFrameCompositorLayout() {
compositorLayoutRefreshNeeded(true);
// Generate before begin() so compositor readiness is already pending at transitionRequested
if (typeof CompositorService !== "undefined") {
if (CompositorService.isNiri && typeof NiriService !== "undefined")
NiriService.generateNiriLayoutConfig(true);
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined")
HyprlandService.generateLayoutConfig(true);
}
FrameTransitionState.begin();
}
@@ -1609,7 +1707,6 @@ Singleton {
"applyStoredTheme": applyStoredTheme,
"regenSystemThemes": regenSystemThemes,
"updateCompositorLayout": updateCompositorLayout,
"updateCompositorInput": updateCompositorInput,
"applyStoredIconTheme": applyStoredIconTheme,
"updateBarConfigs": updateBarConfigs,
"updateCompositorCursor": updateCompositorCursor,
@@ -1633,7 +1730,6 @@ Singleton {
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
const oldVersion = obj?.configVersion ?? 0;
const legacyPins = oldVersion < 13 ? Store.extractPins(obj) : null;
if (oldVersion < settingsConfigVersion) {
const migrated = Store.migrateToVersion(obj, settingsConfigVersion);
if (migrated) {
@@ -1641,8 +1737,6 @@ Singleton {
obj = migrated;
}
}
if (legacyPins)
Qt.callLater(() => CacheData.migratePins(legacyPins));
if (obj?.lockScreenActiveMonitor !== undefined) {
var oldVal = obj.lockScreenActiveMonitor;
@@ -2041,6 +2135,14 @@ Singleton {
return showSeconds ? "h:mm:ss AP" : "h:mm AP";
}
function getEffectiveClockDateFormat() {
return clockDateFormat && clockDateFormat.length > 0 ? clockDateFormat : "ddd d";
}
function getEffectiveLockDateFormat() {
return lockDateFormat && lockDateFormat.length > 0 ? lockDateFormat : Locale.LongFormat;
}
function initializeListModels() {
const defaultBar = barConfigs[0] || getBarConfig("default");
if (defaultBar) {
@@ -2053,6 +2155,39 @@ Singleton {
widgetDataChanged();
}
function hasNamedWorkspaces() {
if (typeof NiriService === "undefined" || !CompositorService.isNiri)
return false;
for (var i = 0; i < NiriService.allWorkspaces.length; i++) {
var ws = NiriService.allWorkspaces[i];
if (ws.name && ws.name.trim() !== "")
return true;
}
return false;
}
function getNamedWorkspaces() {
var namedWorkspaces = [];
if (typeof NiriService === "undefined" || !CompositorService.isNiri)
return namedWorkspaces;
for (const ws of NiriService.allWorkspaces) {
if (ws.name && ws.name.trim() !== "") {
namedWorkspaces.push(ws.name);
}
}
return namedWorkspaces;
}
function getPopupYPosition(barHeight) {
const defaultBar = barConfigs[0] || getBarConfig("default");
const gothOffset = defaultBar?.gothCornersEnabled ? Theme.cornerRadius : 0;
const spacing = defaultBar?.spacing ?? 4;
const bottomGap = defaultBar?.bottomGap ?? 0;
return barHeight + spacing + bottomGap - gothOffset + Theme.popupDistance;
}
function getPopupTriggerPosition(pos, screen, barThickness, widgetWidth, barSpacing, barPosition, barConfig) {
const relativeX = pos.x;
const relativeY = pos.y;
@@ -2346,10 +2481,51 @@ Singleton {
updateBarConfigs();
if (positionChanged) {
notificationPopupsInvalidated();
NotificationService.dismissAllPopups();
}
}
function checkBarCollisions(barId) {
const bar = getBarConfig(barId);
if (!bar || !bar.enabled)
return [];
const conflicts = [];
const enabledBars = getEnabledBarConfigs();
for (var i = 0; i < enabledBars.length; i++) {
const other = enabledBars[i];
if (other.id === barId)
continue;
const samePosition = bar.position === other.position;
if (!samePosition)
continue;
const barScreens = bar.screenPreferences || ["all"];
const otherScreens = other.screenPreferences || ["all"];
const hasAll = barScreens.includes("all") || otherScreens.includes("all");
if (hasAll) {
conflicts.push({
"barId": other.id,
"barName": other.name,
"reason": "Same position on all screens"
});
continue;
}
const overlapping = barScreens.some(screen => otherScreens.includes(screen));
if (overlapping) {
conflicts.push({
"barId": other.id,
"barName": other.name,
"reason": "Same position on overlapping screens"
});
}
}
return conflicts;
}
function deleteBarConfig(barId) {
if (barId === "default")
return;
@@ -2498,6 +2674,38 @@ Singleton {
return filtered;
}
function getFrameFilteredScreens() {
var prefs = frameScreenPreferences || ["all"];
if (!prefs || prefs.length === 0 || prefs.includes("all")) {
return Quickshell.screens;
}
return Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs));
}
function getActiveBarEdgeForScreen(screen) {
if (!screen)
return "";
for (var i = 0; i < barConfigs.length; i++) {
var bc = barConfigs[i];
if (!bc.enabled)
continue;
var prefs = bc.screenPreferences || ["all"];
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
continue;
switch (bc.position ?? 0) {
case SettingsData.Position.Top:
return "top";
case SettingsData.Position.Bottom:
return "bottom";
case SettingsData.Position.Left:
return "left";
case SettingsData.Position.Right:
return "right";
}
}
return "";
}
function getActiveBarEdgesForScreen(screen) {
if (!screen)
return [];
@@ -2556,19 +2764,51 @@ Singleton {
return edges;
}
readonly property real frameBarContentGap: frameBarInsetPadding < 0 ? frameThickness : frameBarInsetPadding
readonly property real frameBarContentGapExtra: Math.max(0, frameBarContentGap - frameThickness)
function frameEdgeReservation(screen, edge) {
if (!screen)
function frameEdgeInsetForSide(screen, side) {
if (!frameEnabled || !screen)
return 0;
return getActiveBarEdgesForScreen(screen).includes(edge) ? frameBarSize : frameThickness;
const edges = getActiveBarEdgesForScreen(screen);
return edges.includes(side) ? frameBarSize : frameThickness;
}
function frameEdgeInsetForSide(screen, side) {
if (!frameEnabled)
return 0;
return frameEdgeReservation(screen, side);
function getActiveBarThicknessForScreen(screen) {
if (frameEnabled)
return frameBarSize;
if (!screen)
return frameThickness;
for (var i = 0; i < barConfigs.length; i++) {
var bc = barConfigs[i];
if (!bc.enabled)
continue;
var prefs = bc.screenPreferences || ["all"];
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
continue;
const innerPadding = bc.innerPadding ?? 4;
const barT = Math.max(26 + innerPadding * 0.6, Theme.barHeight - 4 - (8 - innerPadding));
const spacing = bc.spacing ?? 4;
const bottomGap = bc.bottomGap ?? 0;
return barT + spacing + bottomGap;
}
return frameThickness;
}
function sendTestNotifications() {
NotificationService.dismissAllPopups();
sendTestNotification(0);
testNotifTimer1.start();
testNotifTimer2.start();
}
function sendTestNotification(index) {
const notifications = [["Notification Position Test", "DMS test notification 1 of 3 ~ Hi there!", "preferences-system"], ["Second Test", "DMS Notification 2 of 3 ~ Check it out!", "applications-graphics"], ["Third Test", "DMS notification 3 of 3 ~ Enjoy!", "face-smile"]];
if (index < 0 || index >= notifications.length) {
return;
}
const notif = notifications[index];
testNotificationProcess.command = ["notify-send", "-h", "int:transient:1", "-a", "DMS", "-i", notif[2], notif[0], notif[1]];
testNotificationProcess.running = true;
}
function setMatugenScheme(scheme) {
@@ -2587,6 +2827,15 @@ Singleton {
set("matugenContrast", value);
}
function setRunUserMatugenTemplates(enabled) {
if (runUserMatugenTemplates === enabled)
return;
set("runUserMatugenTemplates", enabled);
if (typeof Theme !== "undefined") {
Theme.generateSystemThemesFromCurrentTheme();
}
}
function setMatugenTargetMonitor(monitorName) {
if (matugenTargetMonitor === monitorName)
return;
@@ -2605,6 +2854,11 @@ Singleton {
SessionData.setWeatherLocation(displayName, coordinates);
}
function setIconTheme(themeName) {
const light = iconThemePerMode && typeof SessionData !== "undefined" && SessionData.isLightMode;
setIconThemeForMode(themeName, light);
}
function setIconThemeForMode(themeName, light) {
if (light)
iconThemeLight = themeName;
@@ -2650,7 +2904,20 @@ Singleton {
// https://github.com/Supreeeme/xwayland-satellite/issues/104
// no idea if this matters on other compositors but we also set XCURSOR stuff in the launcher
function updateCompositorCursor() {
compositorCursorRefreshNeeded();
if (typeof CompositorService === "undefined")
return;
if (CompositorService.isNiri && typeof NiriService !== "undefined") {
NiriService.generateNiriCursorConfig();
return;
}
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined") {
HyprlandService.generateCursorConfig();
return;
}
if (CompositorService.isMango && typeof MangoService !== "undefined") {
MangoService.generateCursorConfig();
return;
}
}
function updateXResources() {
@@ -2718,6 +2985,20 @@ Singleton {
return env;
}
function setGtkThemingEnabled(enabled) {
set("gtkThemingEnabled", enabled);
if (enabled && typeof Theme !== "undefined") {
Theme.generateSystemThemesFromCurrentTheme();
}
}
function setQtThemingEnabled(enabled) {
set("qtThemingEnabled", enabled);
if (enabled && typeof Theme !== "undefined") {
Theme.generateSystemThemesFromCurrentTheme();
}
}
function setShowDock(enabled) {
showDock = enabled;
const defaultBar = barConfigs[0] || getBarConfig("default");
@@ -2763,6 +3044,16 @@ Singleton {
Qt.callLater(() => forceDockLayoutRefresh());
}
function setDankBarSpacing(spacing) {
const defaultBar = barConfigs[0] || getBarConfig("default");
if (defaultBar) {
updateBarConfig(defaultBar.id, {
"spacing": spacing
});
}
updateCompositorLayout();
}
function setDankBarPosition(position) {
const defaultBar = barConfigs[0] || getBarConfig("default");
if (!defaultBar)
@@ -2818,6 +3109,39 @@ Singleton {
}
}
function resetDankBarWidgetsToDefault() {
var defaultLeft = ["launcherButton", "workspaceSwitcher", "focusedWindow"];
var defaultCenter = ["music", "clock", "weather"];
var defaultRight = ["systemTray", "clipboard", "notificationButton", "battery", "controlCenterButton"];
const defaultBar = barConfigs[0] || getBarConfig("default");
if (defaultBar) {
updateBarConfig(defaultBar.id, {
"leftWidgets": defaultLeft,
"centerWidgets": defaultCenter,
"rightWidgets": defaultRight
});
}
updateListModel(leftWidgetsModel, defaultLeft);
updateListModel(centerWidgetsModel, defaultCenter);
updateListModel(rightWidgetsModel, defaultRight);
showLauncherButton = true;
showWorkspaceSwitcher = true;
showFocusedWindow = true;
showWeather = true;
showMusic = true;
showClipboard = true;
showCpuUsage = true;
showMemUsage = true;
showCpuTemp = true;
showGpuTemp = true;
showSystemTray = true;
showClock = true;
showNotificationButton = true;
showBattery = true;
showControlCenterButton = true;
showCapsLockIndicator = true;
}
function setWorkspaceNameIcon(workspaceName, iconData) {
var iconMap = JSON.parse(JSON.stringify(workspaceNameIcons));
iconMap[workspaceName] = iconData;
@@ -3099,6 +3423,15 @@ Singleton {
Theme.reloadCustomThemeVariant();
}
function toggleDankBarVisible() {
const defaultBar = barConfigs[0] || getBarConfig("default");
if (defaultBar) {
updateBarConfig(defaultBar.id, {
"visible": !defaultBar.visible
});
}
}
function toggleShowDock() {
setShowDock(!showDock);
}
@@ -3120,6 +3453,13 @@ Singleton {
savePluginSettings();
}
function removePluginSettings(pluginId) {
if (pluginSettings[pluginId]) {
delete pluginSettings[pluginId];
savePluginSettings();
}
}
function getPluginSettingsForPlugin(pluginId) {
const settings = pluginSettings[pluginId];
return settings ? JSON.parse(JSON.stringify(settings)) : {};
@@ -3145,6 +3485,22 @@ Singleton {
return settings ? JSON.parse(JSON.stringify(settings)) : {};
}
function setNiriOutputSettings(outputId, settings) {
const updated = JSON.parse(JSON.stringify(niriOutputSettings));
updated[outputId] = settings;
niriOutputSettings = updated;
saveSettings();
}
function removeNiriOutputSettings(outputId) {
if (!niriOutputSettings[outputId])
return;
const updated = JSON.parse(JSON.stringify(niriOutputSettings));
delete updated[outputId];
niriOutputSettings = updated;
saveSettings();
}
function getHyprlandOutputSetting(outputId, key, defaultValue) {
if (!hyprlandOutputSettings[outputId])
return defaultValue;
@@ -3169,6 +3525,40 @@ Singleton {
saveSettings();
}
function getHyprlandOutputSettings(outputId) {
const settings = hyprlandOutputSettings[outputId];
return settings ? JSON.parse(JSON.stringify(settings)) : {};
}
function setHyprlandOutputSettings(outputId, settings) {
const updated = JSON.parse(JSON.stringify(hyprlandOutputSettings));
updated[outputId] = settings;
hyprlandOutputSettings = updated;
saveSettings();
}
function removeHyprlandOutputSettings(outputId) {
if (!hyprlandOutputSettings[outputId])
return;
const updated = JSON.parse(JSON.stringify(hyprlandOutputSettings));
delete updated[outputId];
hyprlandOutputSettings = updated;
saveSettings();
}
function getDisplayProfiles(compositor) {
return displayProfiles[compositor] || {};
}
function setDisplayProfile(compositor, profileId, data) {
const updated = JSON.parse(JSON.stringify(displayProfiles));
if (!updated[compositor])
updated[compositor] = {};
updated[compositor][profileId] = data;
displayProfiles = updated;
saveSettings();
}
function removeDisplayProfile(compositor, profileId) {
if (!displayProfiles[compositor] || !displayProfiles[compositor][profileId])
return;
@@ -3222,6 +3612,29 @@ Singleton {
id: rightWidgetsModel
}
property Process testNotificationProcess
testNotificationProcess: Process {
command: []
running: false
}
property Timer testNotifTimer1
testNotifTimer1: Timer {
interval: 400
repeat: false
onTriggered: sendTestNotification(1)
}
property Timer testNotifTimer2
testNotifTimer2: Timer {
interval: 800
repeat: false
onTriggered: sendTestNotification(2)
}
property alias settingsFile: settingsFile
Timer {
+26
View File
@@ -416,6 +416,24 @@ const StockThemes = {
},
};
const ThemeCategories = {
GENERIC: {
name: "Generic",
variants: [
"blue",
"purple",
"green",
"orange",
"red",
"cyan",
"pink",
"amber",
"coral",
"monochrome",
],
},
};
const ThemeNames = {
BLUE: "blue",
PURPLE: "purple",
@@ -430,6 +448,10 @@ const ThemeNames = {
DYNAMIC: "dynamic",
};
function isStockTheme(themeName) {
return Object.keys(StockThemes.DARK).includes(themeName);
}
function getAvailableThemes(isLight = false) {
return isLight ? StockThemes.LIGHT : StockThemes.DARK;
}
@@ -442,3 +464,7 @@ function getThemeByName(themeName, isLight = false) {
function getAllThemeNames() {
return Object.keys(StockThemes.DARK);
}
function getThemeCategories() {
return ThemeCategories;
}
+605 -64
View File
@@ -107,11 +107,8 @@ Singleton {
property int _colorsRetryCount: 0
property double _lastGenerateMs: 0
property bool blurLayersActive: false
property bool matugenToastSuppressed: false
signal screenTransitionNeeded
signal themeGenerationStarting
property bool themeModeAutomationActive: false
property bool dmsServiceWasDisconnected: true
readonly property var dank16: {
const raw = matugenColors?.dank16;
@@ -147,7 +144,7 @@ Singleton {
Quickshell.execDetached(["mkdir", "-p", stateDir]);
// shellDir may be an embedded-UI extraction, which is read-only and
// unexecutable (dankgo shellapp/shellfs makeReadOnly chmods 0444)
Quickshell.execDetached(["bash", shellDir + "/scripts/gtk.sh", configDir, "assets", "", shellDir]);
Quickshell.execDetached(["bash", shellDir + "/scripts/gtk.sh", configDir, "", shellDir, "assets-only"]);
Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => {
matugenAvailable = (code === 0) && !envDisableMatugen;
@@ -199,6 +196,235 @@ Singleton {
const currentIsLight = (typeof SessionData !== "undefined") ? SessionData.isLightMode : false;
SettingsData.updateCosmicThemeMode(currentIsLight);
}
if (typeof SessionData !== "undefined" && SessionData.themeModeAutoEnabled) {
startThemeModeAutomation();
}
}
Connections {
target: SessionData
enabled: typeof SessionData !== "undefined"
function onThemeModeAutoEnabledChanged() {
if (SessionData.themeModeAutoEnabled) {
root.startThemeModeAutomation();
} else {
root.stopThemeModeAutomation();
}
}
function onThemeModeAutoModeChanged() {
if (root.themeModeAutomationActive) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
root.syncLocationThemeSchedule();
}
}
function onThemeModeStartHourChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeStartMinuteChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeEndHourChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeEndMinuteChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeShareGammaSettingsChanged() {
if (root.themeModeAutomationActive) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
root.syncLocationThemeSchedule();
}
}
function onNightModeStartHourChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onNightModeStartMinuteChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onNightModeEndHourChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onNightModeEndMinuteChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onLatitudeChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0 && typeof DMSService !== "undefined") {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
root.evaluateThemeMode();
root.syncLocationThemeSchedule();
}
}
function onLongitudeChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0 && typeof DMSService !== "undefined") {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
root.evaluateThemeMode();
root.syncLocationThemeSchedule();
}
}
function onNightModeUseIPLocationChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
if (typeof DMSService !== "undefined") {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": SessionData.nightModeUseIPLocation
}, response => {
if (!response.error && !SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
});
}
root.evaluateThemeMode();
root.syncLocationThemeSchedule();
}
}
}
// React to gamma backend's isDay state changes for location-based mode
Connections {
target: DisplayService
enabled: typeof DisplayService !== "undefined" && typeof SessionData !== "undefined" && SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "location" && !themeAutoBackendAvailable()
function onGammaIsDayChanged() {
if (root.isLightMode !== DisplayService.gammaIsDay) {
root.setLightMode(DisplayService.gammaIsDay, true, true);
}
}
}
Connections {
target: DMSService
function onThemeAutoStateUpdate(data) {
if (!SessionData.themeModeAutoEnabled) {
return;
}
applyThemeAutoState(data);
}
function onConnectionStateChanged() {
if (DMSService.isConnected && SessionData.themeModeAutoMode === "time") {
root.syncTimeThemeSchedule();
}
if (DMSService.isConnected && SessionData.themeModeAutoMode === "location") {
root.syncLocationThemeSchedule();
}
if (themeAutoBackendAvailable() && SessionData.themeModeAutoEnabled) {
DMSService.sendRequest("theme.auto.getState", null, response => {
if (response && response.result) {
applyThemeAutoState(response.result);
}
});
}
if (!SessionData.themeModeAutoEnabled) {
return;
}
if (DMSService.isConnected && SessionData.themeModeAutoMode === "location") {
if (SessionData.nightModeUseIPLocation) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": true
}, response => {
if (!response.error) {
log.info("Theme automation: IP location enabled after connection");
}
});
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": false
}, response => {
if (!response.error) {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
}, locationResponse => {
if (locationResponse?.error) {
log.warn("Theme automation: Failed to set location", locationResponse.error);
}
});
}
});
} else {
log.warn("Theme automation: No location configured");
}
}
}
}
Connections {
target: SessionService
enabled: SessionData.themeModeAutoEnabled
function onSessionUnlocked() {
root.triggerThemeAutomationRefresh();
}
function onSessionResumed() {
root.triggerThemeAutomationRefresh();
}
}
function triggerThemeAutomationRefresh() {
if (!themeAutoBackendAvailable()) {
root.evaluateThemeMode();
return;
}
DMSService.sendRequest("theme.auto.trigger", {});
}
function getMatugenColor(path, fallback) {
@@ -345,8 +571,8 @@ Singleton {
property color surfaceVariantAlpha: withAlpha(surfaceVariant, 0.2)
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
readonly property bool blurForegroundLayers: blurLayersActive && foregroundLayers
readonly property bool transparentBlurLayers: blurLayersActive && !foregroundLayers
readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers
readonly property bool transparentBlurLayers: BlurService.enabled && !foregroundLayers
readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true)
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
@@ -432,7 +658,7 @@ Singleton {
}
}
readonly property color ccTileInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.16) : (foregroundLayers ? withAlpha(surfaceContainerHigh, blurLayersActive ? Math.min(popupTransparency, 0.24) : popupTransparency) : withAlpha(surfaceContainer, 0))
readonly property color ccTileInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.16) : (foregroundLayers ? withAlpha(surfaceContainerHigh, BlurService.enabled ? Math.min(popupTransparency, 0.24) : popupTransparency) : withAlpha(surfaceContainer, 0))
readonly property color ccPillInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.08) : nestedSurface
readonly property color ccPillInactiveHoverBg: transparentBlurLayers ? withAlpha(primary, 0.10) : primaryPressed
readonly property color ccSliderTrackColor: transparentBlurLayers ? surfaceText : surfaceContainerHigh
@@ -752,6 +978,22 @@ Singleton {
};
}
function elevationTintOpacity(level) {
if (!level)
return 0;
if (level === elevationLevel1)
return 0.05;
if (level === elevationLevel2)
return 0.08;
if (level === elevationLevel3)
return 0.11;
if (level === elevationLevel4)
return 0.12;
if (level === elevationLevel5)
return 0.14;
return 0.08;
}
readonly property var animationDurations: [
{
"shorter": 0,
@@ -1019,7 +1261,9 @@ Singleton {
}
function screenTransition() {
screenTransitionNeeded();
if (CompositorService.isNiri) {
NiriService.doScreenTransition();
}
}
function switchTheme(themeName, savePrefs = true, enableTransition = true) {
@@ -1071,6 +1315,7 @@ Singleton {
SessionData.setLightMode(light);
}
PortalService.setLightMode(light);
if (typeof SettingsData !== "undefined") {
SettingsData.updateCosmicThemeMode(light);
}
@@ -1081,6 +1326,22 @@ Singleton {
setLightMode(!isLightMode, savePrefs, true);
}
function forceGenerateSystemThemes() {
if (!matugenAvailable) {
return;
}
generateSystemThemesFromCurrentTheme();
}
function getAvailableThemes() {
return StockThemes.getAllThemeNames();
}
function getThemeDisplayName(themeName) {
const themeData = StockThemes.getThemeByName(themeName, isLightMode);
return themeData.name;
}
function getThemeColors(themeName) {
if (themeName === "custom" && customThemeData) {
return customThemeData;
@@ -1196,6 +1457,10 @@ Singleton {
readonly property var _availableThemeNames: StockThemes.getAllThemeNames()
property string currentThemeName: currentTheme
function panelBackground() {
return withAlpha(surfaceContainer, panelTransparency);
}
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : popupTransparency
property bool widgetBackgroundHasAlpha: {
@@ -1272,6 +1537,10 @@ Singleton {
}
}
function isColorDark(c) {
return (0.299 * c.r + 0.587 * c.g + 0.114 * c.b) < 0.5;
}
function barIconSize(barThickness, offset, maximizeIcon, iconScale) {
const defaultOffset = offset !== undefined ? offset : -6;
const size = (maximizeIcon ?? false) ? iconSizeLarge : iconSize;
@@ -1354,6 +1623,19 @@ Singleton {
}
}
function getPowerProfileDescription(profile) {
switch (profile) {
case 0:
return I18n.tr("Extend battery life", "power profile description");
case 1:
return I18n.tr("Balance power and performance", "power profile description");
case 2:
return I18n.tr("Prioritize performance", "power profile description");
default:
return I18n.tr("Custom power profile", "power profile description");
}
}
function onLightModeChanged() {
if (currentTheme === "custom" && customThemeFileView.path) {
customThemeFileView.reload();
@@ -1381,7 +1663,9 @@ Singleton {
log.info("Setting desired theme -", kind, "mode:", isLight ? "light" : "dark", stockColors ? "(stock colors)" : "(dynamic)");
themeGenerationStarting();
if (typeof NiriService !== "undefined" && CompositorService.isNiri) {
NiriService.suppressNextToast();
}
const desired = {
"kind": kind,
@@ -1493,7 +1777,8 @@ Singleton {
if (currentTheme === dynamic) {
if (!rawWallpaperPath) {
log.warn("Auto theme has no wallpaper - skipping matugen");
log.warn("Auto theme has no wallpaper - skipping matugen, syncing portal mode only");
PortalService.setLightMode(isLight);
return;
}
const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot";
@@ -1641,47 +1926,6 @@ Singleton {
return colors;
}
function refreshGtkTheme() {
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
const theme = isLight ? "adw-gtk3" : "adw-gtk3-dark";
const schema = "org.gnome.desktop.interface";
const key = "gtk-theme";
const makeCmd = (tool, schema, val) => {
if (tool === "gsettings") {
return `gsettings set ${schema} ${key} '' && gsettings set ${schema} ${key} ${val}`;
} else {
const dconfPath = `/${schema.replace(/\./g, "/")}`;
return `dconf write ${dconfPath}/${key} "''" && dconf write ${dconfPath}/${key} "'${val}'"`;
}
};
Proc.runCommand("gtkRefresher", ["sh", "-c", makeCmd("gsettings", schema, theme)], (output, exitCode) => {
if (exitCode !== 0) {
Proc.runCommand("gtkRefreshFallback", ["sh", "-c", makeCmd("dconf", schema, theme)], (output, exitCode) => {
if (exitCode !== 0) {
log.warn("Failed to refresh gtk-theme");
}
});
}
});
}
function patchGtk3colors() {
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
Proc.runCommand("gtk3Patcher", ["bash", shellDir + "/scripts/gtk.sh", configDir, "patch", isLight, shellDir], (output, exitCode) => {
switch (exitCode) {
case 0:
refreshGtkTheme();
break;
case 2:
break;
default:
log.warn(`Failed to patch GTK3 colors: ${output}`);
}
});
}
function applyGtkColors() {
if (!matugenAvailable) {
if (typeof ToastService !== "undefined") {
@@ -1691,9 +1935,9 @@ Singleton {
}
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "true" : "false";
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, "apply", isLight, shellDir], (output, exitCode) => {
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, isLight, shellDir], (output, exitCode) => {
if (exitCode === 0) {
if (typeof ToastService !== "undefined" && !root.matugenToastSuppressed) {
if (typeof ToastService !== "undefined" && typeof NiriService !== "undefined" && !NiriService.matugenSuppression) {
ToastService.showInfo(I18n.tr("GTK colors applied successfully"));
}
} else {
@@ -1731,17 +1975,18 @@ Singleton {
return Qt.rgba(c.r, c.g, c.b, a);
}
function popupLayerColor(baseColor) {
if (isConnectedEffect)
return connectedSurfaceColor;
return withAlpha(baseColor, popupTransparency);
}
function blendAlpha(c, a) {
if (!c || c.r === undefined)
return Qt.rgba(0, 0, 0, 0);
return Qt.rgba(c.r, c.g, c.b, c.a * a);
}
function hoverTint(base) {
const factor = 1.2;
return isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
}
function blend(c1, c2, r) {
return Qt.rgba(c1.r * (1 - r) + c2.r * r, c1.g * (1 - r) + c2.g * r, c1.b * (1 - r) + c2.b * r, c1.a * (1 - r) + c2.a * r);
}
@@ -1886,11 +2131,8 @@ Singleton {
root.matugenCompleted(currentMode, "error");
}
if (!pendingThemeRequest) {
if (SettingsData.matugenTemplateGtk)
patchGtk3colors();
if (!pendingThemeRequest)
return;
}
const req = pendingThemeRequest;
pendingThemeRequest = null;
@@ -2058,4 +2300,303 @@ Singleton {
root.switchTheme(defaultTheme, true, false);
}
}
// Theme mode automation functions
function themeAutoBackendAvailable() {
return typeof DMSService !== "undefined" && DMSService.isConnected && Array.isArray(DMSService.capabilities) && DMSService.capabilities.includes("theme.auto");
}
function applyThemeAutoState(state) {
if (!state) {
return;
}
if (state.config && state.config.mode && state.config.mode !== SessionData.themeModeAutoMode) {
return;
}
if (typeof SessionData !== "undefined" && state.nextTransition !== undefined) {
SessionData.themeModeNextTransition = state.nextTransition || "";
}
if (state.isLight !== undefined && root.isLightMode !== state.isLight) {
root.setLightMode(state.isLight, true, true);
}
}
function syncTimeThemeSchedule() {
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
return;
}
if (!DMSService.isConnected) {
return;
}
const timeModeActive = SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "time";
if (!timeModeActive) {
return;
}
DMSService.sendRequest("theme.auto.setMode", {
"mode": "time"
});
const shareSettings = SessionData.themeModeShareGammaSettings;
const startHour = shareSettings ? SessionData.nightModeStartHour : SessionData.themeModeStartHour;
const startMinute = shareSettings ? SessionData.nightModeStartMinute : SessionData.themeModeStartMinute;
const endHour = shareSettings ? SessionData.nightModeEndHour : SessionData.themeModeEndHour;
const endMinute = shareSettings ? SessionData.nightModeEndMinute : SessionData.themeModeEndMinute;
DMSService.sendRequest("theme.auto.setSchedule", {
"startHour": startHour,
"startMinute": startMinute,
"endHour": endHour,
"endMinute": endMinute
}, response => {
if (response && response.error) {
log.error("Theme automation: Failed to sync time schedule:", response.error);
}
});
DMSService.sendRequest("theme.auto.setEnabled", {
"enabled": true
});
DMSService.sendRequest("theme.auto.trigger", {});
}
function syncLocationThemeSchedule() {
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
return;
}
if (!DMSService.isConnected) {
return;
}
const locationModeActive = SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "location";
if (!locationModeActive) {
return;
}
DMSService.sendRequest("theme.auto.setMode", {
"mode": "location"
});
if (SessionData.nightModeUseIPLocation) {
DMSService.sendRequest("theme.auto.setUseIPLocation", {
"use": true
});
} else {
DMSService.sendRequest("theme.auto.setUseIPLocation", {
"use": false
});
if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("theme.auto.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
}
DMSService.sendRequest("theme.auto.setEnabled", {
"enabled": true
});
DMSService.sendRequest("theme.auto.trigger", {});
}
function evaluateThemeMode() {
if (typeof SessionData === "undefined" || !SessionData.themeModeAutoEnabled) {
return;
}
if (themeAutoBackendAvailable()) {
DMSService.sendRequest("theme.auto.getState", null, response => {
if (response && response.result) {
applyThemeAutoState(response.result);
}
});
return;
}
const mode = SessionData.themeModeAutoMode;
if (mode === "location") {
evaluateLocationBasedThemeMode();
} else {
evaluateTimeBasedThemeMode();
}
}
function evaluateLocationBasedThemeMode() {
if (typeof DisplayService !== "undefined") {
const shouldBeLight = DisplayService.gammaIsDay;
if (root.isLightMode !== shouldBeLight) {
root.setLightMode(shouldBeLight, true, true);
}
return;
}
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
const shouldBeLight = calculateIsDaytime(SessionData.latitude, SessionData.longitude);
if (root.isLightMode !== shouldBeLight) {
root.setLightMode(shouldBeLight, true, true);
}
return;
}
if (root.themeModeAutomationActive) {
if (SessionData.nightModeUseIPLocation) {
log.warn("Theme automation: Waiting for IP location from backend");
} else {
log.warn("Theme automation: Location mode requires coordinates");
}
}
}
function evaluateTimeBasedThemeMode() {
const shareSettings = SessionData.themeModeShareGammaSettings;
const startHour = shareSettings ? SessionData.nightModeStartHour : SessionData.themeModeStartHour;
const startMinute = shareSettings ? SessionData.nightModeStartMinute : SessionData.themeModeStartMinute;
const endHour = shareSettings ? SessionData.nightModeEndHour : SessionData.themeModeEndHour;
const endMinute = shareSettings ? SessionData.nightModeEndMinute : SessionData.themeModeEndMinute;
const now = new Date();
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const startMinutes = startHour * 60 + startMinute;
const endMinutes = endHour * 60 + endMinute;
let shouldBeLight;
if (startMinutes < endMinutes) {
shouldBeLight = currentMinutes < startMinutes || currentMinutes >= endMinutes;
} else {
shouldBeLight = currentMinutes >= endMinutes && currentMinutes < startMinutes;
}
if (root.isLightMode !== shouldBeLight) {
root.setLightMode(shouldBeLight, true, true);
}
}
function calculateIsDaytime(lat, lng) {
const now = new Date();
const start = new Date(now.getFullYear(), 0, 0);
const diff = now - start;
const dayOfYear = Math.floor(diff / 86400000);
const latRad = lat * Math.PI / 180;
const declination = 23.45 * Math.sin((360 / 365) * (dayOfYear - 81) * Math.PI / 180);
const declinationRad = declination * Math.PI / 180;
const cosHourAngle = -Math.tan(latRad) * Math.tan(declinationRad);
if (cosHourAngle > 1) {
return false; // Polar night
}
if (cosHourAngle < -1) {
return true; // Midnight sun
}
const hourAngle = Math.acos(cosHourAngle);
const hourAngleDeg = hourAngle * 180 / Math.PI;
const sunriseHour = 12 - hourAngleDeg / 15;
const sunsetHour = 12 + hourAngleDeg / 15;
const timeZoneOffset = now.getTimezoneOffset() / 60;
const localSunrise = sunriseHour - lng / 15 - timeZoneOffset;
const localSunset = sunsetHour - lng / 15 - timeZoneOffset;
const currentHour = now.getHours() + now.getMinutes() / 60;
const normalizeSunrise = ((localSunrise % 24) + 24) % 24;
const normalizeSunset = ((localSunset % 24) + 24) % 24;
return currentHour >= normalizeSunrise && currentHour < normalizeSunset;
}
// Helper function to send location to backend
function sendLocationToBackend() {
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
return false;
}
if (!DMSService.isConnected) {
return false;
}
if (SessionData.nightModeUseIPLocation) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": true
}, response => {
if (response?.error) {
log.warn("Theme automation: Failed to enable IP location", response.error);
}
});
return true;
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": false
}, response => {
if (!response.error) {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
}, locResp => {
if (locResp?.error) {
log.warn("Theme automation: Failed to set location", locResp.error);
}
});
}
});
return true;
}
return false;
}
Timer {
id: locationRetryTimer
interval: 1000
repeat: true
running: false
property int retryCount: 0
onTriggered: {
if (root.sendLocationToBackend()) {
stop();
retryCount = 0;
root.evaluateThemeMode();
} else {
retryCount++;
if (retryCount >= 10) {
stop();
retryCount = 0;
}
}
}
}
function startThemeModeAutomation() {
root.themeModeAutomationActive = true;
root.syncTimeThemeSchedule();
root.syncLocationThemeSchedule();
const sent = root.sendLocationToBackend();
if (!sent && typeof SessionData !== "undefined" && SessionData.themeModeAutoMode === "location") {
locationRetryTimer.start();
} else {
root.evaluateThemeMode();
}
}
function stopThemeModeAutomation() {
root.themeModeAutomationActive = false;
if (typeof DMSService !== "undefined" && DMSService.isConnected) {
DMSService.sendRequest("theme.auto.setEnabled", {
"enabled": false
});
}
}
}
+21 -2
View File
@@ -4,13 +4,17 @@
function markdownToHtml(text) {
if (!text) return "";
// Store code blocks and inline code to protect them from further processing
const codeBlocks = [];
const inlineCode = [];
let blockIndex = 0;
let inlineIndex = 0;
// First, extract and replace code blocks with placeholders
let html = text.replace(/```([\s\S]*?)```/g, (match, code) => {
// Trim leading and trailing blank lines only
const trimmedCode = code.replace(/^\n+|\n+$/g, '');
// Escape HTML entities in code
const escapedCode = trimmedCode.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
@@ -18,7 +22,9 @@ function markdownToHtml(text) {
return `\x00CODEBLOCK${blockIndex++}\x00`;
});
// Extract and replace inline code
html = html.replace(/`([^`]+)`/g, (match, code) => {
// Escape HTML entities in code
const escapedCode = code.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
@@ -34,10 +40,12 @@ function markdownToHtml(text) {
return prefix + `\x00URL${urlIndex++}\x00`;
});
// Escape HTML entities (but not in code blocks or URLs)
html = html.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Headers
html = html.replace(/^### (.*?)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.*?)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.*?)$/gm, '<h1>$1</h1>');
@@ -50,16 +58,20 @@ function markdownToHtml(text) {
html = html.replace(/__(.*?)__/g, '<b>$1</b>');
html = html.replace(/_(.*?)_/g, '<i>$1</i>');
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
// Lists
html = html.replace(/^\* (.*?)$/gm, '<li>$1</li>');
html = html.replace(/^- (.*?)$/gm, '<li>$1</li>');
html = html.replace(/^\d+\. (.*?)$/gm, '<li>$1</li>');
// Wrap consecutive list items in ul/ol tags
html = html.replace(/(<li>[\s\S]*?<\/li>\s*)+/g, function(match) {
return '<ul>' + match + '</ul>';
});
// Restore extracted URLs as anchor tags (preserves raw & in href)
html = html.replace(/\x00URL(\d+)\x00/g, (_, index) => {
const url = urls[parseInt(index)];
const display = url.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -75,23 +87,30 @@ function markdownToHtml(text) {
return inlineCode[parseInt(index)];
});
// Line breaks (after code blocks are restored)
html = html.replace(/\n\n/g, '</p><p>');
html = html.replace(/\n/g, '<br/>');
// Wrap in paragraph tags if not already wrapped
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
// Clean up the final HTML
// Remove <br/> tags immediately before block elements
html = html.replace(/<br\/>\s*<pre>/g, '<pre>');
html = html.replace(/<br\/>\s*<ul>/g, '<ul>');
html = html.replace(/<br\/>\s*<h[1-6]>/g, '<h$1>');
// Remove empty paragraphs
html = html.replace(/<p>\s*<\/p>/g, '');
html = html.replace(/<p>\s*<br\/>\s*<\/p>/g, '');
html = html.replace(/(<br\/>){3,}/g, '<br/><br/>');
html = html.replace(/(<\/p>)\s*(<p>)/g, '$1$2');
// Remove excessive line breaks
html = html.replace(/(<br\/>){3,}/g, '<br/><br/>'); // Max 2 consecutive line breaks
html = html.replace(/(<\/p>)\s*(<p>)/g, '$1$2'); // Remove whitespace between paragraphs
// Remove leading/trailing whitespace
html = html.trim();
return html;
+13 -15
View File
@@ -5,13 +5,11 @@ import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
Singleton {
id: root
signal toastRequested(int severity, string title, string body, string command, string category)
signal toastCategoryDismissed(string category)
property var settingsRoot: null
onSettingsRootChanged: {
@@ -319,7 +317,7 @@ Singleton {
function launchAuthApplyTerminalFallback(fromPrecheck, details) {
authApplyTerminalFallbackFromPrecheck = fromPrecheck;
if (details && details !== "")
toastRequested(0, I18n.tr("Authentication changes need sudo. Opening terminal so you can use password or fingerprint."), details, "", "auth-sync");
ToastService.showInfo(I18n.tr("Authentication changes need sudo. Opening terminal so you can use password or fingerprint."), details, "", "auth-sync");
authApplyTerminalFallbackStderr = "";
authApplyTerminalFallbackProcess.running = true;
}
@@ -366,21 +364,21 @@ Singleton {
}
function deferGreeterAutoLoginSyncToPill(details) {
toastCategoryDismissed("greeter-autologin-sync");
ToastService.dismissCategory("greeter-autologin-sync");
if (settingsRoot)
settingsRoot.set("greeterSyncPending", true);
toastRequested(1, I18n.tr("Auto-login change needs a sync"), I18n.tr("Administrator access is required. Use the Sync button in Settings → Greeter to apply.") + (details ? "\n\n" + details : ""), "dms-greeter sync --autologin", "greeter-autologin-sync");
ToastService.showWarning(I18n.tr("Auto-login change needs a sync"), I18n.tr("Administrator access is required. Use the Sync button in Settings → Greeter to apply.") + (details ? "\n\n" + details : ""), "dms-greeter sync --autologin", "greeter-autologin-sync");
finishGreeterAutoLoginSync();
}
function greeterAutoLoginSyncSuccessToast(details) {
const enabling = settingsRoot && settingsRoot.greeterAutoLogin;
// Clear the sticky in-progress toast, then confirm with an auto-dismissing toast.
toastCategoryDismissed("greeter-autologin-sync");
ToastService.dismissCategory("greeter-autologin-sync");
if (enabling) {
toastRequested(1, I18n.tr("Auto-login enabled"), I18n.tr("You'll skip the greeter password after the next reboot. The lock screen and signing out still require your password.") + (details ? "\n\n" + details : ""), "", "");
ToastService.showWarning(I18n.tr("Auto-login enabled"), I18n.tr("You'll skip the greeter password after the next reboot. The lock screen and signing out still require your password.") + (details ? "\n\n" + details : ""));
} else {
toastRequested(0, I18n.tr("Auto-login disabled"), I18n.tr("You'll enter your password at the greeter after the next reboot.") + (details ? "\n\n" + details : ""), "", "");
ToastService.showInfo(I18n.tr("Auto-login disabled"), I18n.tr("You'll enter your password at the greeter after the next reboot.") + (details ? "\n\n" + details : ""));
}
}
@@ -577,7 +575,7 @@ Singleton {
onExited: exitCode => {
const enabling = root.settingsRoot && root.settingsRoot.greeterAutoLogin;
if (exitCode === 0) {
root.toastRequested(1, enabling ? I18n.tr("Applying auto-login on startup...") : I18n.tr("Disabling auto-login on startup..."), "", "dms-greeter sync --autologin", "greeter-autologin-sync");
ToastService.showWarning(enabling ? I18n.tr("Applying auto-login on startup...") : I18n.tr("Disabling auto-login on startup..."), "", "dms-greeter sync --autologin", "greeter-autologin-sync");
root.greeterAutoLoginSyncProcess.running = true;
return;
}
@@ -606,7 +604,7 @@ Singleton {
let details = out;
if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
root.toastRequested(0, I18n.tr("Authentication changes applied"), details, "", "auth-sync");
ToastService.showInfo(I18n.tr("Authentication changes applied"), details, "", "auth-sync");
root.detectAuthCapabilities();
root.finishAuthApply();
return;
@@ -617,7 +615,7 @@ Singleton {
details = out;
if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
root.toastRequested(1, I18n.tr("Background authentication sync failed. Trying terminal mode."), details, "", "auth-sync");
ToastService.showWarning(I18n.tr("Background authentication sync failed. Trying terminal mode."), details, "", "auth-sync");
root.launchAuthApplyTerminalFallback(false, "");
}
}
@@ -633,7 +631,7 @@ Singleton {
onExited: exitCode => {
const err = (root.authApplySudoProbeStderr || "").trim();
if (exitCode === 0) {
root.toastRequested(0, I18n.tr("Applying authentication changes..."), "", "", "auth-sync");
ToastService.showInfo(I18n.tr("Applying authentication changes..."), "", "", "auth-sync");
root.authApplyProcess.running = true;
return;
}
@@ -653,10 +651,10 @@ Singleton {
onExited: exitCode => {
if (exitCode === 0) {
const message = root.authApplyTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete authentication there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete authentication there; it will close automatically when done.");
root.toastRequested(0, message, "", "", "auth-sync");
ToastService.showInfo(message, "", "", "auth-sync");
} else {
let details = (root.authApplyTerminalFallbackStderr || "").trim();
root.toastRequested(2, I18n.tr("Terminal fallback failed. Install a supported terminal emulator or run 'dms auth sync' manually.") + " (exit " + exitCode + ")", details, "", "auth-sync");
ToastService.showError(I18n.tr("Terminal fallback failed. Install a supported terminal emulator or run 'dms auth sync' manually.") + " (exit " + exitCode + ")", details, "", "auth-sync");
}
root.finishAuthApply();
}
+1 -2
View File
@@ -98,8 +98,7 @@ var SPEC = {
niriOverviewLastMode: { def: "apps" },
settingsSidebarExpandedIds: { def: "," },
settingsSidebarCollapsedIds: { def: "," },
showConfigReloadToast: { def: true }
settingsSidebarCollapsedIds: { def: "," }
};
function getValidKeys() {
@@ -75,3 +75,26 @@ function migrateToVersion(obj, targetVersion, settingsData) {
return session;
}
function cleanup(fileText) {
var getValidKeys = SpecModule.getValidKeys;
if (!fileText || !fileText.trim()) return null;
try {
var session = JSON.parse(fileText);
var validKeys = getValidKeys();
var needsSave = false;
for (var key in session) {
if (validKeys.indexOf(key) < 0) {
delete session[key];
needsSave = true;
}
}
return needsSave ? JSON.stringify(session, null, 2) : null;
} catch (e) {
console.warn("SessionData: Failed to cleanup unused keys:", e.message);
return null;
}
}
+5 -19
View File
@@ -38,24 +38,6 @@ var SPEC = {
mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
mangoTrackpadNaturalScrolling: { def: true, onChange: "updateCompositorCursor" },
mouseAccelProfile: { def: "default", onChange: "updateCompositorInput" },
mouseAccelSpeed: { def: 0.0, onChange: "updateCompositorInput" },
mouseLeftHanded: { def: false, onChange: "updateCompositorInput" },
mouseMiddleEmulation: { def: false, onChange: "updateCompositorInput" },
mouseNaturalScroll: { def: false, onChange: "updateCompositorInput" },
mouseScrollFactor: { def: 1.0, onChange: "updateCompositorInput" },
mouseScrollMethod: { def: "default", onChange: "updateCompositorInput" },
touchpadAccelProfile: { def: "default", onChange: "updateCompositorInput" },
touchpadAccelSpeed: { def: 0.0, onChange: "updateCompositorInput" },
touchpadDisableOnExternalMouse: { def: false, onChange: "updateCompositorInput" },
touchpadDisableWhileTyping: { def: true, onChange: "updateCompositorInput" },
touchpadDragLock: { def: false, onChange: "updateCompositorInput" },
touchpadMiddleEmulation: { def: false, onChange: "updateCompositorInput" },
touchpadNaturalScroll: { def: true, onChange: "updateCompositorInput" },
touchpadScrollFactor: { def: 1.0, onChange: "updateCompositorInput" },
touchpadScrollMethod: { def: "default", onChange: "updateCompositorInput" },
touchpadTapAndDrag: { def: true, onChange: "updateCompositorInput" },
touchpadTapToClick: { def: true, onChange: "updateCompositorInput" },
firstDayOfWeek: { def: -1 },
showWeekNumber: { def: false },
@@ -373,6 +355,11 @@ var SPEC = {
fadeToDpmsEnabled: { def: true },
fadeToDpmsGracePeriod: { def: 5 },
launchPrefix: { def: "" },
brightnessDevicePins: { def: {} },
wifiNetworkPins: { def: {} },
bluetoothDevicePins: { def: {} },
audioInputDevicePins: { def: {} },
audioOutputDevicePins: { def: {} },
gtkThemingEnabled: { def: false, onChange: "regenSystemThemes" },
qtThemingEnabled: { def: false, onChange: "regenSystemThemes" },
@@ -490,7 +477,6 @@ var SPEC = {
notificationTimeoutLow: { def: 5000 },
notificationTimeoutNormal: { def: 5000 },
notificationTimeoutCritical: { def: 0 },
notificationIgnoreAppTimeout: { def: false },
notificationCompactMode: { def: false },
notificationShowTimeoutBar: { def: false },
notificationDedupeEnabled: { def: true },
+23 -26
View File
@@ -2,21 +2,6 @@
.import "./SettingsSpec.js" as SpecModule
var PIN_KEYS = ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"];
function extractPins(obj) {
if (!obj) return null;
var pins = null;
for (var i = 0; i < PIN_KEYS.length; i++) {
var value = obj[PIN_KEYS[i]];
if (!value || Object.keys(value).length === 0) continue;
if (!pins) pins = {};
pins[PIN_KEYS[i]] = value;
}
return pins;
}
function parse(root, jsonObj) {
var SPEC = SpecModule.SPEC;
@@ -278,16 +263,28 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 12;
}
if (currentVersion < 13) {
console.info("Migrating settings from version", currentVersion, "to version 13");
console.info("Moving device and network pins to cache.json");
for (var p = 0; p < PIN_KEYS.length; p++) {
delete settings[PIN_KEYS[p]];
}
settings.configVersion = 13;
}
return settings;
}
function cleanup(fileText) {
var getValidKeys = SpecModule.getValidKeys;
if (!fileText || !fileText.trim()) return;
try {
var settings = JSON.parse(fileText);
var validKeys = getValidKeys();
var needsSave = false;
for (var key in settings) {
if (validKeys.indexOf(key) < 0) {
delete settings[key];
needsSave = true;
}
}
return needsSave ? JSON.stringify(settings, null, 2) : null;
} catch (e) {
console.warn("SettingsData: Failed to cleanup unused keys:", e.message);
return null;
}
}
+65
View File
@@ -122,6 +122,11 @@ const times = [
// adds a custom time to the times config
function addTime(angle, riseName, setName) {
times.push([angle, riseName, setName]);
};
// calculations for sun times
const J0 = 0.0009;
@@ -241,3 +246,63 @@ function getMoonIllumination(date) {
angle
};
};
function hoursLater(date, h) {
return new Date(date.valueOf() + h * dayMs / 24);
}
// calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article
function getMoonTimes(date, lat, lng, inUTC) {
const t = new Date(date);
if (inUTC) t.setUTCHours(0, 0, 0, 0);
else t.setHours(0, 0, 0, 0);
const hc = 0.133 * rad;
let h0 = getMoonPosition(t, lat, lng).altitude - hc,
rise, set, ye;
// go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set)
for (let i = 1; i <= 24; i += 2) {
const h1 = getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc;
const h2 = getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc;
const a = (h0 + h2) / 2 - h1;
const b = (h2 - h0) / 2;
const xe = -b / (2 * a);
const d = b * b - 4 * a * h1;
let roots = 0, x1 = 0, x2 = 0;
ye = (a * xe + b) * xe + h1;
if (d >= 0) {
const dx = Math.sqrt(d) / (Math.abs(a) * 2);
x1 = xe - dx;
x2 = xe + dx;
if (Math.abs(x1) <= 1) roots++;
if (Math.abs(x2) <= 1) roots++;
if (x1 < -1) x1 = x2;
}
if (roots === 1) {
if (h0 < 0) rise = i + x1;
else set = i + x1;
} else if (roots === 2) {
rise = i + (ye < 0 ? x2 : x1);
set = i + (ye < 0 ? x1 : x2);
}
if (rise && set) break;
h0 = h2;
}
const result = {};
if (rise) result.rise = hoursLater(t, rise);
if (set) result.set = hoursLater(t, set);
if (!rise && !set) result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true;
return result;
};
-22
View File
@@ -233,11 +233,6 @@ Item {
PolkitService.polkitAvailable;
DisplayConfigState.hasOutputBackend;
PortalService.systemColorScheme;
IconThemeService.revision;
DesktopService.isSystemd;
TrashService.count;
WallpaperCyclingService.cyclingActive;
ThemeAutoService.active;
}
Loader {
@@ -445,23 +440,6 @@ Item {
}
}
LazyLoader {
id: qrGeneratorModalLoader
active: false
Component.onCompleted: {
PopoutService.qrGeneratorModalLoader = qrGeneratorModalLoader;
}
QRGeneratorModal {
id: qrGeneratorModalItem
Component.onCompleted: {
PopoutService.qrGeneratorModal = qrGeneratorModalItem;
}
}
}
LazyLoader {
id: polkitAuthModalLoader
active: false
+46 -75
View File
@@ -25,30 +25,32 @@ Item {
required property var windowRuleModalLoader
function getPreferredBar(refPropertyName) {
if (!root.dankBarRepeater || root.dankBarRepeater.count === 0)
return null;
const focusedScreenName = BarWidgetService.getFocusedScreenName();
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]);
const loaders = Array.from({
length: root.dankBarRepeater.count
}, (_, i) => root.dankBarRepeater.itemAt(i));
let currentBar = null;
for (const bar of bars) {
if (!bar)
continue;
const onFocusedScreen = focusedScreenName && bar.modelData?.name === focusedScreenName;
const hasRef = !refPropertyName || !!bar[refPropertyName];
for (const loader of loaders) {
const instances = loader?.item?.barVariants?.instances || [];
for (const bar of instances) {
if (!bar)
continue;
if (hasRef) {
currentBar = bar;
const onFocusedScreen = focusedScreenName && bar.modelData?.name === focusedScreenName;
const hasRef = !refPropertyName || !!bar[refPropertyName];
if (onFocusedScreen)
break;
if (hasRef) {
currentBar = bar;
if (onFocusedScreen)
break;
}
}
}
@@ -283,66 +285,28 @@ Item {
}
}
function _resolvePosition(position) {
switch ((position || "").toLowerCase()) {
case "left":
return "left";
case "center":
return "center";
case "right":
return "right";
default:
return "";
}
}
function _dashBar(position) {
if (position)
return root.getPreferredBar();
return root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
}
function _openDash(tab, position) {
const bar = _dashBar(position);
if (!bar)
return false;
const tabId = _resolveTabId(tab);
const dash = root.dankDashPopoutLoader.item;
if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) {
if (position && bar.positionDash)
bar.positionDash(dash, position);
dash.requestTab(tabId);
if (dash.updateSurfacePosition)
dash.updateSurfacePosition();
return true;
}
return bar.triggerDashTab(tabId, position);
}
function _toggleDash(tab, position) {
if (root.dankDashPopoutLoader.item?.dashVisible) {
root.dankDashPopoutLoader.item.dashVisible = false;
return true;
}
const bar = _dashBar(position);
if (!bar)
return false;
return bar.triggerDashTab(_resolveTabId(tab), position);
}
function resolveTabIndex(tab: string): int {
return SettingsData.dashTabIndexForId(_resolveTabId(tab));
}
function open(tab: string): string {
return _openDash(tab, "") ? "DASH_OPEN_SUCCESS" : "DASH_OPEN_FAILED";
}
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
if (!bar)
return "DASH_OPEN_FAILED";
function openAt(tab: string, position: string): string {
return _openDash(tab, _resolvePosition(position)) ? "DASH_OPEN_SUCCESS" : "DASH_OPEN_FAILED";
const tabId = _resolveTabId(tab);
const dash = root.dankDashPopoutLoader.item;
if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) {
dash.requestTab(tabId);
if (dash.updateSurfacePosition)
dash.updateSurfacePosition();
return "DASH_OPEN_SUCCESS";
}
if (!bar.triggerDashTab(tabId))
return "DASH_OPEN_FAILED";
return "DASH_OPEN_SUCCESS";
}
function close(): string {
@@ -354,11 +318,18 @@ Item {
}
function toggle(tab: string): string {
return _toggleDash(tab, "") ? "DASH_TOGGLE_SUCCESS" : "DASH_TOGGLE_FAILED";
}
if (root.dankDashPopoutLoader.item?.dashVisible) {
root.dankDashPopoutLoader.item.dashVisible = false;
return "DASH_TOGGLE_SUCCESS";
}
function toggleAt(tab: string, position: string): string {
return _toggleDash(tab, _resolvePosition(position)) ? "DASH_TOGGLE_SUCCESS" : "DASH_TOGGLE_FAILED";
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
if (bar) {
if (!bar.triggerDashTab(_resolveTabId(tab)))
return "DASH_TOGGLE_FAILED";
return "DASH_TOGGLE_SUCCESS";
}
return "DASH_TOGGLE_FAILED";
}
target: "dash"
@@ -314,20 +314,26 @@ Item {
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
function _frameEdgeInset(side) {
if (!effectiveScreen)
return 0;
return SettingsData.frameEdgeInsetForSide(effectiveScreen, side);
}
readonly property real _connectedAlignedX: {
switch (resolvedConnectedBarSide) {
case "top":
case "bottom":
{
const insetL = SettingsData.frameEdgeInsetForSide(effectiveScreen, "left");
const insetR = SettingsData.frameEdgeInsetForSide(effectiveScreen, "right");
const insetL = _frameEdgeInset("left");
const insetR = _frameEdgeInset("right");
const usable = Math.max(0, screenWidth - insetL - insetR);
return insetL + Math.max(0, (usable - alignedWidth) / 2);
}
case "left":
return SettingsData.frameEdgeInsetForSide(effectiveScreen, "left");
return _frameEdgeInset("left");
case "right":
return screenWidth - alignedWidth - SettingsData.frameEdgeInsetForSide(effectiveScreen, "right");
return screenWidth - alignedWidth - _frameEdgeInset("right");
}
return 0;
}
@@ -335,14 +341,14 @@ Item {
readonly property real _connectedAlignedY: {
switch (resolvedConnectedBarSide) {
case "top":
return SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
return _frameEdgeInset("top");
case "bottom":
return screenHeight - alignedHeight - SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
return screenHeight - alignedHeight - _frameEdgeInset("bottom");
case "left":
case "right":
{
const insetT = SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
const insetB = SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
const insetT = _frameEdgeInset("top");
const insetB = _frameEdgeInset("bottom");
const usable = Math.max(0, screenHeight - insetT - insetB);
return insetT + Math.max(0, (usable - alignedHeight) / 2);
}
@@ -105,6 +105,12 @@ Item {
}
readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide)
function _frameEdgeInset(side) {
if (!effectiveScreen)
return 0;
return SettingsData.frameEdgeInsetForSide(effectiveScreen, side);
}
readonly property var _connectedModalPos: {
const fallback = {
"x": (screenWidth - modalWidth) / 2,
@@ -114,10 +120,10 @@ Item {
case "top":
case "bottom":
{
const insetL = SettingsData.frameEdgeInsetForSide(effectiveScreen, "left");
const insetR = SettingsData.frameEdgeInsetForSide(effectiveScreen, "right");
const insetT = SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
const insetB = SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
const insetL = _frameEdgeInset("left");
const insetR = _frameEdgeInset("right");
const insetT = _frameEdgeInset("top");
const insetB = _frameEdgeInset("bottom");
const usable = Math.max(0, screenWidth - insetL - insetR);
const usableH = Math.max(0, screenHeight - insetT - insetB);
return {
@@ -128,11 +134,11 @@ Item {
case "left":
case "right":
{
const insetT = SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
const insetB = SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
const insetT = _frameEdgeInset("top");
const insetB = _frameEdgeInset("bottom");
const usable = Math.max(0, screenHeight - insetT - insetB);
return {
"x": resolvedConnectedBarSide === "left" ? SettingsData.frameEdgeInsetForSide(effectiveScreen, "left") : screenWidth - modalWidth - SettingsData.frameEdgeInsetForSide(effectiveScreen, "right"),
"x": resolvedConnectedBarSide === "left" ? _frameEdgeInset("left") : screenWidth - modalWidth - _frameEdgeInset("right"),
"y": insetT + Math.max(0, (usable - modalHeight) / 2)
};
}
@@ -182,16 +188,16 @@ Item {
readonly property real _connectedChromeY: {
if (!launcherArcExtenderActive)
return alignedY;
return resolvedConnectedBarSide === "top" ? Theme.snap(SettingsData.frameEdgeInsetForSide(effectiveScreen, "top"), dpr) : alignedY;
return resolvedConnectedBarSide === "top" ? Theme.snap(_frameEdgeInset("top"), dpr) : alignedY;
}
readonly property real _connectedChromeWidth: alignedWidth
readonly property real _connectedChromeHeight: {
if (!launcherArcExtenderActive)
return alignedHeight;
if (resolvedConnectedBarSide === "top")
return Theme.snap(Math.max(alignedHeight, alignedY + alignedHeight - SettingsData.frameEdgeInsetForSide(effectiveScreen, "top")), dpr);
return Theme.snap(Math.max(alignedHeight, alignedY + alignedHeight - _frameEdgeInset("top")), dpr);
if (resolvedConnectedBarSide === "bottom")
return Theme.snap(Math.max(alignedHeight, screenHeight - SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom") - alignedY), dpr);
return Theme.snap(Math.max(alignedHeight, screenHeight - _frameEdgeInset("bottom") - alignedY), dpr);
return alignedHeight;
}
readonly property real contentSurfaceHeight: launcherArcExtenderActive ? _connectedChromeHeight : alignedHeight
@@ -89,6 +89,15 @@ function fuzzyScore(text, query) {
return bestScore
}
function getTimeBucketWeight(daysSinceUsed) {
for (var i = 0; i < TimeBuckets.length; i++) {
if (daysSinceUsed <= TimeBuckets[i].maxDays) {
return TimeBuckets[i].weight
}
}
return 10
}
function calculateTextScore(name, query) {
if (name === query) return Weights.exactMatch
if (name.startsWith(query)) return Weights.prefixMatch
-9
View File
@@ -64,10 +64,6 @@ DankModal {
NotificationService.dismissAllPopups();
}
function dismissLastNotification() {
NotificationService.dismissLastNotification();
}
modalWidth: Math.min(500, screenWidth - 48)
modalHeight: Math.min(700, screenHeight * 0.85)
backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
@@ -194,11 +190,6 @@ DankModal {
return "NOTIFICATION_MODAL_DISMISS_ALL_POPUPS_SUCCESS";
}
function dismiss(): string {
notificationModal.dismissLastNotification();
return "NOTIFICATION_DISMISS_SUCCESS";
}
target: "notifications"
}
+1 -14
View File
@@ -18,19 +18,6 @@ DankModal {
open();
}
function getPowerProfileDescription(profile) {
switch (profile) {
case 0:
return I18n.tr("Extend battery life", "power profile description");
case 1:
return I18n.tr("Balance power and performance", "power profile description");
case 2:
return I18n.tr("Prioritize performance", "power profile description");
default:
return I18n.tr("Custom power profile", "power profile description");
}
}
function hideDialog() {
close();
}
@@ -257,7 +244,7 @@ DankModal {
// Selected power profile description
StyledText {
text: (root.selectedIndex >= 0 && root.selectedIndex < root.profileModel.length) ? root.getPowerProfileDescription(root.profileModel[root.selectedIndex]) : ""
text: (root.selectedIndex >= 0 && root.selectedIndex < root.profileModel.length) ? Theme.getPowerProfileDescription(root.profileModel[root.selectedIndex]) : ""
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium
horizontalAlignment: Text.AlignHCenter
+12 -3
View File
@@ -6,7 +6,6 @@ import qs.Common
import qs.Modules.ProcessList
import qs.Services
import qs.Widgets
import "../Common/Format.js" as Format
FloatingWindow {
id: processListModal
@@ -68,6 +67,16 @@ FloatingWindow {
show();
}
function formatBytes(bytes) {
if (bytes < 1024)
return bytes.toFixed(0) + " B/s";
if (bytes < 1024 * 1024)
return (bytes / 1024).toFixed(1) + " KB/s";
if (bytes < 1024 * 1024 * 1024)
return (bytes / (1024 * 1024)).toFixed(1) + " MB/s";
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB/s";
}
function nextTab() {
currentTab = (currentTab + 1) % 4;
}
@@ -532,7 +541,7 @@ FloatingWindow {
}
StyledText {
text: "↓" + Format.formatRate(DgopService.networkRxRate) + " ↑" + Format.formatRate(DgopService.networkTxRate)
text: "↓" + formatBytes(DgopService.networkRxRate) + " ↑" + formatBytes(DgopService.networkTxRate)
font.pixelSize: Theme.fontSizeSmall
font.family: SettingsData.monoFontFamily
color: Theme.surfaceText
@@ -550,7 +559,7 @@ FloatingWindow {
}
StyledText {
text: "↓" + Format.formatRate(DgopService.diskReadRate) + " ↑" + Format.formatRate(DgopService.diskWriteRate)
text: "↓" + formatBytes(DgopService.diskReadRate) + " ↑" + formatBytes(DgopService.diskWriteRate)
font.pixelSize: Theme.fontSizeSmall
font.family: SettingsData.monoFontFamily
color: Theme.surfaceText
-285
View File
@@ -1,285 +0,0 @@
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
}
}
}
}
}
}
@@ -718,20 +718,5 @@ FocusScope {
Qt.callLater(() => item.forceActiveFocus());
}
}
Loader {
id: mouseTouchpadLoader
anchors.fill: parent
active: root.currentIndex === 44
visible: active
focus: active
sourceComponent: MouseTouchpadTab {}
onActiveChanged: {
if (active && item)
Qt.callLater(() => item.forceActiveFocus());
}
}
}
}
+5 -11
View File
@@ -41,9 +41,6 @@ FloatingWindow {
signal closingModal
function show() {
if (visible && !backingWindowVisible) {
visible = false;
}
visible = true;
}
@@ -52,11 +49,7 @@ FloatingWindow {
}
function toggle() {
if (visible && backingWindowVisible) {
hide();
return;
}
show();
visible = !visible;
}
function setTabIndex(tabIndex: int) {
@@ -68,12 +61,13 @@ FloatingWindow {
function showWithTab(tabIndex: int) {
setTabIndex(tabIndex);
show();
visible = true;
}
function showWithTabName(tabName: string) {
setTabIndex(sidebar.resolveTabIndex(tabName));
show();
var idx = sidebar.resolveTabIndex(tabName);
setTabIndex(idx);
visible = true;
}
function resolveTabIndex(tabName: string): int {
@@ -319,13 +319,6 @@ Rectangle {
"icon": "headphones",
"tabIndex": 29
},
{
"id": "mouse_touchpad",
"text": I18n.tr("Mouse & Touchpad"),
"icon": "mouse",
"tabIndex": 44,
"niriOnly": true
},
{
"id": "locale",
"text": I18n.tr("Locale"),
+3 -83
View File
@@ -31,7 +31,6 @@ DankModal {
property string promptToken: ""
property string promptReason: ""
property var promptFields: []
property var promptHints: []
property string promptSetting: ""
property bool isVpnPrompt: false
@@ -41,21 +40,17 @@ 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 && !isCertificatePrompt
readonly property bool showPasswordField: fieldsInfo.length === 0
readonly property bool showAnonField: requiresEnterprise && !isVpnPrompt
readonly property bool showDomainField: requiresEnterprise && !isVpnPrompt
readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11" && !isCertificatePrompt
readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11"
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;
@@ -72,16 +67,10 @@ 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);
@@ -112,7 +101,6 @@ DankModal {
promptToken = "";
promptReason = "";
promptFields = [];
promptHints = [];
promptSetting = "";
isVpnPrompt = false;
connectionName = "";
@@ -139,7 +127,6 @@ DankModal {
promptToken = "";
promptReason = "";
promptFields = [];
promptHints = [];
promptSetting = "";
isVpnPrompt = false;
connectionName = "";
@@ -158,7 +145,6 @@ DankModal {
promptToken = token;
promptReason = reason;
promptFields = fields || [];
promptHints = hints || [];
promptSetting = setting || "802-11-wireless-security";
connectionType = connType || "802-11-wireless";
connectionName = connName || ssid || "";
@@ -338,8 +324,6 @@ 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)
@@ -359,8 +343,6 @@ 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)
@@ -401,45 +383,6 @@ 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
@@ -747,15 +690,10 @@ 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++) {
@@ -767,8 +705,6 @@ DankModal {
}
return true;
}
if (isCertificatePrompt)
return serverCertificateFingerprint.length > 0;
if (isVpnPrompt)
return passwordInput.text.length > 0;
if (isHiddenNetwork)
@@ -780,7 +716,7 @@ DankModal {
StyledText {
id: connectText
anchors.centerIn: parent
text: isCertificatePrompt ? I18n.tr("Trust", "Button that approves a VPN server certificate fingerprint") : I18n.tr("Connect")
text: I18n.tr("Connect")
font.pixelSize: Theme.fontSizeMedium
color: Theme.background
font.weight: Font.Medium
@@ -795,22 +731,6 @@ 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
+25 -2
View File
@@ -25,6 +25,29 @@ Item {
property bool effectActive: false
property bool useNextForEffect: false
function getFillMode(modeName) {
switch (modeName) {
case "Stretch":
return Image.Stretch;
case "Fit":
case "PreserveAspectFit":
return Image.PreserveAspectFit;
case "Fill":
case "PreserveAspectCrop":
return Image.PreserveAspectCrop;
case "Tile":
return Image.Tile;
case "TileVertically":
return Image.TileVertically;
case "TileHorizontally":
return Image.TileHorizontally;
case "Pad":
return Image.Pad;
default:
return Image.PreserveAspectCrop;
}
}
Component.onCompleted: {
if (initialSource && initialSource !== wallpaperSource && !(CompositorService.isNiri && SessionData.isSwitchingMode)) {
currentWallpaper.source = initialSource;
@@ -121,7 +144,7 @@ Item {
smooth: true
cache: true
sourceSize: root.blurTextureSize
fillMode: Theme.getFillMode(SessionData.getMonitorWallpaperFillMode(root.screenName))
fillMode: root.getFillMode(SessionData.getMonitorWallpaperFillMode(root.screenName))
onStatusChanged: {
if (status === Image.Error) {
@@ -143,7 +166,7 @@ Item {
smooth: true
cache: true
sourceSize: root.blurTextureSize
fillMode: Theme.getFillMode(SessionData.getMonitorWallpaperFillMode(root.screenName))
fillMode: root.getFillMode(SessionData.getMonitorWallpaperFillMode(root.screenName))
onStatusChanged: {
if (status === Image.Error) {
@@ -4,7 +4,6 @@ import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
import "../../Common/Format.js" as Format
Item {
id: root
@@ -189,6 +188,16 @@ Item {
return DgopService.availableGpus.find(g => g.pciId === selectedGpuPciId);
}
function formatBytes(bytes) {
if (bytes < 1024)
return bytes.toFixed(0) + "B";
if (bytes < 1024 * 1024)
return (bytes / 1024).toFixed(0) + "K";
if (bytes < 1024 * 1024 * 1024)
return (bytes / (1024 * 1024)).toFixed(1) + "M";
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + "G";
}
function formatMemKB(kb) {
if (kb < 1024)
return kb.toFixed(0) + "K";
@@ -197,18 +206,26 @@ Item {
return (kb / (1024 * 1024)).toFixed(1) + "G";
}
function addToHistory(arr, val) {
var newArr = arr.slice();
newArr.push(val);
if (newArr.length > historySize)
newArr.shift();
return newArr;
}
function sampleData() {
if (showCpuGraph)
cpuHistory = Format.addToHistory(cpuHistory, DgopService.cpuUsage, historySize);
cpuHistory = addToHistory(cpuHistory, DgopService.cpuUsage);
if (showMemoryGraph)
memHistory = Format.addToHistory(memHistory, DgopService.memoryUsage, historySize);
memHistory = addToHistory(memHistory, DgopService.memoryUsage);
if (showNetworkGraph) {
netRxHistory = Format.addToHistory(netRxHistory, DgopService.networkRxRate, historySize);
netTxHistory = Format.addToHistory(netTxHistory, DgopService.networkTxRate, historySize);
netRxHistory = addToHistory(netRxHistory, DgopService.networkRxRate);
netTxHistory = addToHistory(netTxHistory, DgopService.networkTxRate);
}
if (showDisk) {
diskReadHistory = Format.addToHistory(diskReadHistory, DgopService.diskReadRate, historySize);
diskWriteHistory = Format.addToHistory(diskWriteHistory, DgopService.diskWriteRate, historySize);
diskReadHistory = addToHistory(diskReadHistory, DgopService.diskReadRate);
diskWriteHistory = addToHistory(diskWriteHistory, DgopService.diskWriteRate);
}
}
@@ -502,7 +519,7 @@ Item {
color: root.accentColor
}
StyledText {
text: Format.formatBytes(DgopService.networkRxRate) + "/s"
text: root.formatBytes(DgopService.networkRxRate) + "/s"
isMonospace: true
font.pixelSize: Theme.fontSizeMedium
color: root.textColor
@@ -516,7 +533,7 @@ Item {
color: root.dimColor
}
StyledText {
text: Format.formatBytes(DgopService.networkTxRate) + "/s"
text: root.formatBytes(DgopService.networkTxRate) + "/s"
isMonospace: true
font.pixelSize: Theme.fontSizeMedium
color: root.textColor
@@ -535,7 +552,7 @@ Item {
color: root.accentColor
}
StyledText {
text: Format.formatBytes(DgopService.diskReadRate) + "/s"
text: root.formatBytes(DgopService.diskReadRate) + "/s"
isMonospace: true
font.pixelSize: Theme.fontSizeMedium
color: root.textColor
@@ -549,7 +566,7 @@ Item {
color: root.dimColor
}
StyledText {
text: Format.formatBytes(DgopService.diskWriteRate) + "/s"
text: root.formatBytes(DgopService.diskWriteRate) + "/s"
isMonospace: true
font.pixelSize: Theme.fontSizeMedium
color: root.textColor
@@ -469,8 +469,6 @@ PluginComponent {
}
MouseArea {
id: peerMouseArea
z: -1
anchors.fill: parent
hoverEnabled: true
@@ -1,7 +1,7 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Modules.ControlCenter.Details
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
@@ -0,0 +1,131 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property string iconName: ""
property string text: ""
property string secondaryText: ""
property bool isActive: false
property int widgetIndex: 0
property var widgetData: null
property bool editMode: false
signal clicked
width: parent ? parent.width : 200
height: 60
radius: {
if (Theme.cornerRadius === 0)
return 0;
return isActive ? Theme.cornerRadius : Theme.cornerRadius + 4;
}
readonly property color _tileBgActive: Theme.ccTileActiveBg
readonly property color _tileBgInactive: Theme.ccPillInactiveBg
readonly property color _tileRingActive: Theme.ccTileRing
color: isActive ? _tileBgActive : _tileBgInactive
border.color: isActive ? _tileRingActive : Theme.outlineMedium
border.width: isActive ? 1 : Theme.layerOutlineWidth
opacity: enabled ? 1.0 : 0.6
function hoverTint(base) {
const factor = 1.2;
return Theme.isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: mouseArea.containsMouse ? hoverTint(root.color) : Theme.withAlpha(hoverTint(root.color), 0)
opacity: mouseArea.containsMouse ? 0.08 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
}
}
}
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingL + 2
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingM
DankIcon {
name: root.iconName
size: Theme.iconSize
color: isActive ? Theme.ccTileActiveText : Theme.ccTileInactiveIcon
anchors.verticalCenter: parent.verticalCenter
}
Item {
width: parent.width - Theme.iconSize - parent.spacing
height: parent.height
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXXS
Typography {
width: parent.width
text: root.text
style: Typography.Style.Body
color: isActive ? Theme.ccTileActiveText : Theme.surfaceText
elide: Text.ElideRight
wrapMode: Text.NoWrap
horizontalAlignment: Text.AlignLeft
}
Typography {
width: parent.width
text: root.secondaryText
style: Typography.Style.Caption
color: isActive ? Theme.ccTileActiveText : Theme.surfaceVariantText
visible: text.length > 0
elide: Text.ElideRight
wrapMode: Text.NoWrap
horizontalAlignment: Text.AlignLeft
}
}
}
}
DankRipple {
id: ripple
cornerRadius: root.radius
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: root.enabled
onPressed: mouse => ripple.trigger(mouse.x, mouse.y)
onClicked: root.clicked()
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on radius {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
@@ -0,0 +1,91 @@
import QtQuick
import qs.Common
import qs.Modules.ControlCenter.Details
Item {
id: root
property string expandedSection: ""
property var expandedWidgetData: null
height: active ? 250 : 0
visible: active
readonly property bool active: expandedSection !== ""
Loader {
anchors.fill: parent
anchors.topMargin: Theme.spacingS
sourceComponent: {
if (!root.active)
return null;
if (expandedSection.startsWith("diskUsage_")) {
return diskUsageDetailComponent;
}
switch (expandedSection) {
case "wifi":
return networkDetailComponent;
case "bluetooth":
return bluetoothDetailComponent;
case "audioOutput":
return audioOutputDetailComponent;
case "audioInput":
return audioInputDetailComponent;
case "battery":
return batteryDetailComponent;
default:
return null;
}
}
}
Component {
id: networkDetailComponent
NetworkDetail {}
}
Component {
id: bluetoothDetailComponent
BluetoothDetail {}
}
Component {
id: audioOutputDetailComponent
AudioOutputDetail {}
}
Component {
id: audioInputDetailComponent
AudioInputDetail {}
}
Component {
id: batteryDetailComponent
BatteryDetail {}
}
Component {
id: diskUsageDetailComponent
DiskUsageDetail {
currentMountPath: root.expandedWidgetData?.mountPath || "/"
instanceId: root.expandedWidgetData?.instanceId || ""
onMountPathChanged: newMountPath => {
if (root.expandedWidgetData && root.expandedWidgetData.id === "diskUsage") {
const widgets = SettingsData.controlCenterWidgets || [];
const newWidgets = widgets.map(w => {
if (w.id === "diskUsage" && w.instanceId === root.expandedWidgetData.instanceId) {
const updatedWidget = Object.assign({}, w);
updatedWidget.mountPath = newMountPath;
return updatedWidget;
}
return w;
});
SettingsData.set("controlCenterWidgets", newWidgets);
}
}
}
}
}
@@ -0,0 +1,55 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property string iconName: ""
property string text: ""
signal pressed
height: 34
radius: Theme.cornerRadius
color: mouseArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.surfaceVariant, 0.5)
Row {
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: root.iconName
size: Theme.fontSizeSmall
color: mouseArea.containsMouse ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Typography {
text: root.text
style: Typography.Style.Button
color: mouseArea.containsMouse ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
DankRipple {
id: ripple
cornerRadius: root.radius
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: mouse => {
ripple.trigger(mouse.x, mouse.y);
root.pressed();
}
}
}
@@ -4,7 +4,6 @@ import Quickshell.Services.Pipewire
import qs.Common
import qs.Services
import qs.Widgets
import "../../../Common/QmlUtils.js" as QmlUtils
Rectangle {
id: root
@@ -151,9 +150,17 @@ Rectangle {
property int maxPinnedInputs: 3
function normalizePinList(value) {
if (Array.isArray(value))
return value.filter(v => v);
if (typeof value === "string" && value.length > 0)
return [value];
return [];
}
function getPinnedInputs() {
const pins = CacheData.audioInputDevicePins || {};
return QmlUtils.normalizePinList(pins["preferredInput"]);
const pins = SettingsData.audioInputDevicePins || {};
return normalizePinList(pins["preferredInput"]);
}
Column {
@@ -308,8 +315,8 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
onClicked: {
const pins = JSON.parse(JSON.stringify(CacheData.audioInputDevicePins || {}));
let pinnedList = QmlUtils.normalizePinList(pins["preferredInput"]);
const pins = JSON.parse(JSON.stringify(SettingsData.audioInputDevicePins || {}));
let pinnedList = audioContent.normalizePinList(pins["preferredInput"]);
const pinIndex = pinnedList.indexOf(modelData.name);
if (pinIndex !== -1) {
@@ -325,7 +332,7 @@ Rectangle {
else
delete pins["preferredInput"];
CacheData.set("audioInputDevicePins", pins);
SettingsData.set("audioInputDevicePins", pins);
}
}
}
@@ -4,7 +4,6 @@ import Quickshell.Services.Pipewire
import qs.Common
import qs.Services
import qs.Widgets
import "../../../Common/QmlUtils.js" as QmlUtils
Rectangle {
id: root
@@ -161,9 +160,17 @@ Rectangle {
property int maxPinnedOutputs: 3
function normalizePinList(value) {
if (Array.isArray(value))
return value.filter(v => v);
if (typeof value === "string" && value.length > 0)
return [value];
return [];
}
function getPinnedOutputs() {
const pins = CacheData.audioOutputDevicePins || {};
return QmlUtils.normalizePinList(pins["preferredOutput"]);
const pins = SettingsData.audioOutputDevicePins || {};
return normalizePinList(pins["preferredOutput"]);
}
Column {
@@ -317,8 +324,8 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
onClicked: {
const pins = JSON.parse(JSON.stringify(CacheData.audioOutputDevicePins || {}));
let pinnedList = QmlUtils.normalizePinList(pins["preferredOutput"]);
const pins = JSON.parse(JSON.stringify(SettingsData.audioOutputDevicePins || {}));
let pinnedList = audioContent.normalizePinList(pins["preferredOutput"]);
const pinIndex = pinnedList.indexOf(modelData.name);
if (pinIndex !== -1) {
@@ -334,7 +341,7 @@ Rectangle {
else
delete pins["preferredOutput"];
CacheData.set("audioOutputDevicePins", pins);
SettingsData.set("audioOutputDevicePins", pins);
}
}
}
@@ -5,7 +5,6 @@ import Quickshell.Bluetooth
import qs.Common
import qs.Services
import qs.Widgets
import "../../../Common/QmlUtils.js" as QmlUtils
Rectangle {
id: root
@@ -96,9 +95,17 @@ Rectangle {
BluetoothService.updateDeviceCodec(deviceAddress, codecName);
}
function normalizePinList(value) {
if (Array.isArray(value))
return value.filter(v => v);
if (typeof value === "string" && value.length > 0)
return [value];
return [];
}
function getPinnedDevices() {
const pins = CacheData.bluetoothDevicePins || {};
return QmlUtils.normalizePinList(pins["preferredDevice"]);
const pins = SettingsData.bluetoothDevicePins || {};
return normalizePinList(pins["preferredDevice"]);
}
Row {
@@ -388,8 +395,8 @@ Rectangle {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
const pins = JSON.parse(JSON.stringify(CacheData.bluetoothDevicePins || {}));
let pinnedList = QmlUtils.normalizePinList(pins["preferredDevice"]);
const pins = JSON.parse(JSON.stringify(SettingsData.bluetoothDevicePins || {}));
let pinnedList = root.normalizePinList(pins["preferredDevice"]);
const pinIndex = pinnedList.indexOf(pairedDelegate.modelData.address);
if (pinIndex !== -1) {
@@ -406,7 +413,7 @@ Rectangle {
delete pins["preferredDevice"];
}
CacheData.set("bluetoothDevicePins", pins);
SettingsData.set("bluetoothDevicePins", pins);
}
}
}
@@ -37,7 +37,7 @@ Rectangle {
const pinKey = getScreenPinKey();
if (pinKey.length > 0) {
const pins = CacheData.brightnessDevicePins || {};
const pins = SettingsData.brightnessDevicePins || {};
const pinnedDevice = pins[pinKey];
if (pinnedDevice && pinnedDevice.length > 0) {
const found = devices.find(d => d.name === pinnedDevice);
@@ -85,12 +85,12 @@ Rectangle {
}
const pinKey = getScreenPinKey();
if (pinKey.length > 0) {
const pins = CacheData.brightnessDevicePins || {};
const pins = SettingsData.brightnessDevicePins || {};
const existing = pins[pinKey];
if (existing && existing !== deviceName) {
const next = JSON.parse(JSON.stringify(pins));
delete next[pinKey];
CacheData.set("brightnessDevicePins", next);
SettingsData.set("brightnessDevicePins", next);
}
}
root.currentDeviceName = deviceName;
@@ -106,7 +106,7 @@ Rectangle {
const pinKey = getScreenPinKey();
if (!pinKey || !deviceName)
return false;
const pins = CacheData.brightnessDevicePins || {};
const pins = SettingsData.brightnessDevicePins || {};
return pins[pinKey] === deviceName;
}
@@ -114,13 +114,13 @@ Rectangle {
const pinKey = getScreenPinKey();
if (!pinKey || !deviceName)
return;
const pins = JSON.parse(JSON.stringify(CacheData.brightnessDevicePins || {}));
const pins = JSON.parse(JSON.stringify(SettingsData.brightnessDevicePins || {}));
if (pins[pinKey] === deviceName) {
delete pins[pinKey];
} else {
pins[pinKey] = deviceName;
}
CacheData.set("brightnessDevicePins", pins);
SettingsData.set("brightnessDevicePins", pins);
}
implicitHeight: {
@@ -269,7 +269,7 @@ Rectangle {
readonly property bool selected: !!(modelData && modelData.name === root.currentDeviceName)
readonly property bool devicePinnedHere: {
CacheData.brightnessDevicePins;
SettingsData.brightnessDevicePins;
return root.isDevicePinnedToScreen(modelData ? modelData.name : "");
}
@@ -1,7 +1,6 @@
import QtQuick
import qs.Common
import qs.Widgets
import "../../../Common/Format.js" as Format
Rectangle {
id: root
@@ -24,8 +23,33 @@ Rectangle {
onTriggered: root.nowMs = Date.now()
}
function _pad2(n) {
return n < 10 ? "0" + n : "" + n;
}
function formatUntil(ts) {
if (!ts)
return "";
const d = new Date(ts);
const use24h = (typeof SettingsData !== "undefined") ? SettingsData.use24HourClock : true;
if (use24h)
return _pad2(d.getHours()) + ":" + _pad2(d.getMinutes());
const suffix = d.getHours() >= 12 ? "PM" : "AM";
const h12 = ((d.getHours() + 11) % 12) + 1;
return h12 + ":" + _pad2(d.getMinutes()) + " " + suffix;
}
function formatRemaining(ms) {
return Format.formatRemaining(ms, "", I18n.tr("%1 min left"), I18n.tr("%1 h left"), I18n.tr("%1 h %2 m left"));
if (ms <= 0)
return "";
const totalMinutes = Math.ceil(ms / 60000);
if (totalMinutes < 60)
return I18n.tr("%1 min left").arg(totalMinutes);
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes - hours * 60;
if (mins === 0)
return I18n.tr("%1 h left").arg(hours);
return I18n.tr("%1 h %2 m left").arg(hours).arg(mins);
}
function minutesUntilTomorrowMorning() {
@@ -101,7 +125,7 @@ Rectangle {
if (SessionData.doNotDisturbUntil <= 0)
return I18n.tr("On indefinitely");
const remaining = Math.max(0, SessionData.doNotDisturbUntil - root.nowMs);
return root.formatRemaining(remaining) + " · " + I18n.tr("until %1").arg(Format.formatUntil(SessionData.doNotDisturbUntil, SettingsData.use24HourClock));
return root.formatRemaining(remaining) + " · " + I18n.tr("until %1").arg(root.formatUntil(SessionData.doNotDisturbUntil));
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
@@ -7,7 +7,6 @@ import qs.Services
import qs.Widgets
import qs.Modals
import qs.Modals.Common
import "../../../Common/QmlUtils.js" as QmlUtils
Rectangle {
id: root
@@ -41,11 +40,7 @@ Rectangle {
property bool hasWifiAvailable: (NetworkService.wifiDevices?.length ?? 0) > 0
property bool hasBothConnectionTypes: hasEthernetAvailable && hasWifiAvailable
property int maxPinnedNetworks: 3
// 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
readonly property int hotspotContentHeight: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable ? 56 + Theme.spacingS : 0
property var hotspotStartConfirm: ConfirmModal {}
@@ -71,9 +66,17 @@ Rectangle {
PopoutService.openSettingsWithTab("network_wifi");
}
function normalizePinList(value) {
if (Array.isArray(value))
return value.filter(v => v);
if (typeof value === "string" && value.length > 0)
return [value];
return [];
}
function getPinnedNetworks() {
const pins = CacheData.wifiNetworkPins || {};
return QmlUtils.normalizePinList(pins["preferredWifi"]);
const pins = SettingsData.wifiNetworkPins || {};
return normalizePinList(pins["preferredWifi"]);
}
property int currentPreferenceIndex: {
@@ -188,7 +191,7 @@ Rectangle {
anchors.right: parent.right
anchors.margins: Theme.spacingM
anchors.topMargin: Theme.spacingM
visible: root.showHotspotRow
visible: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable
height: visible ? 56 : 0
Rectangle {
@@ -904,8 +907,8 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
onClicked: {
const pins = JSON.parse(JSON.stringify(CacheData.wifiNetworkPins || {}));
let pinnedList = QmlUtils.normalizePinList(pins["preferredWifi"]);
const pins = JSON.parse(JSON.stringify(SettingsData.wifiNetworkPins || {}));
let pinnedList = root.normalizePinList(pins["preferredWifi"]);
const pinIndex = pinnedList.indexOf(modelData.ssid);
if (pinIndex !== -1) {
@@ -921,7 +924,7 @@ Rectangle {
else
delete pins["preferredWifi"];
CacheData.set("wifiNetworkPins", pins);
SettingsData.set("wifiNetworkPins", pins);
}
}
}
@@ -34,7 +34,7 @@ Row {
if (screenName && screenName.length > 0) {
const screen = Quickshell.screens.find(s => s.name === screenName);
const pinKey = screen ? SettingsData.getScreenDisplayName(screen) : screenName;
const pins = CacheData.brightnessDevicePins || {};
const pins = SettingsData.brightnessDevicePins || {};
const pinnedDevice = pins[pinKey];
if (pinnedDevice && pinnedDevice.length > 0) {
const found = DisplayService.devices.find(dev => dev.name === pinnedDevice);
@@ -0,0 +1,72 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property string iconName: ""
property color iconColor: Theme.surfaceText
property string labelText: ""
property real value: 0.0
property real maximumValue: 1.0
property real minimumValue: 0.0
signal sliderValueChanged(real value)
width: parent ? parent.width : 200
height: 60
radius: Theme.cornerRadius
color: Theme.nestedSurface
border.color: Theme.outlineMedium
border.width: Theme.layerOutlineWidth
opacity: enabled ? 1.0 : 0.6
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.spacingM
anchors.right: sliderContainer.left
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
name: root.iconName
size: Theme.iconSize
color: root.iconColor
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: root.labelText
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
Rectangle {
id: sliderContainer
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: Theme.spacingM
width: 120
height: parent.height - Theme.spacingS * 2
DankSlider {
anchors.centerIn: parent
width: parent.width
enabled: root.enabled
minimum: Math.round(root.minimumValue * 100)
maximum: Math.round(root.maximumValue * 100)
value: Math.round(root.value * 100)
trackColor: Theme.ccSliderTrackColor
trackOpacity: Theme.ccSliderTrackOpacity
onSliderValueChanged: root.sliderValueChanged(newValue / 100.0)
}
}
}
@@ -25,6 +25,11 @@ Rectangle {
height: 60
radius: Theme.cornerRadius
function hoverTint(base) {
const factor = 1.2;
return Theme.isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
}
readonly property color _containerBg: Theme.ccPillInactiveBg
color: {
@@ -56,7 +61,7 @@ Rectangle {
radius: root.radius
z: 0
visible: false
color: Theme.hoverTint(_containerBg)
color: hoverTint(_containerBg)
opacity: 0.08
antialiasing: true
Behavior on opacity {
@@ -93,7 +98,7 @@ Rectangle {
Rectangle {
anchors.fill: parent
radius: _tileRadius
color: Theme.hoverTint(iconTile.color)
color: hoverTint(iconTile.color)
opacity: tileMouse.pressed ? 0.3 : (tileMouse.containsMouse ? 0.2 : 0.0)
visible: opacity > 0
antialiasing: true
@@ -0,0 +1,23 @@
import QtQuick
Rectangle {
id: root
property string title: ""
property Component content: null
property bool isVisible: true
property int contentHeight: 300
width: parent ? parent.width : 400
implicitHeight: isVisible ? contentHeight : 0
height: implicitHeight
color: "transparent"
clip: true
Loader {
id: contentLoader
anchors.fill: parent
sourceComponent: root.content
asynchronous: true
}
}
@@ -22,6 +22,11 @@ Rectangle {
return isActive ? Theme.cornerRadius : Theme.cornerRadius + 4;
}
function hoverTint(base) {
const factor = 1.2;
return Theme.isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
}
readonly property color _tileBgActive: Theme.ccTileActiveBg
readonly property color _tileBgInactive: Theme.ccPillInactiveBg
readonly property color _tileRingActive: Theme.ccTileRing
@@ -42,7 +47,7 @@ Rectangle {
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.hoverTint(root.color)
color: hoverTint(root.color)
opacity: mouseArea.pressed ? 0.3 : (mouseArea.containsMouse ? 0.2 : 0.0)
visible: opacity > 0
antialiasing: true
@@ -16,6 +16,11 @@ Rectangle {
height: 48
radius: Theme.cornerRadius === 0 ? 0 : Theme.cornerRadius
function hoverTint(base) {
const factor = 1.2;
return Theme.isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
}
color: Theme.primary
border.color: Theme.ccTileRing
border.width: 1
@@ -24,7 +29,7 @@ Rectangle {
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.hoverTint(root.color)
color: hoverTint(root.color)
opacity: mouseArea.pressed ? 0.3 : (mouseArea.containsMouse ? 0.2 : 0.0)
visible: opacity > 0
antialiasing: true
@@ -25,6 +25,11 @@ Rectangle {
return isActive ? Theme.cornerRadius : Theme.cornerRadius + 4;
}
function hoverTint(base) {
const factor = 1.2;
return Theme.isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
}
readonly property color _tileBgActive: Theme.ccTileActiveBg
readonly property color _tileBgInactive: Theme.ccPillInactiveBg
readonly property color _tileRingActive: Theme.ccTileRing
@@ -45,7 +50,7 @@ Rectangle {
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.hoverTint(root.color)
color: hoverTint(root.color)
opacity: mouseArea.pressed ? 0.3 : (mouseArea.containsMouse ? 0.2 : 0.0)
visible: opacity > 0
antialiasing: true
@@ -34,6 +34,11 @@ Rectangle {
height: 48
radius: Theme.cornerRadius + 4
function hoverTint(base) {
const factor = 1.2;
return Theme.isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
}
readonly property color _tileBg: Theme.ccPillInactiveBg
color: mouseArea.containsMouse ? Theme.ccPillInactiveHoverBg : _tileBg
@@ -45,7 +50,7 @@ Rectangle {
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.hoverTint(root.color)
color: hoverTint(root.color)
opacity: mouseArea.pressed ? 0.3 : (mouseArea.containsMouse ? 0.2 : 0.0)
visible: opacity > 0
antialiasing: true
@@ -20,6 +20,11 @@ Rectangle {
return isActive ? Theme.cornerRadius : Theme.cornerRadius + 4;
}
function hoverTint(base) {
const factor = 1.2;
return Theme.isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
}
readonly property color _tileBgActive: Theme.ccTileActiveBg
readonly property color _tileBgInactive: Theme.ccPillInactiveBg
readonly property color _tileRingActive: Theme.ccTileRing
@@ -40,7 +45,7 @@ Rectangle {
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.hoverTint(root.color)
color: hoverTint(root.color)
opacity: mouseArea.pressed ? 0.3 : (mouseArea.containsMouse ? 0.2 : 0.0)
visible: opacity > 0
antialiasing: true
@@ -39,12 +39,17 @@ Rectangle {
border.width: isActive ? 1 : Theme.layerOutlineWidth
opacity: enabled ? 1.0 : 0.6
function hoverTint(base) {
const factor = 1.2;
return Theme.isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
}
readonly property color _containerBg: Theme.ccPillInactiveBg
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: mouseArea.containsMouse ? Theme.hoverTint(_containerBg) : Theme.withAlpha(_containerBg, 0)
color: mouseArea.containsMouse ? hoverTint(_containerBg) : Theme.withAlpha(_containerBg, 0)
opacity: mouseArea.containsMouse ? 0.08 : 0.0
Behavior on opacity {
@@ -29,8 +29,6 @@ Item {
property var centerWidgets: []
property int totalWidgets: 0
property real totalSize: 0
property real contentStart: 0
property real contentSize: 0
function updateLayout() {
if (SettingsData.centeringMode === "geometric") {
@@ -38,25 +36,6 @@ Item {
} else {
applyIndexLayout();
}
updateContentExtent();
}
function updateContentExtent() {
if (centerWidgets.length === 0) {
contentStart = 0;
contentSize = 0;
return;
}
let start = Infinity;
let end = -Infinity;
for (const widget of centerWidgets) {
const pos = isVertical ? widget.y : widget.x;
const size = isVertical ? widget.height : widget.width;
start = Math.min(start, pos);
end = Math.max(end, pos + size);
}
contentStart = start;
contentSize = end - start;
}
function applyGeometricLayout() {
+37 -75
View File
@@ -69,62 +69,7 @@ Item {
}
}
function dashSectionAnchor(section) {
let item;
switch (section) {
case "left":
item = barWindow.isVertical ? topBarContent.vLeftSection : topBarContent.hLeftSection;
break;
case "right":
item = barWindow.isVertical ? topBarContent.vRightSection : topBarContent.hRightSection;
break;
default:
item = barWindow.isVertical ? topBarContent.vCenterSection : topBarContent.hCenterSection;
}
if (!item)
return null;
if (barWindow.isVertical)
return {
"pos": item.mapToItem(null, 0, item.height / 2),
"width": item.height
};
return {
"pos": item.mapToItem(null, 0, 0),
"width": item.width
};
}
function positionDash(popout, position) {
if (!popout.setTriggerPosition) {
popout.triggerScreen = barWindow.screen;
return "center";
}
const explicit = position === "left" || position === "center" || position === "right";
const section = explicit ? position : (clockButtonRef?.section || "center");
const clockAnchor = clockButtonRef?.visualContent ? {
"pos": clockButtonRef.visualContent.mapToItem(null, 0, 0),
"width": clockButtonRef.visualWidth
} : null;
let anchor;
if (!explicit && section !== "center" && clockAnchor)
anchor = clockAnchor;
else
anchor = dashSectionAnchor(section) || clockAnchor;
if (!anchor) {
popout.triggerScreen = barWindow.screen;
return section;
}
const barPosition = axis?.edge === "left" ? 2 : (axis?.edge === "right" ? 3 : (axis?.edge === "top" ? 0 : 1));
const pos = SettingsData.getPopupTriggerPosition(anchor.pos, barWindow.screen, barWindow.effectiveBarThickness, anchor.width, barConfig?.spacing ?? 4, barPosition, barConfig);
popout.setTriggerPosition(pos.x, pos.y, pos.width, section, barWindow.screen, barPosition, barWindow.effectiveBarThickness, barConfig?.spacing ?? 4, barConfig);
return section;
}
function triggerDashTab(tabId, position) {
function triggerDashTab(tabId) {
const loader = PopoutService.dankDashPopoutLoader;
if (!loader)
return false;
@@ -133,7 +78,38 @@ Item {
return false;
}
const section = positionDash(loader.item, position);
let section = "center";
if (clockButtonRef && clockButtonRef.visualContent && loader.item.setTriggerPosition) {
const barPosition = axis?.edge === "left" ? 2 : (axis?.edge === "right" ? 3 : (axis?.edge === "top" ? 0 : 1));
section = clockButtonRef.section || "center";
let triggerPos, triggerWidth;
if (section === "center") {
const centerSection = barWindow.isVertical ? (barWindow.axis?.edge === "left" ? topBarContent.vCenterSection : topBarContent.vCenterSection) : topBarContent.hCenterSection;
if (centerSection) {
if (barWindow.isVertical) {
const centerY = centerSection.height / 2;
triggerPos = centerSection.mapToItem(null, 0, centerY);
triggerWidth = centerSection.height;
} else {
triggerPos = centerSection.mapToItem(null, 0, 0);
triggerWidth = centerSection.width;
}
} else {
triggerPos = clockButtonRef.visualContent.mapToItem(null, 0, 0);
triggerWidth = clockButtonRef.visualWidth;
}
} else {
triggerPos = clockButtonRef.visualContent.mapToItem(null, 0, 0);
triggerWidth = clockButtonRef.visualWidth;
}
const pos = SettingsData.getPopupTriggerPosition(triggerPos, barWindow.screen, barWindow.effectiveBarThickness, triggerWidth, barConfig?.spacing ?? 4, barPosition, barConfig);
loader.item.setTriggerPosition(pos.x, pos.y, pos.width, section, barWindow.screen, barPosition, barWindow.effectiveBarThickness, barConfig?.spacing ?? 4, barConfig);
} else {
loader.item.triggerScreen = barWindow.screen;
}
if (loader.item.requestTab)
loader.item.requestTab(tabId);
PopoutManager.requestPopout(loader.item, undefined, (barConfig?.id ?? "default") + "-" + section + "-" + tabId);
@@ -832,30 +808,16 @@ Item {
const pos = section.mapToItem(barWindow.hostWindow.contentItem, 0, 0);
const implW = section.implicitWidth || 0;
const implH = section.implicitHeight || 0;
const contentSize = isCenter ? (section.contentSize || 0) : 0;
let offsetX = isCenter && !barWindow.isVertical ? (section.width - implW) / 2 : 0;
let offsetY = !barWindow.isVertical ? (section.height - implH) / 2 : (isCenter ? (section.height - implH) / 2 : 0);
let w = implW;
let h = implH;
// index centering lays content out asymmetrically; use the real extent
if (contentSize > 0) {
if (barWindow.isVertical) {
offsetY = section.contentStart;
h = contentSize;
} else {
offsetX = section.contentStart;
w = contentSize;
}
}
const offsetX = isCenter && !barWindow.isVertical ? (section.width - implW) / 2 : 0;
const offsetY = !barWindow.isVertical ? (section.height - implH) / 2 : (isCenter ? (section.height - implH) / 2 : 0);
const edgePad = 2;
return {
"x": pos.x + offsetX - edgePad,
"y": pos.y + offsetY - edgePad,
"w": w + edgePad * 2,
"h": h + edgePad * 2
"w": implW + edgePad * 2,
"h": implH + edgePad * 2
};
}
+22 -5
View File
@@ -38,10 +38,11 @@ Item {
readonly property real _barInsetPaddingRaw: SettingsData.barInsetPaddingSyncAll ? SettingsData.barInsetPaddingShared : (barConfig?.barInsetPadding ?? -1)
readonly property real _barInsetPaddingAuto: _barIsVertical ? Theme.spacingXS : _edgeBaseMargin
readonly property real _barInsetPadding: _barInsetPaddingRaw < 0 ? _barInsetPaddingAuto : _barInsetPaddingRaw
// Hosted bars span their edge fully; frameBarContentGap is the free-end gap measured from the
// screen edge (auto = frameThickness, aligning widgets with the interior cutout).
readonly property real _frameInsetResolved: SettingsData.frameBarContentGap
readonly property real _frameInsetExtra: SettingsData.frameBarContentGapExtra
// Connected-frame Bar Inset Padding: absolute free-end gap the hosted bar spans full-width into
// (auto < 0 = frameThickness so widgets align with the interior cutout, 0 = edge-to-edge). The extra
// beyond frameThickness is what an adjacent bar end adds on top of its corner alignment.
readonly property real _frameInsetResolved: SettingsData.frameBarInsetPadding < 0 ? SettingsData.frameThickness : SettingsData.frameBarInsetPadding
readonly property real _frameInsetExtra: Math.max(0, _frameInsetResolved - SettingsData.frameThickness)
// Horizontal bars span the full width and own the corners; the perpendicular vertical bar
// tucks in below/above. Where they meet, inset the corner widget so it centres in the
@@ -273,6 +274,22 @@ Item {
return ws.num !== -1 ? ws.num : ws.name;
}
function escapeSwayWorkspaceName(name) {
return String(name ?? "").replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
}
function dispatchSwayWorkspace(ws) {
if (!ws)
return;
try {
if (ws.num !== undefined && ws.num !== -1) {
I3.dispatch(`workspace number ${ws.num}`);
} else if (ws.name) {
I3.dispatch(`workspace "${escapeSwayWorkspaceName(ws.name)}"`);
}
} catch (_) {}
}
function switchWorkspace(direction) {
const realWorkspaces = getRealWorkspaces();
if (realWorkspaces.length < 2) {
@@ -317,7 +334,7 @@ Item {
const nextIndex = direction > 0 ? Math.min(validIndex + 1, realWorkspaces.length - 1) : Math.max(validIndex - 1, 0);
if (nextIndex !== validIndex) {
CompositorService.dispatchSwayWorkspace(realWorkspaces[nextIndex]);
dispatchSwayWorkspace(realWorkspaces[nextIndex]);
}
}
}
+2 -9
View File
@@ -20,21 +20,14 @@ PanelWindow {
readonly property int barPos: body.barPos
readonly property bool barRevealed: body.barRevealed
property alias controlCenterButtonRef: body.controlCenterButtonRef
property alias clockButtonRef: body.clockButtonRef
property alias systemUpdateButtonRef: body.systemUpdateButtonRef
function triggerSystemUpdate() {
body.triggerSystemUpdate();
}
function triggerControlCenter() {
body.triggerControlCenter();
}
function triggerDashTab(tabId, position) {
return body.triggerDashTab(tabId, position);
}
function positionDash(popout, position) {
return body.positionDash(popout, position);
function triggerDashTab(tabId) {
body.triggerDashTab(tabId);
}
function triggerWallpaperBrowser() {
body.triggerWallpaperBrowser();
@@ -3,7 +3,6 @@ import QtQuick.Layouts
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.ControlCenter.Details
DankPopout {
id: root
@@ -203,7 +203,7 @@ BasePill {
const pinKey = getScreenPinKey();
if (!pinKey)
return "";
const pins = CacheData.brightnessDevicePins || {};
const pins = SettingsData.brightnessDevicePins || {};
return pins[pinKey] || "";
}

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