mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-22 19:24:01 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2db7646fe3 | ||
|
|
097de493bb | ||
|
|
a7db0354ae | ||
|
|
2d2502cc67 | ||
|
|
402a8515ee | ||
|
|
4cf0f0c898 | ||
|
|
fd0564a3ec | ||
|
|
07f8675a7b | ||
|
|
cab2a1a3bc | ||
|
|
41dd91fe75 | ||
|
|
c074b220ee | ||
|
|
243d716703 | ||
|
|
fde2181c3b | ||
|
|
a5ffd18596 | ||
|
|
e0aa1f0177 | ||
|
|
e73b9c3ce1 | ||
|
|
69955286a7 | ||
|
|
d3a02f6342 | ||
|
|
86234e5b09 | ||
|
|
938fabe27d | ||
|
|
1b34aa0ce1 | ||
|
|
76ed5d68fa | ||
|
|
b69496dbcd | ||
|
|
ef179f9c92 | ||
|
|
b451d4d8e0 | ||
|
|
12f7675950 | ||
|
|
06cd88e990 | ||
|
|
aa4b99def4 | ||
|
|
4015f634c4 | ||
|
|
b2d1ec6690 | ||
|
|
1efc10dc7e | ||
|
|
94710cf643 | ||
|
|
3711cb11fa | ||
|
|
40a0eb6841 | ||
|
|
0a643028b3 | ||
|
|
0189eb0dbb | ||
|
|
ed26bdebdc | ||
|
|
2e2c74e355 | ||
|
|
c1a3be60c3 | ||
|
|
f8260513b1 | ||
|
|
ed1855946f | ||
|
|
c0bc1d8885 | ||
|
|
3f09d7b1c8 | ||
|
|
710f20d085 | ||
|
|
6261e580aa | ||
|
|
42912ebe74 | ||
|
|
e8a755b85d | ||
|
|
6713f05e02 | ||
|
|
5d39702136 | ||
|
|
166b82df1b | ||
|
|
bca1466162 | ||
|
|
75b8932b20 | ||
|
|
a1b51b191f | ||
|
|
5defc26929 | ||
|
|
71e8a85cd1 | ||
|
|
95b2750e5f | ||
|
|
7f1a423174 | ||
|
|
33e599100c | ||
|
|
cb85620777 | ||
|
|
faf25e98a5 | ||
|
|
d7b264701e | ||
|
|
c929a8952b | ||
|
|
82089c588a | ||
|
|
19a56d1835 | ||
|
|
8371fa8ef5 | ||
|
|
acca7fae68 | ||
|
|
a478c27fd0 | ||
|
|
094dcdd035 | ||
|
|
344c1e610c | ||
|
|
fdf8722818 | ||
|
|
f2114c1a01 | ||
|
|
6ed79d06df | ||
|
|
8ff677d2eb | ||
|
|
700f43a194 | ||
|
|
53a5582858 |
@@ -531,8 +531,8 @@ jobs:
|
|||||||
repo:
|
repo:
|
||||||
- dgop
|
- dgop
|
||||||
- dankgo
|
- dankgo
|
||||||
- dank-qml-common
|
# - dank-qml-common
|
||||||
- dankcalendar
|
# - dankcalendar
|
||||||
- danksearch
|
- danksearch
|
||||||
- dank-greeter
|
- dank-greeter
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
|
|||||||
ASSETS_DIR=assets
|
ASSETS_DIR=assets
|
||||||
APPLICATIONS_DIR=$(DATA_DIR)/applications
|
APPLICATIONS_DIR=$(DATA_DIR)/applications
|
||||||
|
|
||||||
.PHONY: all build dev run clean lint-qml install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
|
.PHONY: all build dev run clean lint-qml install install-bin install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
@@ -53,14 +53,6 @@ install-bin:
|
|||||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||||
@echo "Binary installed"
|
@echo "Binary installed"
|
||||||
|
|
||||||
install-shell:
|
|
||||||
@echo "Installing shell files to $(SHELL_INSTALL_DIR)..."
|
|
||||||
@test -e $(SHELL_DIR)/DankCommon/Widgets/DankIcon.qml || { echo "DankCommon missing: run git submodule update --init"; exit 1; }
|
|
||||||
@mkdir -p $(SHELL_INSTALL_DIR)
|
|
||||||
@cp -rL $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
|
|
||||||
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
|
|
||||||
@echo "Shell files installed"
|
|
||||||
|
|
||||||
install-completions:
|
install-completions:
|
||||||
@echo "Installing shell completions..."
|
@echo "Installing shell completions..."
|
||||||
@mkdir -p $(DATA_DIR)/bash-completion/completions
|
@mkdir -p $(DATA_DIR)/bash-completion/completions
|
||||||
@@ -98,7 +90,7 @@ install-desktop:
|
|||||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||||
@echo "Desktop entries installed"
|
@echo "Desktop entries installed"
|
||||||
|
|
||||||
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
|
install: install-bin install-completions install-systemd install-icon install-desktop
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Installation complete!"
|
@echo "Installation complete!"
|
||||||
@echo ""
|
@echo ""
|
||||||
@@ -111,7 +103,7 @@ uninstall-bin:
|
|||||||
@echo "Binary removed"
|
@echo "Binary removed"
|
||||||
|
|
||||||
uninstall-shell:
|
uninstall-shell:
|
||||||
@echo "Removing shell files from $(SHELL_INSTALL_DIR)..."
|
@echo "Removing pre-1.6 shell files from $(SHELL_INSTALL_DIR)..."
|
||||||
@rm -rf $(SHELL_INSTALL_DIR)
|
@rm -rf $(SHELL_INSTALL_DIR)
|
||||||
@echo "Shell files removed"
|
@echo "Shell files removed"
|
||||||
|
|
||||||
@@ -159,7 +151,6 @@ help:
|
|||||||
@echo "Install:"
|
@echo "Install:"
|
||||||
@echo " install - Build and install everything (requires sudo)"
|
@echo " install - Build and install everything (requires sudo)"
|
||||||
@echo " install-bin - Install only the binary"
|
@echo " install-bin - Install only the binary"
|
||||||
@echo " install-shell - Install only shell files"
|
|
||||||
@echo " install-completions - Install only shell completions"
|
@echo " install-completions - Install only shell completions"
|
||||||
@echo " install-systemd - Install only systemd service"
|
@echo " install-systemd - Install only systemd service"
|
||||||
@echo " install-icon - Install only icon"
|
@echo " install-icon - Install only icon"
|
||||||
@@ -168,7 +159,7 @@ help:
|
|||||||
@echo "Uninstall:"
|
@echo "Uninstall:"
|
||||||
@echo " uninstall - Remove everything (requires sudo)"
|
@echo " uninstall - Remove everything (requires sudo)"
|
||||||
@echo " uninstall-bin - Remove only the binary"
|
@echo " uninstall-bin - Remove only the binary"
|
||||||
@echo " uninstall-shell - Remove only shell files"
|
@echo " uninstall-shell - Remove shell files left by pre-1.6 installs"
|
||||||
@echo " uninstall-completions - Remove only shell completions"
|
@echo " uninstall-completions - Remove only shell completions"
|
||||||
@echo " uninstall-systemd - Remove only systemd service"
|
@echo " uninstall-systemd - Remove only systemd service"
|
||||||
@echo " uninstall-icon - Remove only icon"
|
@echo " uninstall-icon - Remove only icon"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Type=dbus
|
|||||||
BusName=org.freedesktop.Notifications
|
BusName=org.freedesktop.Notifications
|
||||||
ExecStart=/usr/bin/dms run --session
|
ExecStart=/usr/bin/dms run --session
|
||||||
ExecReload=/bin/kill -USR1 $MAINPID
|
ExecReload=/bin/kill -USR1 $MAINPID
|
||||||
LimitNOFILE=16384
|
LimitNOFILE=16384:infinity
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartForceExitStatus=TEMPFAIL
|
RestartForceExitStatus=TEMPFAIL
|
||||||
SuccessExitStatus=TEMPFAIL
|
SuccessExitStatus=TEMPFAIL
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
colorOutputFmt string
|
colorOutputFmt string
|
||||||
colorAutocopy bool
|
colorAutocopy bool
|
||||||
colorNotify bool
|
colorNotify bool
|
||||||
colorLowercase bool
|
colorLowercase bool
|
||||||
|
colorAllowMulti bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var colorCmd = &cobra.Command{
|
var colorCmd = &cobra.Command{
|
||||||
@@ -54,7 +55,8 @@ Examples:
|
|||||||
dms color pick --rgb # Output as RGB
|
dms color pick --rgb # Output as RGB
|
||||||
dms color pick --json # Output all formats as JSON
|
dms color pick --json # Output all formats as JSON
|
||||||
dms color pick --hex -l # Output hex in lowercase
|
dms color pick --hex -l # Output hex in lowercase
|
||||||
dms color pick -a # Auto-copy result to clipboard`,
|
dms color pick -a # Auto-copy result to clipboard
|
||||||
|
dms color pick --allow-multiple # Skip the one-picker-at-a-time guard`,
|
||||||
Run: runColorPick,
|
Run: runColorPick,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +71,7 @@ func init() {
|
|||||||
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
|
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
|
||||||
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
|
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
|
||||||
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
|
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
|
||||||
|
colorPickCmd.Flags().BoolVar(&colorAllowMulti, "allow-multiple", false, "Open a picker even when another selection overlay is already open")
|
||||||
|
|
||||||
colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json")
|
colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json")
|
||||||
|
|
||||||
@@ -90,11 +93,12 @@ func runColorPick(cmd *cobra.Command, args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config := colorpicker.Config{
|
config := colorpicker.Config{
|
||||||
Format: format,
|
Format: format,
|
||||||
CustomFormat: colorOutputFmt,
|
CustomFormat: colorOutputFmt,
|
||||||
Lowercase: colorLowercase,
|
Lowercase: colorLowercase,
|
||||||
Autocopy: colorAutocopy,
|
Autocopy: colorAutocopy,
|
||||||
Notify: colorNotify,
|
Notify: colorNotify,
|
||||||
|
AllowMultiple: colorAllowMulti,
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-shot process holding a few screen-sized buffers: skip GC cycles while it runs.
|
// One-shot process holding a few screen-sized buffers: skip GC cycles while it runs.
|
||||||
|
|||||||
@@ -79,11 +79,11 @@ func runDank16(cmd *cobra.Command, args []string) {
|
|||||||
primaryLight = primaryColor
|
primaryLight = primaryColor
|
||||||
}
|
}
|
||||||
variantOpts := dank16.VariantOptions{
|
variantOpts := dank16.VariantOptions{
|
||||||
PrimaryDark: primaryDark,
|
PrimaryDark: primaryDark,
|
||||||
PrimaryLight: primaryLight,
|
PrimaryLight: primaryLight,
|
||||||
Background: background,
|
BackgroundDark: background,
|
||||||
UseDPS: contrastAlgo == "dps",
|
UseDPS: contrastAlgo == "dps",
|
||||||
IsLightMode: isLight,
|
IsLightMode: isLight,
|
||||||
}
|
}
|
||||||
variantColors := dank16.GenerateVariantPalette(variantOpts)
|
variantColors := dank16.GenerateVariantPalette(variantOpts)
|
||||||
fmt.Print(dank16.GenerateVariantJSON(variantColors))
|
fmt.Print(dank16.GenerateVariantJSON(variantColors))
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ var (
|
|||||||
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
||||||
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
||||||
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
|
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
|
||||||
|
scrollVersionRegex = regexp.MustCompile(`scroll version (\d+\.\d+)`)
|
||||||
|
aqueousVersionRegex = regexp.MustCompile(`(?i)aqueous v?(\d+\.\d+(?:\.\d+)?)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
var doctorCmd = &cobra.Command{
|
var doctorCmd = &cobra.Command{
|
||||||
@@ -739,6 +741,8 @@ func checkWindowManagers() []checkResult {
|
|||||||
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
||||||
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
||||||
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
|
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
|
||||||
|
{"Scroll", "scroll", "--version", scrollVersionRegex, []string{"scroll"}},
|
||||||
|
{"Aqueous", "aqueous", "-version", aqueousVersionRegex, []string{"aqueous"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []checkResult
|
var results []checkResult
|
||||||
@@ -771,7 +775,7 @@ func checkWindowManagers() []checkResult {
|
|||||||
results = append(results, checkResult{
|
results = append(results, checkResult{
|
||||||
catCompositor, "Compositor", statusError,
|
catCompositor, "Compositor", statusError,
|
||||||
"No supported Wayland compositor found",
|
"No supported Wayland compositor found",
|
||||||
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
|
"Install Hyprland, niri, Sway, River, Wayfire, labwc, mangowc, miracle-wm, Scroll, or Aqueous",
|
||||||
doctorDocsURL + "#compositor-checks",
|
doctorDocsURL + "#compositor-checks",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -780,11 +784,28 @@ func checkWindowManagers() []checkResult {
|
|||||||
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if os.Getenv("AQUEOUS_SOCKET") != "" {
|
||||||
|
results = append(results, checkAqueousConfigHelper())
|
||||||
|
}
|
||||||
|
|
||||||
results = append(results, checkCompositorBlurSupport())
|
results = append(results, checkCompositorBlurSupport())
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkAqueousConfigHelper() checkResult {
|
||||||
|
url := doctorDocsURL + "#compositor-checks"
|
||||||
|
path, err := exec.LookPath("aqueous-config")
|
||||||
|
if err != nil {
|
||||||
|
return checkResult{catCompositor, "aqueous-config", statusWarn, "Not found", "Keybind and display settings edits from the shell need the aqueous-config helper", url}
|
||||||
|
}
|
||||||
|
details := "Keybind and display settings edits from the shell"
|
||||||
|
if doctorVerbose {
|
||||||
|
details = path
|
||||||
|
}
|
||||||
|
return checkResult{catCompositor, "aqueous-config", statusOK, "Installed", details, url}
|
||||||
|
}
|
||||||
|
|
||||||
func checkCompositorBlurSupport() checkResult {
|
func checkCompositorBlurSupport() checkResult {
|
||||||
supported, err := blur.ProbeSupport()
|
supported, err := blur.ProbeSupport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -817,6 +838,8 @@ func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
|
|||||||
|
|
||||||
func detectRunningWM() string {
|
func detectRunningWM() string {
|
||||||
switch {
|
switch {
|
||||||
|
case os.Getenv("AQUEOUS_SOCKET") != "":
|
||||||
|
return "Aqueous"
|
||||||
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
|
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
|
||||||
return "Hyprland"
|
return "Hyprland"
|
||||||
case os.Getenv("NIRI_SOCKET") != "":
|
case os.Getenv("NIRI_SOCKET") != "":
|
||||||
@@ -911,7 +934,7 @@ ShellRoot {
|
|||||||
{"Polkit", "Authentication prompts"},
|
{"Polkit", "Authentication prompts"},
|
||||||
{"IdleMonitor", "Idle detection"},
|
{"IdleMonitor", "Idle detection"},
|
||||||
{"IdleInhibitor", "Prevent idle/sleep"},
|
{"IdleInhibitor", "Prevent idle/sleep"},
|
||||||
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
|
{"ShortcutInhibitor", "Allow shortcut management (niri, Aqueous)"},
|
||||||
{"BackgroundBlur", "Background blur API support in Quickshell"},
|
{"BackgroundBlur", "Background blur API support in Quickshell"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -46,7 +47,7 @@ var keybindsSetCmd = &cobra.Command{
|
|||||||
Short: "Set a keybind override",
|
Short: "Set a keybind override",
|
||||||
Long: "Create or update a keybind override for the specified provider",
|
Long: "Create or update a keybind override for the specified provider",
|
||||||
Args: cobra.ExactArgs(3),
|
Args: cobra.ExactArgs(3),
|
||||||
Run: runKeybindsSet,
|
Run: runKeybindsEdit,
|
||||||
}
|
}
|
||||||
|
|
||||||
var keybindsRemoveCmd = &cobra.Command{
|
var keybindsRemoveCmd = &cobra.Command{
|
||||||
@@ -54,7 +55,7 @@ var keybindsRemoveCmd = &cobra.Command{
|
|||||||
Short: "Remove a keybind",
|
Short: "Remove a keybind",
|
||||||
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
|
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
Run: runKeybindsRemove,
|
Run: runKeybindsEdit,
|
||||||
}
|
}
|
||||||
|
|
||||||
var keybindsResetCmd = &cobra.Command{
|
var keybindsResetCmd = &cobra.Command{
|
||||||
@@ -62,10 +63,14 @@ var keybindsResetCmd = &cobra.Command{
|
|||||||
Short: "Reset a keybind override to its DMS default",
|
Short: "Reset a keybind override to its DMS default",
|
||||||
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
|
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
Run: runKeybindsReset,
|
Run: runKeybindsEdit,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings")
|
||||||
|
for _, command := range []*cobra.Command{keybindsSetCmd, keybindsRemoveCmd, keybindsResetCmd} {
|
||||||
|
command.Flags().Bool("json", false, "Return structured mutation results, including errors")
|
||||||
|
}
|
||||||
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
|
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
|
||||||
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
|
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
|
||||||
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
|
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
|
||||||
@@ -93,6 +98,9 @@ func initializeProviders() {
|
|||||||
registry := keybinds.GetDefaultRegistry()
|
registry := keybinds.GetDefaultRegistry()
|
||||||
|
|
||||||
hyprlandProvider := providers.NewHyprlandProvider("")
|
hyprlandProvider := providers.NewHyprlandProvider("")
|
||||||
|
if err := registry.Register(providers.NewAqueousProvider()); err != nil {
|
||||||
|
log.Warnf("Failed to register Aqueous provider: %v", err)
|
||||||
|
}
|
||||||
if err := registry.Register(hyprlandProvider); err != nil {
|
if err := registry.Register(hyprlandProvider); err != nil {
|
||||||
log.Warnf("Failed to register Hyprland provider: %v", err)
|
log.Warnf("Failed to register Hyprland provider: %v", err)
|
||||||
}
|
}
|
||||||
@@ -206,85 +214,92 @@ func runKeybindsShow(cmd *cobra.Command, args []string) {
|
|||||||
printCheatSheet(provider)
|
printCheatSheet(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getWritableProvider(name string) keybinds.WritableProvider {
|
func keybindEditFailure(err error) map[string]any {
|
||||||
provider, err := keybinds.GetDefaultRegistry().Get(name)
|
code := "command_failed"
|
||||||
|
var helperError *providers.AqueousError
|
||||||
|
if errors.As(err, &helperError) {
|
||||||
|
code = helperError.Code
|
||||||
|
}
|
||||||
|
return map[string]any{"success": false, "code": code, "message": err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runKeybindsEdit(cmd *cobra.Command, args []string) {
|
||||||
|
result, err := editKeybind(cmd, args)
|
||||||
|
if err == nil {
|
||||||
|
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
structured, _ := cmd.Flags().GetBool("json")
|
||||||
|
if !structured {
|
||||||
|
log.Fatalf("Failed to save keybind: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(keybindEditFailure(err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func editKeybind(cmd *cobra.Command, args []string) (map[string]any, error) {
|
||||||
|
provider, err := keybinds.GetDefaultRegistry().Get(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error: %v", err)
|
return nil, err
|
||||||
|
}
|
||||||
|
key := args[1]
|
||||||
|
if aqueous, ok := provider.(*providers.AqueousProvider); ok {
|
||||||
|
generation, _ := cmd.Flags().GetString("expected-generation")
|
||||||
|
edit := providers.AqueousBindEdit{Generation: generation, Key: key, Remove: cmd.Name() != "set"}
|
||||||
|
if !edit.Remove {
|
||||||
|
for _, flag := range []string{"desc", "allow-when-locked", "cooldown-ms", "no-repeat", "no-inhibiting", "flags"} {
|
||||||
|
if cmd.Flags().Changed(flag) {
|
||||||
|
return nil, fmt.Errorf("aqueous does not support --%s in this provider", flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edit.Action = args[2]
|
||||||
|
edit.OriginalKey, _ = cmd.Flags().GetString("replace-key")
|
||||||
|
}
|
||||||
|
result, err := aqueous.Edit(cmd.Context(), edit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"success": true, "code": "applied", "generation": result.String("generation"), "key": key}, nil
|
||||||
}
|
}
|
||||||
writable, ok := provider.(keybinds.WritableProvider)
|
writable, ok := provider.(keybinds.WritableProvider)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Fatalf("Provider %s does not support writing keybinds", name)
|
return nil, fmt.Errorf("provider %s does not support writing keybinds", args[0])
|
||||||
}
|
}
|
||||||
return writable
|
result := map[string]any{"success": true, "key": key}
|
||||||
}
|
switch cmd.Name() {
|
||||||
|
case "set":
|
||||||
func runKeybindsSet(cmd *cobra.Command, args []string) {
|
options := make(map[string]any)
|
||||||
providerName, key, action := args[0], args[1], args[2]
|
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
|
||||||
writable := getWritableProvider(providerName)
|
options["allow-when-locked"] = true
|
||||||
|
}
|
||||||
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
|
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
|
||||||
_ = writable.RemoveBind(replaceKey)
|
options["cooldown-ms"] = v
|
||||||
}
|
}
|
||||||
|
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
||||||
options := make(map[string]any)
|
options["repeat"] = false
|
||||||
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
|
}
|
||||||
options["allow-when-locked"] = true
|
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
|
||||||
}
|
options["allow-inhibiting"] = false
|
||||||
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
|
}
|
||||||
options["cooldown-ms"] = v
|
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
||||||
}
|
options["flags"] = v
|
||||||
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
}
|
||||||
options["repeat"] = false
|
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
|
||||||
}
|
_ = writable.RemoveBind(replaceKey)
|
||||||
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
|
}
|
||||||
options["allow-inhibiting"] = false
|
desc, _ := cmd.Flags().GetString("desc")
|
||||||
}
|
err = writable.SetBind(key, args[2], desc, options)
|
||||||
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
result["action"] = args[2]
|
||||||
options["flags"] = v
|
result["path"] = writable.GetOverridePath()
|
||||||
}
|
case "remove":
|
||||||
|
err = writable.RemoveBind(key)
|
||||||
desc, _ := cmd.Flags().GetString("desc")
|
result["removed"] = true
|
||||||
if err := writable.SetBind(key, action, desc, options); err != nil {
|
case "reset":
|
||||||
log.Fatalf("Error setting keybind: %v", err)
|
err = writable.ResetBind(key)
|
||||||
}
|
result["reset"] = true
|
||||||
|
default:
|
||||||
output, _ := json.MarshalIndent(map[string]any{
|
return nil, fmt.Errorf("unsupported keybind operation: %s", cmd.Name())
|
||||||
"success": true,
|
}
|
||||||
"key": key,
|
return result, err
|
||||||
"action": action,
|
|
||||||
"path": writable.GetOverridePath(),
|
|
||||||
}, "", " ")
|
|
||||||
fmt.Fprintln(os.Stdout, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
func runKeybindsRemove(_ *cobra.Command, args []string) {
|
|
||||||
providerName, key := args[0], args[1]
|
|
||||||
writable := getWritableProvider(providerName)
|
|
||||||
|
|
||||||
if err := writable.RemoveBind(key); err != nil {
|
|
||||||
log.Fatalf("Error removing keybind: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
output, _ := json.MarshalIndent(map[string]any{
|
|
||||||
"success": true,
|
|
||||||
"key": key,
|
|
||||||
"removed": true,
|
|
||||||
}, "", " ")
|
|
||||||
fmt.Fprintln(os.Stdout, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
func runKeybindsReset(_ *cobra.Command, args []string) {
|
|
||||||
providerName, key := args[0], args[1]
|
|
||||||
writable := getWritableProvider(providerName)
|
|
||||||
|
|
||||||
if err := writable.ResetBind(key); err != nil {
|
|
||||||
log.Fatalf("Error resetting keybind: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
output, _ := json.MarshalIndent(map[string]any{
|
|
||||||
"success": true,
|
|
||||||
"key": key,
|
|
||||||
"reset": true,
|
|
||||||
}, "", " ")
|
|
||||||
fmt.Fprintln(os.Stdout, string(output))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKeybindEditFailure(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
err error
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{&providers.AqueousError{Code: "external_change", Message: "changed"}, "external_change"},
|
||||||
|
{fmt.Errorf("helper: %w", &providers.AqueousError{Code: "read_only", Message: "denied"}), "read_only"},
|
||||||
|
{errors.New("transport failed"), "command_failed"},
|
||||||
|
} {
|
||||||
|
result := keybindEditFailure(tc.err)
|
||||||
|
if result["success"] != false || result["code"] != tc.code || result["message"] != tc.err.Error() {
|
||||||
|
t.Fatalf("unexpected failure result: %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
ssOutputName string
|
ssOutputName string
|
||||||
|
ssSeat string
|
||||||
ssCursor string
|
ssCursor string
|
||||||
ssFormat string
|
ssFormat string
|
||||||
ssQuality int
|
ssQuality int
|
||||||
@@ -33,6 +34,8 @@ var (
|
|||||||
ssStdout bool
|
ssStdout bool
|
||||||
ssJSON bool
|
ssJSON bool
|
||||||
ssGeometry bool
|
ssGeometry bool
|
||||||
|
ssAllowMulti bool
|
||||||
|
ssHUD string
|
||||||
)
|
)
|
||||||
|
|
||||||
type screenshotMetadata struct {
|
type screenshotMetadata struct {
|
||||||
@@ -57,7 +60,7 @@ Modes:
|
|||||||
full - Capture the focused output
|
full - Capture the focused output
|
||||||
all - Capture all outputs combined
|
all - Capture all outputs combined
|
||||||
output - Capture a specific output by name
|
output - Capture a specific output by name
|
||||||
window - Capture the focused window (Hyprland/Mango/niri)
|
window - Capture the focused window (Hyprland/Mango/niri/Aqueous)
|
||||||
last - Capture the last selected region
|
last - Capture the last selected region
|
||||||
scroll - Select a region, then scroll to capture a stitched tall image
|
scroll - Select a region, then scroll to capture a stitched tall image
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@ Examples:
|
|||||||
dms screenshot full # Full screen of focused output
|
dms screenshot full # Full screen of focused output
|
||||||
dms screenshot all # All screens combined
|
dms screenshot all # All screens combined
|
||||||
dms screenshot output -o DP-1 # Specific output
|
dms screenshot output -o DP-1 # Specific output
|
||||||
dms screenshot window # Focused window (Hyprland)
|
dms screenshot window # Focused window
|
||||||
dms screenshot last # Last region (pre-selected)
|
dms screenshot last # Last region (pre-selected)
|
||||||
dms screenshot --reset # Reset last region pre-selection
|
dms screenshot --reset # Reset last region pre-selection
|
||||||
dms screenshot --no-clipboard # Save file only
|
dms screenshot --no-clipboard # Save file only
|
||||||
@@ -80,6 +83,7 @@ Examples:
|
|||||||
dms screenshot --cursor=on # Include cursor
|
dms screenshot --cursor=on # Include cursor
|
||||||
dms screenshot -f jpg -q 85 # JPEG with quality 85
|
dms screenshot -f jpg -q 85 # JPEG with quality 85
|
||||||
dms screenshot --json # Print capture metadata as JSON
|
dms screenshot --json # Print capture metadata as JSON
|
||||||
|
dms screenshot --allow-multiple # Skip the one-selector-at-a-time guard
|
||||||
dms screenshot -g # Print selected region geometry (X,Y WxH) to stdout
|
dms screenshot -g # Print selected region geometry (X,Y WxH) to stdout
|
||||||
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
|
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
|
||||||
dms screenshot scroll --interval 250`,
|
dms screenshot scroll --interval 250`,
|
||||||
@@ -120,7 +124,7 @@ If no previous region exists, falls back to interactive selection.`,
|
|||||||
var ssWindowCmd = &cobra.Command{
|
var ssWindowCmd = &cobra.Command{
|
||||||
Use: "window",
|
Use: "window",
|
||||||
Short: "Capture the focused window",
|
Short: "Capture the focused window",
|
||||||
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
|
Long: `Capture the currently focused window. Supported on Hyprland, Mango, niri, and Aqueous. Aqueous requires a running DMS shell and crops the output including borders and overlapping windows.`,
|
||||||
Run: runScreenshotWindow,
|
Run: runScreenshotWindow,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +167,7 @@ var notifyActionCmd = &cobra.Command{
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
|
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
|
||||||
|
screenshotCmd.PersistentFlags().StringVar(&ssSeat, "seat", "", "Seat for Aqueous capture (required with multiple seats)")
|
||||||
screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
|
screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
|
||||||
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
|
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
|
||||||
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
|
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
|
||||||
@@ -176,6 +181,8 @@ func init() {
|
|||||||
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
||||||
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
|
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
|
||||||
screenshotCmd.PersistentFlags().BoolVarP(&ssGeometry, "geometry", "g", false, "Print selected region geometry (X,Y WxH) to stdout without capturing an image")
|
screenshotCmd.PersistentFlags().BoolVarP(&ssGeometry, "geometry", "g", false, "Print selected region geometry (X,Y WxH) to stdout without capturing an image")
|
||||||
|
screenshotCmd.PersistentFlags().BoolVar(&ssAllowMulti, "allow-multiple", false, "Open a selector even when another one is already open")
|
||||||
|
screenshotCmd.PersistentFlags().StringVar(&ssHUD, "hud", "auto", "HUD overlay scale in region selector (auto, off/0, or integer scale 1-4)")
|
||||||
|
|
||||||
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
|
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
|
||||||
|
|
||||||
@@ -195,6 +202,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
|||||||
config := screenshot.DefaultConfig()
|
config := screenshot.DefaultConfig()
|
||||||
config.Mode = mode
|
config.Mode = mode
|
||||||
config.OutputName = ssOutputName
|
config.OutputName = ssOutputName
|
||||||
|
config.Seat = ssSeat
|
||||||
if strings.EqualFold(ssCursor, "on") {
|
if strings.EqualFold(ssCursor, "on") {
|
||||||
config.Cursor = screenshot.CursorOn
|
config.Cursor = screenshot.CursorOn
|
||||||
}
|
}
|
||||||
@@ -205,6 +213,8 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
|||||||
config.Reset = ssReset
|
config.Reset = ssReset
|
||||||
config.Stdout = ssStdout
|
config.Stdout = ssStdout
|
||||||
config.Geometry = ssGeometry
|
config.Geometry = ssGeometry
|
||||||
|
config.AllowMultiple = ssAllowMulti
|
||||||
|
config.HUD = ssHUD
|
||||||
|
|
||||||
if ssGeometry {
|
if ssGeometry {
|
||||||
config.Clipboard = false
|
config.Clipboard = false
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func init() {
|
|||||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates")
|
||||||
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
|
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
|
||||||
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxIPCMessageSize allows room for a 50 MB clipboard entry plus JSON/base64
|
// maxIPCMessageSize allows room for a 50 MB clipboard entry plus JSON/base64
|
||||||
@@ -96,10 +97,7 @@ func tryServerRequest(req models.Request) (*models.Response[any], bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getServerSocketPath() string {
|
func getServerSocketPath() string {
|
||||||
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
|
runtimeDir := utils.RuntimeDir()
|
||||||
if runtimeDir == "" {
|
|
||||||
runtimeDir = os.TempDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
if sessionSock, ok := shellApp.SessionSocketPath(); ok {
|
if sessionSock, ok := shellApp.SessionSocketPath(); ok {
|
||||||
return sessionSock
|
return sessionSock
|
||||||
|
|||||||
+8
-9
@@ -3,7 +3,7 @@ module github.com/AvengeMedia/DankMaterialShell/core
|
|||||||
go 1.26.5
|
go 1.26.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577
|
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287
|
||||||
github.com/Nadim147c/material/v3 v3.1.1
|
github.com/Nadim147c/material/v3 v3.1.1
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||||
github.com/alecthomas/chroma/v2 v2.27.0
|
github.com/alecthomas/chroma/v2 v2.27.0
|
||||||
@@ -18,7 +18,7 @@ require (
|
|||||||
github.com/pilebones/go-udev v0.9.1
|
github.com/pilebones/go-udev v0.9.1
|
||||||
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
||||||
github.com/yuin/goldmark v1.8.4
|
github.com/yuin/goldmark v1.8.4
|
||||||
@@ -71,23 +71,23 @@ require (
|
|||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/crypto v0.54.0 // indirect
|
golang.org/x/crypto v0.54.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
|
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
|
||||||
golang.org/x/net v0.57.0 // indirect
|
golang.org/x/net v0.57.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e
|
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-git/go-git/v6 v6.0.0-alpha.4
|
github.com/go-git/go-git/v6 v6.0.0-alpha.4
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
@@ -98,13 +98,12 @@ require (
|
|||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/afero v1.15.0
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v1.0.0 // indirect
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/text v0.40.0
|
golang.org/x/text v0.41.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+22
-24
@@ -1,9 +1,9 @@
|
|||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e h1:AUCBM5EF+hxCG55uJl30waRQHhR5+Wij9u8AfoTkQvs=
|
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea h1:A2IuXIzhTQwvBZiS8pxU5RmWSszMd16Oh11gUlJ9OeU=
|
||||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
|
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
|
||||||
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577 h1:z7daEy56wLH6R7PGS3MKIVoDHNdVoDptY/f2i61EDCQ=
|
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287 h1:wzQqP16zxfUcfFltJelKg6pTdRyiLY2l2NJLT0Par+Q=
|
||||||
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577/go.mod h1:2kt1u9YgHwi2npqB5ago8kwZdFrHlPGXv3PM/vwRHfY=
|
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287/go.mod h1:lwzO5Y4BO5z0I7cLMp5A7sw/THliwGz1TNv6xoUehY4=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/Nadim147c/material/v3 v3.1.1 h1:3ZuTPWBJWUpzzWW1rtqe28RMZXhA4f0/mg/Jk3JG94Y=
|
github.com/Nadim147c/material/v3 v3.1.1 h1:3ZuTPWBJWUpzzWW1rtqe28RMZXhA4f0/mg/Jk3JG94Y=
|
||||||
@@ -38,8 +38,8 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF
|
|||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||||
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
@@ -61,8 +61,6 @@ github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daT
|
|||||||
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
|
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ=
|
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ=
|
||||||
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c=
|
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c=
|
||||||
github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
|
github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
|
||||||
@@ -85,8 +83,8 @@ github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
|||||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ=
|
github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ=
|
||||||
@@ -166,8 +164,6 @@ github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H
|
|||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
@@ -192,8 +188,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||||
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU=
|
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU=
|
||||||
@@ -204,8 +200,8 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
|
|||||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
|
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
|
||||||
@@ -219,18 +215,20 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
|
|||||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
|
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
|
||||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
|
||||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
@@ -243,10 +241,10 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
|||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||||
|
|||||||
@@ -2,28 +2,19 @@ package clipboard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
|
||||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
xkbKeymapFormatV1 = 1
|
|
||||||
|
|
||||||
keyStateReleased = 0
|
keyStateReleased = 0
|
||||||
keyStatePressed = 1
|
keyStatePressed = 1
|
||||||
|
|
||||||
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
|
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
|
||||||
shiftModMask = 1 << 0
|
shiftModMask = 1 << 0
|
||||||
ctrlModMask = 1 << 2
|
ctrlModMask = 1 << 2
|
||||||
|
|
||||||
// evdev fallbacks for a standard pc105 map
|
|
||||||
fallbackCtrlKey = 29 // KEY_LEFTCTRL
|
|
||||||
fallbackShiftKey = 42 // KEY_LEFTSHIFT
|
|
||||||
fallbackVKey = 47 // KEY_V
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
|
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
|
||||||
@@ -50,25 +41,25 @@ func SendPasteKeystroke(withShift bool) (err error) {
|
|||||||
}
|
}
|
||||||
defer keyboard.Release()
|
defer keyboard.Release()
|
||||||
|
|
||||||
var keymap *wlclient.KeyboardKeymapEvent
|
var seatKeymap *wlclient.KeyboardKeymapEvent
|
||||||
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||||
if keymap == nil {
|
if seatKeymap == nil {
|
||||||
keymap = &e
|
seatKeymap = &e
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
s.display.Roundtrip()
|
s.display.Roundtrip()
|
||||||
|
|
||||||
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
|
if seatKeymap == nil || seatKeymap.Format != keymap.FormatXkbV1 {
|
||||||
return fmt.Errorf("no xkb keymap from seat")
|
return fmt.Errorf("no xkb keymap from seat")
|
||||||
}
|
}
|
||||||
defer unix.Close(keymap.Fd)
|
defer unix.Close(seatKeymap.Fd)
|
||||||
|
|
||||||
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
|
keymapText, err := keymap.Read(seatKeymap.Fd, seatKeymap.Size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read keymap: %w", err)
|
return fmt.Errorf("read keymap: %w", err)
|
||||||
}
|
}
|
||||||
keys := resolveKeycodes(keymapText)
|
keys := keymap.Parse(keymapText)
|
||||||
|
|
||||||
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
|
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,15 +67,15 @@ func SendPasteKeystroke(withShift bool) (err error) {
|
|||||||
}
|
}
|
||||||
defer vk.Destroy()
|
defer vk.Destroy()
|
||||||
|
|
||||||
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
|
if err := vk.Keymap(keymap.FormatXkbV1, seatKeymap.Fd, seatKeymap.Size); err != nil {
|
||||||
return fmt.Errorf("set keymap: %w", err)
|
return fmt.Errorf("set keymap: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mods := uint32(ctrlModMask)
|
mods := uint32(ctrlModMask)
|
||||||
held := []uint32{keys.ctrl}
|
held := []uint32{keys.Keycode("Control_L")}
|
||||||
if withShift {
|
if withShift {
|
||||||
mods |= shiftModMask
|
mods |= shiftModMask
|
||||||
held = append(held, keys.shift)
|
held = append(held, keys.Keycode("Shift_L"))
|
||||||
}
|
}
|
||||||
|
|
||||||
t := uint32(0)
|
t := uint32(0)
|
||||||
@@ -121,93 +112,8 @@ func SendPasteKeystroke(withShift bool) (err error) {
|
|||||||
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
|
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
|
||||||
return fmt.Errorf("set modifiers: %w", err)
|
return fmt.Errorf("set modifiers: %w", err)
|
||||||
}
|
}
|
||||||
if err := press(keys.v); err != nil {
|
if err := press(keys.Keycode("v")); err != nil {
|
||||||
return fmt.Errorf("key press: %w", err)
|
return fmt.Errorf("key press: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readKeymap(fd int, size uint32) (string, error) {
|
|
||||||
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
text := strings.TrimRight(string(data), "\x00")
|
|
||||||
return text, unix.Munmap(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
type pasteKeycodes struct {
|
|
||||||
ctrl uint32
|
|
||||||
shift uint32
|
|
||||||
v uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
|
|
||||||
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
|
|
||||||
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
|
|
||||||
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// xkbcommon may serialize keysyms as hex escapes instead of names
|
|
||||||
// (e.g. "0x76" for v, "0xffe3" for Control_L).
|
|
||||||
var keysymNames = map[uint32]string{
|
|
||||||
0x76: "v",
|
|
||||||
0xffe3: "Control_L",
|
|
||||||
0xffe1: "Shift_L",
|
|
||||||
}
|
|
||||||
|
|
||||||
func canonicalKeysym(sym string) string {
|
|
||||||
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
|
|
||||||
return sym
|
|
||||||
}
|
|
||||||
value, err := strconv.ParseUint(sym[2:], 16, 32)
|
|
||||||
if err != nil {
|
|
||||||
return sym
|
|
||||||
}
|
|
||||||
if name, ok := keysymNames[uint32(value)]; ok {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return sym
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveKeycodes finds the evdev keycodes producing the keysyms we need in
|
|
||||||
// the seat keymap's first group, falling back to pc105 positions.
|
|
||||||
func resolveKeycodes(keymap string) pasteKeycodes {
|
|
||||||
codes := map[string]uint32{}
|
|
||||||
for _, m := range keycodeDefRe.FindAllStringSubmatch(keymap, -1) {
|
|
||||||
if code, err := strconv.Atoi(m[2]); err == nil {
|
|
||||||
codes[m[1]] = uint32(code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
keys := pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}
|
|
||||||
want := map[string]*uint32{
|
|
||||||
"Control_L": &keys.ctrl,
|
|
||||||
"Shift_L": &keys.shift,
|
|
||||||
"v": &keys.v,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, m := range keySymbolsRe.FindAllStringSubmatch(keymap, -1) {
|
|
||||||
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
|
|
||||||
if group == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
xkbCode, ok := codes[m[1]]
|
|
||||||
if !ok || xkbCode < 8 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
level1 := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
|
|
||||||
target, wanted := want[level1]
|
|
||||||
if !wanted {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
*target = xkbCode - 8
|
|
||||||
delete(want, level1)
|
|
||||||
if len(want) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
|
||||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
@@ -32,20 +33,20 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer keyboard.Release()
|
defer keyboard.Release()
|
||||||
|
|
||||||
var keymap *wlclient.KeyboardKeymapEvent
|
var seatKeymap *wlclient.KeyboardKeymapEvent
|
||||||
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||||
if keymap == nil {
|
if seatKeymap == nil {
|
||||||
keymap = &e
|
seatKeymap = &e
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
s.display.Roundtrip()
|
s.display.Roundtrip()
|
||||||
|
|
||||||
if keymap == nil {
|
if seatKeymap == nil {
|
||||||
t.Fatal("no keymap event")
|
t.Fatal("no keymap event")
|
||||||
}
|
}
|
||||||
defer unix.Close(keymap.Fd)
|
defer unix.Close(seatKeymap.Fd)
|
||||||
|
|
||||||
text, err := readKeymap(keymap.Fd, keymap.Size)
|
text, err := keymap.Read(seatKeymap.Fd, seatKeymap.Size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read keymap: %v", err)
|
t.Fatalf("read keymap: %v", err)
|
||||||
}
|
}
|
||||||
@@ -56,10 +57,7 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
keys := resolveKeycodes(text)
|
keys := keymap.Parse(text)
|
||||||
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
|
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", seatKeymap.Size,
|
||||||
|
keys.Keycode("Control_L"), keys.Keycode("Shift_L"), keys.Keycode("v"))
|
||||||
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
|
|
||||||
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package clipboard
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
const qwertyKeymap = `xkb_keymap {
|
|
||||||
xkb_keycodes "(unnamed)" {
|
|
||||||
minimum = 8;
|
|
||||||
maximum = 708;
|
|
||||||
<ESC> = 9;
|
|
||||||
<AB04> = 55;
|
|
||||||
<LCTL> = 37;
|
|
||||||
<LFSH> = 50;
|
|
||||||
alias <AL01> = <AC01>;
|
|
||||||
indicator 1 = "Caps Lock";
|
|
||||||
};
|
|
||||||
xkb_types "(unnamed)" {
|
|
||||||
type "ALPHABETIC" {
|
|
||||||
modifiers = Shift+Lock;
|
|
||||||
map[Shift] = Level2;
|
|
||||||
level_name[Level1] = "Base";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
xkb_symbols "(unnamed)" {
|
|
||||||
key <ESC> { [ Escape ] };
|
|
||||||
key <AB04> { type= "ALPHABETIC", [ v, V ] };
|
|
||||||
key <LCTL> { [ Control_L ] };
|
|
||||||
key <LFSH> { [ Shift_L ] };
|
|
||||||
};
|
|
||||||
};`
|
|
||||||
|
|
||||||
const hexKeymap = `xkb_keymap {
|
|
||||||
xkb_keycodes "(unnamed)" {
|
|
||||||
<AB04> = 56;
|
|
||||||
<LCTL> = 38;
|
|
||||||
<LFSH> = 51;
|
|
||||||
};
|
|
||||||
xkb_symbols "(unnamed)" {
|
|
||||||
key <LCTL> { [ 0xffe3 ] };
|
|
||||||
key <LFSH> {
|
|
||||||
type= "PC_ALT_LEVEL2",
|
|
||||||
symbols[1]= [ 0xffe1, 0xfe08 ]
|
|
||||||
};
|
|
||||||
key <AB04> { [ 0x76, 0x56 ] };
|
|
||||||
};
|
|
||||||
};`
|
|
||||||
|
|
||||||
const dvorakKeymap = `xkb_keymap {
|
|
||||||
xkb_keycodes "(unnamed)" {
|
|
||||||
<AB09> = 60;
|
|
||||||
<LCTL> = 37;
|
|
||||||
<LFSH> = 50;
|
|
||||||
};
|
|
||||||
xkb_symbols "(unnamed)" {
|
|
||||||
key <AB09> { [ v, V ] };
|
|
||||||
key <LCTL> { [ Control_L ] };
|
|
||||||
key <LFSH> { [ Shift_L ] };
|
|
||||||
};
|
|
||||||
};`
|
|
||||||
|
|
||||||
func TestResolveKeycodes(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
keymap string
|
|
||||||
want pasteKeycodes
|
|
||||||
}{
|
|
||||||
{"qwerty", qwertyKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 47}},
|
|
||||||
{"dvorak", dvorakKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 52}},
|
|
||||||
{"hex keysyms", hexKeymap, pasteKeycodes{ctrl: 30, shift: 43, v: 48}},
|
|
||||||
{"empty falls back", "", pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := resolveKeycodes(tt.keymap)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("resolveKeycodes() = %+v, want %+v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,16 +10,19 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_color_management"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_color_management"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
|
||||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Format OutputFormat
|
Format OutputFormat
|
||||||
CustomFormat string
|
CustomFormat string
|
||||||
Lowercase bool
|
Lowercase bool
|
||||||
Autocopy bool
|
Autocopy bool
|
||||||
Notify bool
|
Notify bool
|
||||||
|
AllowMultiple bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Output struct {
|
type Output struct {
|
||||||
@@ -62,6 +65,7 @@ type Picker struct {
|
|||||||
seat *client.Seat
|
seat *client.Seat
|
||||||
pointer *client.Pointer
|
pointer *client.Pointer
|
||||||
keyboard *client.Keyboard
|
keyboard *client.Keyboard
|
||||||
|
keymap *keymap.Keymap
|
||||||
layerShell *wlr_layer_shell.ZwlrLayerShellV1
|
layerShell *wlr_layer_shell.ZwlrLayerShellV1
|
||||||
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
|
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
|
||||||
viewporter *wp_viewporter.WpViewporter
|
viewporter *wp_viewporter.WpViewporter
|
||||||
@@ -91,6 +95,14 @@ func New(config Config) *Picker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) Run() (*Color, error) {
|
func (p *Picker) Run() (*Color, error) {
|
||||||
|
if !p.config.AllowMultiple {
|
||||||
|
lock, err := utils.LockSelectionOverlay()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer lock.Release()
|
||||||
|
}
|
||||||
|
|
||||||
if err := p.connect(); err != nil {
|
if err := p.connect(); err != nil {
|
||||||
return nil, fmt.Errorf("wayland connect: %w", err)
|
return nil, fmt.Errorf("wayland connect: %w", err)
|
||||||
}
|
}
|
||||||
@@ -728,9 +740,14 @@ func (p *Picker) setupPointerHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) setupKeyboardHandlers() {
|
func (p *Picker) setupKeyboardHandlers() {
|
||||||
|
p.keyboard.SetKeymapHandler(func(e client.KeyboardKeymapEvent) {
|
||||||
|
p.keymap = keymap.FromEvent(e)
|
||||||
|
})
|
||||||
|
|
||||||
p.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
|
p.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
|
||||||
|
sym := p.keymap.Keysym(e.Key)
|
||||||
for _, ls := range p.surfaces {
|
for _, ls := range p.surfaces {
|
||||||
ls.state.OnKey(e.Key, e.State)
|
ls.state.OnKey(sym, e.State)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ func (s *SurfaceState) OnPointerButton(button, state uint32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SurfaceState) OnKey(key, state uint32) {
|
func (s *SurfaceState) OnKey(sym string, state uint32) {
|
||||||
if state != 1 {
|
if state != 1 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -274,10 +274,10 @@ func (s *SurfaceState) OnKey(key, state uint32) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
switch key {
|
switch sym {
|
||||||
case 1: // KEY_ESC
|
case "Escape":
|
||||||
s.cancelled = true
|
s.cancelled = true
|
||||||
case 28: // KEY_ENTER
|
case "Return", "KP_Enter":
|
||||||
if s.readyForDisplay && s.screenBuf != nil {
|
if s.readyForDisplay && s.screenBuf != nil {
|
||||||
s.colorPicked = true
|
s.colorPicked = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func TestSurfaceState_ConcurrentIsDone(t *testing.T) {
|
|||||||
for range goroutines / 3 {
|
for range goroutines / 3 {
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
for range iterations {
|
for range iterations {
|
||||||
s.OnKey(1, 1)
|
s.OnKey("Escape", 1)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -661,9 +661,12 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
if existingData != "" {
|
if existingData != "" {
|
||||||
existingConfig = existingData
|
existingConfig = existingData
|
||||||
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
|
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
|
||||||
|
}
|
||||||
result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
|
// A legacy hyprland.conf is only backed up once hyprland.lua is in place; Hyprland
|
||||||
if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil {
|
// watches the live config and regenerates a stub one if it disappears first.
|
||||||
|
if existingData != "" && !strings.EqualFold(filepath.Ext(existingPath), ".conf") {
|
||||||
|
result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, filepath.Base(existingPath)), []byte(existingData))
|
||||||
|
if err != nil {
|
||||||
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
@@ -711,11 +714,15 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
movedLegacy, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
movedLegacy, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
|
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
if mainConfBackup != "" && result.BackupPath == "" {
|
||||||
|
result.BackupPath = mainConfBackup
|
||||||
|
cd.log(fmt.Sprintf("Backed up existing config to %s", mainConfBackup))
|
||||||
|
}
|
||||||
if movedLegacy > 0 {
|
if movedLegacy > 0 {
|
||||||
if result.BackupPath == "" {
|
if result.BackupPath == "" {
|
||||||
result.BackupPath = backupDir
|
result.BackupPath = backupDir
|
||||||
@@ -737,42 +744,53 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func backupHyprlandConfigFile(src, dst string, data []byte, removeSource bool) error {
|
func uniqueBackupPath(dst string) string {
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
if _, err := os.Lstat(dst); err != nil {
|
||||||
return err
|
return dst
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
for i := 1; i < 1000; i++ {
|
||||||
return err
|
candidate := fmt.Sprintf("%s.%d", dst, i)
|
||||||
}
|
if _, err := os.Lstat(candidate); err != nil {
|
||||||
if removeSource {
|
return candidate
|
||||||
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return fmt.Sprintf("%s.%d", dst, time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
|
|
||||||
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, error) {
|
func backupHyprlandConfigFile(dst string, data []byte) (string, error) {
|
||||||
legacyPaths := []string{filepath.Join(configDir, "hyprland.conf")}
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dst = uniqueBackupPath(dst)
|
||||||
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, string, error) {
|
||||||
|
mainConf := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
legacyPaths := []string{mainConf}
|
||||||
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
|
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, "", err
|
||||||
}
|
}
|
||||||
legacyPaths = append(legacyPaths, dmsConfPaths...)
|
legacyPaths = append(legacyPaths, dmsConfPaths...)
|
||||||
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
|
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, "", err
|
||||||
}
|
}
|
||||||
legacyPaths = append(legacyPaths, backupPaths...)
|
legacyPaths = append(legacyPaths, backupPaths...)
|
||||||
|
|
||||||
moved := 0
|
moved := 0
|
||||||
|
mainConfBackup := ""
|
||||||
for _, src := range legacyPaths {
|
for _, src := range legacyPaths {
|
||||||
info, err := os.Lstat(src)
|
info, err := os.Lstat(src)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return moved, err
|
return moved, mainConfBackup, err
|
||||||
}
|
}
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
continue
|
continue
|
||||||
@@ -782,21 +800,28 @@ func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
rel = filepath.Base(src)
|
rel = filepath.Base(src)
|
||||||
}
|
}
|
||||||
dst := filepath.Join(backupDir, rel)
|
dst, err := moveHyprlandConfigFile(src, filepath.Join(backupDir, rel))
|
||||||
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
if err != nil {
|
||||||
return moved, err
|
return moved, mainConfBackup, err
|
||||||
|
}
|
||||||
|
if src == mainConf {
|
||||||
|
mainConfBackup = dst
|
||||||
}
|
}
|
||||||
moved++
|
moved++
|
||||||
}
|
}
|
||||||
|
|
||||||
return moved, nil
|
return moved, mainConfBackup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func moveHyprlandConfigFile(src, dst string) error {
|
func moveHyprlandConfigFile(src, dst string) (string, error) {
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
return os.Rename(src, dst)
|
dst = uniqueBackupPath(dst)
|
||||||
|
if err := os.Rename(src, dst); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
|
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
|
||||||
|
|||||||
@@ -890,3 +890,72 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBackupLegacyHyprlandConfFilesKeepsExistingBackups(t *testing.T) {
|
||||||
|
td := t.TempDir()
|
||||||
|
configDir := filepath.Join(td, ".config", "hypr")
|
||||||
|
dmsDir := filepath.Join(configDir, "dms")
|
||||||
|
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
||||||
|
|
||||||
|
userConfig := "# the config the user actually wrote\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
|
||||||
|
backupDir := filepath.Join(configDir, hyprlandBackupDirName, "2026-01-02_03-04-05")
|
||||||
|
require.NoError(t, os.MkdirAll(backupDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(backupDir, "hyprland.conf"), []byte(userConfig), 0o644))
|
||||||
|
|
||||||
|
autogenerated := "autogenerated = 1\n$mainMod = SUPER\n"
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(configDir, "hyprland.conf"), []byte(autogenerated), 0o644))
|
||||||
|
|
||||||
|
moved, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, moved)
|
||||||
|
assert.Equal(t, filepath.Join(backupDir, "hyprland.conf.1"), mainConfBackup)
|
||||||
|
|
||||||
|
kept, err := os.ReadFile(filepath.Join(backupDir, "hyprland.conf"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, userConfig, string(kept))
|
||||||
|
|
||||||
|
stray, err := os.ReadFile(mainConfBackup)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, autogenerated, string(stray))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) {
|
||||||
|
backupDir := t.TempDir()
|
||||||
|
dst := filepath.Join(backupDir, "hyprland.lua")
|
||||||
|
require.NoError(t, os.WriteFile(dst, []byte("-- first\n"), 0o644))
|
||||||
|
|
||||||
|
written, err := backupHyprlandConfigFile(dst, []byte("-- second\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, dst+".1", written)
|
||||||
|
|
||||||
|
first, err := os.ReadFile(dst)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "-- first\n", string(first))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployHyprlandConfigWritesLuaBeforeRemovingLegacyConf(t *testing.T) {
|
||||||
|
td := t.TempDir()
|
||||||
|
t.Setenv("HOME", td)
|
||||||
|
configDir := filepath.Join(td, ".config", "hypr")
|
||||||
|
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
||||||
|
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
userConfig := "# user config\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
|
||||||
|
require.NoError(t, os.WriteFile(confPath, []byte(userConfig), 0o644))
|
||||||
|
|
||||||
|
logChan := make(chan string, 100)
|
||||||
|
cd := NewConfigDeployer(logChan)
|
||||||
|
go func() {
|
||||||
|
for range logChan {
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer close(logChan)
|
||||||
|
|
||||||
|
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NoFileExists(t, confPath)
|
||||||
|
backup, err := os.ReadFile(result.BackupPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, userConfig, string(backup))
|
||||||
|
assert.Equal(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"), result.BackupPath)
|
||||||
|
}
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
rel = filepath.Base(src)
|
rel = filepath.Base(src)
|
||||||
}
|
}
|
||||||
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
|
dst, err := moveHyprlandConfigFile(src, filepath.Join(configDir, hyprlandBackupDirName, ts, rel))
|
||||||
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
if err != nil {
|
||||||
if logFn != nil {
|
if logFn != nil {
|
||||||
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
|
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -455,6 +455,7 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
|
|||||||
type PaletteOptions struct {
|
type PaletteOptions struct {
|
||||||
IsLight bool
|
IsLight bool
|
||||||
Background string
|
Background string
|
||||||
|
Container string
|
||||||
UseDPS bool
|
UseDPS bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,8 +521,18 @@ func DeriveContainer(primary string, isLight bool) string {
|
|||||||
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
|
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeriveContainer only approximates the Material tone pairing, so a caller that
|
||||||
|
// has the real primary_container should pass it: color5 and color6 are the
|
||||||
|
// primary/container pair and their contrast with each other is the invariant.
|
||||||
|
func resolveContainer(primaryColor string, opts PaletteOptions) string {
|
||||||
|
if opts.Container != "" {
|
||||||
|
return opts.Container
|
||||||
|
}
|
||||||
|
return DeriveContainer(primaryColor, opts.IsLight)
|
||||||
|
}
|
||||||
|
|
||||||
func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||||
baseColor := DeriveContainer(primaryColor, opts.IsLight)
|
baseColor := resolveContainer(primaryColor, opts)
|
||||||
|
|
||||||
rgb := HexToRGB(baseColor)
|
rgb := HexToRGB(baseColor)
|
||||||
hsv := RGBToHSV(rgb)
|
hsv := RGBToHSV(rgb)
|
||||||
@@ -531,13 +542,20 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
|||||||
|
|
||||||
var palette Palette
|
var palette Palette
|
||||||
|
|
||||||
var normalTextTarget, secondaryTarget float64
|
// Delta Phi Star is not symmetric across polarity: on a light background the
|
||||||
if opts.UseDPS {
|
// same Lc buys far less real contrast, so the light targets are the numbers
|
||||||
|
// that land on the same measured WCAG ratios the dark ones already deliver.
|
||||||
|
var normalTextTarget, accentTarget float64
|
||||||
|
switch {
|
||||||
|
case opts.UseDPS && opts.IsLight:
|
||||||
|
normalTextTarget = 70.0
|
||||||
|
accentTarget = 58.0
|
||||||
|
case opts.UseDPS:
|
||||||
normalTextTarget = 40.0
|
normalTextTarget = 40.0
|
||||||
secondaryTarget = 35.0
|
accentTarget = 24.5
|
||||||
} else {
|
default:
|
||||||
normalTextTarget = 4.5
|
normalTextTarget = 4.5
|
||||||
secondaryTarget = 3.0
|
accentTarget = 2.1
|
||||||
}
|
}
|
||||||
|
|
||||||
var bgColor string
|
var bgColor string
|
||||||
@@ -557,8 +575,6 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
|||||||
greenH := blendHue(0.33, ph.H, 0.10)
|
greenH := blendHue(0.33, ph.H, 0.10)
|
||||||
yellowH := blendHue(0.14, ph.H, 0.04)
|
yellowH := blendHue(0.14, ph.H, 0.04)
|
||||||
|
|
||||||
accentTarget := secondaryTarget * 0.7
|
|
||||||
|
|
||||||
if opts.IsLight {
|
if opts.IsLight {
|
||||||
redS := math.Min(baseSat*1.2, 1.0)
|
redS := math.Min(baseSat*1.2, 1.0)
|
||||||
redV := baseVal * 0.95
|
redV := baseVal * 0.95
|
||||||
@@ -570,15 +586,13 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
|||||||
|
|
||||||
yellowS := math.Min(baseSat*1.5, 1.0)
|
yellowS := math.Min(baseSat*1.5, 1.0)
|
||||||
yellowV := math.Min(baseVal*1.2, 1.0)
|
yellowV := math.Min(baseVal*1.2, 1.0)
|
||||||
palette.Color3 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, accentTarget, opts))
|
palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, opts))
|
||||||
|
|
||||||
blueS := math.Min(ph.S*1.05, 1.0)
|
blueS := math.Min(ph.S*1.05, 1.0)
|
||||||
blueV := math.Min(ph.V*1.05, 1.0)
|
blueV := math.Min(ph.V*1.05, 1.0)
|
||||||
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
|
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
|
||||||
|
|
||||||
// Color5 matches primary_container exactly (light container in light mode)
|
palette.Color5 = NewColorInfo(baseColor)
|
||||||
container5 := DeriveContainer(primaryColor, true)
|
|
||||||
palette.Color5 = NewColorInfo(container5)
|
|
||||||
|
|
||||||
palette.Color6 = NewColorInfo(primaryColor)
|
palette.Color6 = NewColorInfo(primaryColor)
|
||||||
|
|
||||||
@@ -604,8 +618,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
|||||||
brightBlueV := math.Min(ph.V*1.15, 1.0)
|
brightBlueV := math.Min(ph.V*1.15, 1.0)
|
||||||
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
|
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
|
||||||
|
|
||||||
lightContainer := DeriveContainer(primaryColor, true)
|
color13S := ph.S * 0.7
|
||||||
palette.Color13 = NewColorInfo(lightContainer)
|
color13V := math.Min(ph.V*1.3, 1.0)
|
||||||
|
palette.Color13 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color13S, V: color13V})), bgColor, accentTarget, opts))
|
||||||
|
|
||||||
brightCyanS := ph.S * 0.5
|
brightCyanS := ph.S * 0.5
|
||||||
brightCyanV := math.Min(ph.V*1.3, 1.0)
|
brightCyanV := math.Min(ph.V*1.3, 1.0)
|
||||||
@@ -632,9 +647,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
|||||||
blueV := ph.V * 0.95
|
blueV := ph.V * 0.95
|
||||||
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
|
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
|
||||||
|
|
||||||
// Color5 matches primary_container exactly (dark container in dark mode)
|
palette.Color5 = NewColorInfo(baseColor)
|
||||||
darkContainer := DeriveContainer(primaryColor, false)
|
|
||||||
palette.Color5 = NewColorInfo(darkContainer)
|
|
||||||
|
|
||||||
palette.Color6 = NewColorInfo(primaryColor)
|
palette.Color6 = NewColorInfo(primaryColor)
|
||||||
|
|
||||||
@@ -681,11 +694,14 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type VariantOptions struct {
|
type VariantOptions struct {
|
||||||
PrimaryDark string
|
PrimaryDark string
|
||||||
PrimaryLight string
|
PrimaryLight string
|
||||||
Background string
|
BackgroundDark string
|
||||||
UseDPS bool
|
BackgroundLight string
|
||||||
IsLightMode bool
|
ContainerDark string
|
||||||
|
ContainerLight string
|
||||||
|
UseDPS bool
|
||||||
|
IsLightMode bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
|
func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
|
||||||
@@ -702,8 +718,8 @@ func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GenerateVariantPalette(opts VariantOptions) VariantPalette {
|
func GenerateVariantPalette(opts VariantOptions) VariantPalette {
|
||||||
darkOpts := PaletteOptions{IsLight: false, Background: opts.Background, UseDPS: opts.UseDPS}
|
darkOpts := PaletteOptions{IsLight: false, Background: opts.BackgroundDark, Container: opts.ContainerDark, UseDPS: opts.UseDPS}
|
||||||
lightOpts := PaletteOptions{IsLight: true, Background: opts.Background, UseDPS: opts.UseDPS}
|
lightOpts := PaletteOptions{IsLight: true, Background: opts.BackgroundLight, Container: opts.ContainerLight, UseDPS: opts.UseDPS}
|
||||||
|
|
||||||
dark := GeneratePalette(opts.PrimaryDark, darkOpts)
|
dark := GeneratePalette(opts.PrimaryDark, darkOpts)
|
||||||
light := GeneratePalette(opts.PrimaryLight, lightOpts)
|
light := GeneratePalette(opts.PrimaryLight, lightOpts)
|
||||||
|
|||||||
@@ -701,7 +701,8 @@ func TestGeneratePaletteWithDPS(t *testing.T) {
|
|||||||
|
|
||||||
bgColor := result.Color0.Hex
|
bgColor := result.Color0.Hex
|
||||||
for i := 1; i < 8; i++ {
|
for i := 1; i < 8; i++ {
|
||||||
// Skip Color5 (container) and Color6 (exact primary) - intentionally not contrast-adjusted
|
// Color5 is the container and Color6 the exact primary: they pair
|
||||||
|
// with each other, not with the background
|
||||||
if i == 5 || i == 6 {
|
if i == 5 || i == 6 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -862,3 +863,112 @@ func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
|
|||||||
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
|
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGeneratePaletteLightYellowMeetsNormalTextContrast(t *testing.T) {
|
||||||
|
primaries := []string{"#63568f", "#7d5800", "#116d2b", "#1c5070", "#8b3f68"}
|
||||||
|
|
||||||
|
for _, primary := range primaries {
|
||||||
|
t.Run(primary, func(t *testing.T) {
|
||||||
|
palette := GeneratePalette(primary, PaletteOptions{IsLight: true, Background: "#f5fafc", UseDPS: true})
|
||||||
|
|
||||||
|
ratio := ContrastRatio(palette.Color3.Hex, palette.Color0.Hex)
|
||||||
|
if ratio < 4.5 {
|
||||||
|
t.Errorf("light Color3 %s vs bg %s ratio %.2f, expected >= 4.5 (yellow must clear the normal-text target)",
|
||||||
|
palette.Color3.Hex, palette.Color0.Hex, ratio)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateVariantPaletteUsesPerVariantBackground(t *testing.T) {
|
||||||
|
const darkBg = "#0f1417"
|
||||||
|
const lightBg = "#f5fafc"
|
||||||
|
|
||||||
|
variants := GenerateVariantPalette(VariantOptions{
|
||||||
|
PrimaryDark: "#8ecff2",
|
||||||
|
PrimaryLight: "#1c5070",
|
||||||
|
BackgroundDark: darkBg,
|
||||||
|
BackgroundLight: lightBg,
|
||||||
|
UseDPS: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if variants.Color0.Dark.Hex != darkBg {
|
||||||
|
t.Errorf("dark Color0 = %s, expected the dark background %s", variants.Color0.Dark.Hex, darkBg)
|
||||||
|
}
|
||||||
|
if variants.Color0.Light.Hex != lightBg {
|
||||||
|
t.Errorf("light Color0 = %s, expected the light background %s", variants.Color0.Light.Hex, lightBg)
|
||||||
|
}
|
||||||
|
|
||||||
|
lightDim := variants.Color8.Light.Hex
|
||||||
|
if lstar := getLstar(lightDim); lstar < 60.0 {
|
||||||
|
t.Errorf("light Color8 %s has L* %.2f, expected >= 60 (derived from the dark surface it collapses to near-black)",
|
||||||
|
lightDim, lstar)
|
||||||
|
}
|
||||||
|
if ratio := ContrastRatio(lightDim, lightBg); ratio > 3.0 {
|
||||||
|
t.Errorf("light Color8 %s vs bg %s ratio %.2f, expected <= 3.0 (bright black stays dim)", lightDim, lightBg, ratio)
|
||||||
|
}
|
||||||
|
|
||||||
|
darkDim := variants.Color8.Dark.Hex
|
||||||
|
if ratio := ContrastRatio(darkDim, darkBg); ratio > 3.0 {
|
||||||
|
t.Errorf("dark Color8 %s vs bg %s ratio %.2f, expected <= 3.0", darkDim, darkBg, ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePaletteColor5PairsWithColor6(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
primary string
|
||||||
|
container string
|
||||||
|
bg string
|
||||||
|
isLight bool
|
||||||
|
}{
|
||||||
|
{"violet dark", "#cdbdff", "#4b3e76", "#141318", false},
|
||||||
|
{"violet light", "#63568f", "#e8deff", "#fdf7ff", true},
|
||||||
|
{"green dark", "#9ed49d", "#1f5027", "#101510", false},
|
||||||
|
{"green light", "#38693c", "#b9f0b8", "#f7fbf2", true},
|
||||||
|
{"amber light", "#805610", "#ffddb3", "#fff8f4", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
palette := GeneratePalette(tt.primary, PaletteOptions{
|
||||||
|
IsLight: tt.isLight,
|
||||||
|
Background: tt.bg,
|
||||||
|
Container: tt.container,
|
||||||
|
UseDPS: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if palette.Color5.Hex != tt.container {
|
||||||
|
t.Errorf("Color5 = %s, expected the supplied container %s", palette.Color5.Hex, tt.container)
|
||||||
|
}
|
||||||
|
if palette.Color6.Hex != tt.primary {
|
||||||
|
t.Errorf("Color6 = %s, expected the primary %s", palette.Color6.Hex, tt.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
pair := ContrastRatio(palette.Color5.Hex, palette.Color6.Hex)
|
||||||
|
if pair < 4.5 {
|
||||||
|
t.Errorf("Color5 %s vs Color6 %s ratio %.2f, expected >= 4.5 (the container/primary pair carries the prompt themes that use ANSI 5 on 6)",
|
||||||
|
palette.Color5.Hex, palette.Color6.Hex, pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
if palette.Color5.Hex == palette.Color13.Hex {
|
||||||
|
t.Errorf("Color5 and Color13 are both %s, expected a distinct bright variant", palette.Color5.Hex)
|
||||||
|
}
|
||||||
|
if sep := ContrastRatio(palette.Color5.Hex, palette.Color13.Hex); sep < 2.0 {
|
||||||
|
t.Errorf("Color5 %s vs Color13 %s ratio %.2f, expected >= 2.0 (normal and bright must be tellable apart)",
|
||||||
|
palette.Color5.Hex, palette.Color13.Hex, sep)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePaletteFallsBackToDerivedContainer(t *testing.T) {
|
||||||
|
const primary = "#cdbdff"
|
||||||
|
|
||||||
|
palette := GeneratePalette(primary, PaletteOptions{Background: "#141318", UseDPS: true})
|
||||||
|
|
||||||
|
want := DeriveContainer(primary, false)
|
||||||
|
if palette.Color5.Hex != want {
|
||||||
|
t.Errorf("Color5 = %s with no container supplied, expected the derived %s", palette.Color5.Hex, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AqueousProvider struct{}
|
||||||
|
|
||||||
|
func NewAqueousProvider() *AqueousProvider { return &AqueousProvider{} }
|
||||||
|
func (*AqueousProvider) Name() string { return "aqueous" }
|
||||||
|
|
||||||
|
func aqueousObjects(snapshot aqueousConfig, key string) []aqueousConfig {
|
||||||
|
values, _ := snapshot[key].([]any)
|
||||||
|
result := make([]aqueousConfig, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if object, ok := value.(map[string]any); ok {
|
||||||
|
result = append(result, aqueousConfig(object))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func AqueousCheatSheet(snapshot aqueousConfig) (*keybinds.CheatSheet, error) {
|
||||||
|
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sheet := &keybinds.CheatSheet{Title: "Aqueous", Provider: "aqueous", Generation: snapshot.String("generation"), ModKey: "Super", DMSBindsIncluded: true, Binds: map[string][]keybinds.Keybind{"Compositor": {}, "Custom": {}}}
|
||||||
|
for _, field := range aqueousObjects(snapshot, "fields") {
|
||||||
|
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values, _ := field["value"].([]any)
|
||||||
|
if len(values) == 0 {
|
||||||
|
values = []any{""}
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
key, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid keybinding list")
|
||||||
|
}
|
||||||
|
sheet.Binds["Compositor"] = append(sheet.Binds["Compositor"], keybinds.Keybind{Key: key, Action: field.String("id"), Description: field.String("label"), Source: "dms"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
|
||||||
|
sheet.Binds["Custom"] = append(sheet.Binds["Custom"], keybinds.Keybind{Key: binding.String("chord"), Action: "spawn " + binding.String("command"), Description: binding.String("command"), Source: "dms"})
|
||||||
|
}
|
||||||
|
seen := map[string]keybinds.Keybind{}
|
||||||
|
for _, category := range []string{"Compositor", "Custom"} {
|
||||||
|
for i, bind := range sheet.Binds[category] {
|
||||||
|
if bind.Key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if previous, exists := seen[bind.Key]; exists {
|
||||||
|
sheet.Binds[category][i].Conflict = &previous
|
||||||
|
}
|
||||||
|
seen[bind.Key] = bind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sheet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AqueousProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
||||||
|
snapshot, err := aqueousHelper(context.Background(), "snapshot", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return AqueousCheatSheet(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AqueousBindEdit struct {
|
||||||
|
Generation string
|
||||||
|
OriginalKey string
|
||||||
|
Key string
|
||||||
|
Action string
|
||||||
|
Remove bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func AqueousBindRequest(snapshot aqueousConfig, edit AqueousBindEdit) (aqueousConfig, error) {
|
||||||
|
if edit.Generation == "" || edit.Generation != snapshot.String("generation") {
|
||||||
|
return nil, &AqueousError{Code: "external_change", Message: "reload and reconcile the retained keybind draft"}
|
||||||
|
}
|
||||||
|
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if edit.Key == "" {
|
||||||
|
return nil, errors.New("missing key")
|
||||||
|
}
|
||||||
|
original := edit.OriginalKey
|
||||||
|
if original == "" {
|
||||||
|
original = edit.Key
|
||||||
|
}
|
||||||
|
originalCount := 0
|
||||||
|
for _, field := range aqueousObjects(snapshot, "fields") {
|
||||||
|
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values, _ := field["value"].([]any)
|
||||||
|
for _, value := range values {
|
||||||
|
if value == original {
|
||||||
|
originalCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
|
||||||
|
if binding.String("chord") == original {
|
||||||
|
originalCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if originalCount > 1 {
|
||||||
|
return nil, &AqueousError{Code: "ambiguous_target", Message: "multiple bindings use the original shortcut"}
|
||||||
|
}
|
||||||
|
if originalCount == 0 && (edit.Remove || edit.OriginalKey != "") {
|
||||||
|
return nil, &AqueousError{Code: "target_removed", Message: "the original shortcut no longer exists"}
|
||||||
|
}
|
||||||
|
changes := []any{}
|
||||||
|
customChanges := []any{}
|
||||||
|
foundAction := false
|
||||||
|
for _, field := range aqueousObjects(snapshot, "fields") {
|
||||||
|
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
old, _ := field["value"].([]any)
|
||||||
|
values := make([]any, 0, len(old)+1)
|
||||||
|
for _, value := range old {
|
||||||
|
if value == original {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !edit.Remove && value == edit.Key && field.String("id") != edit.Action {
|
||||||
|
return nil, fmt.Errorf("key conflict: %s", edit.Key)
|
||||||
|
}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
if field.String("id") == edit.Action && !edit.Remove {
|
||||||
|
foundAction = true
|
||||||
|
if !slices.Contains(values, any(edit.Key)) {
|
||||||
|
values = append(values, edit.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(old, values) {
|
||||||
|
changes = append(changes, map[string]any{"id": field.String("id"), "value": values})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
|
||||||
|
if binding.String("chord") == original {
|
||||||
|
customChanges = append(customChanges, map[string]any{"op": "delete", "id": binding.String("id")})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !edit.Remove && binding.String("chord") == edit.Key {
|
||||||
|
return nil, fmt.Errorf("key conflict: %s", edit.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !edit.Remove && !foundAction {
|
||||||
|
command, ok := strings.CutPrefix(edit.Action, "spawn ")
|
||||||
|
if !ok || strings.TrimSpace(command) == "" {
|
||||||
|
return nil, errors.New("unknown Aqueous action; custom commands use spawn")
|
||||||
|
}
|
||||||
|
customChanges = append(customChanges, map[string]any{"op": "add", "chord": edit.Key, "command": command})
|
||||||
|
}
|
||||||
|
return aqueousConfig{"expected_generation": edit.Generation, "changes": changes, "custom_keybind_changes": customChanges, "create_user_override": true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AqueousProvider) Edit(ctx context.Context, edit AqueousBindEdit) (aqueousConfig, error) {
|
||||||
|
snapshot, err := aqueousHelper(ctx, "snapshot", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request, err := AqueousBindRequest(snapshot, edit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return aqueousHelper(ctx, "apply", request)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func helperResult(ctx context.Context, verb string, input []byte) (aqueousConfig, error) {
|
||||||
|
var result aqueousConfig
|
||||||
|
args := []string{verb, "--shell", "dms"}
|
||||||
|
if input != nil {
|
||||||
|
args = append(args, "--request", "-")
|
||||||
|
}
|
||||||
|
err := utils.RunJSON(ctx, "aqueous-config", args, input, &result)
|
||||||
|
if err != nil || !result.Bool("ok") {
|
||||||
|
if result.String("code") != "" {
|
||||||
|
return result, &AqueousError{Code: result.String("code"), Message: result.String("message")}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if verb == "apply" {
|
||||||
|
return result, &AqueousError{Code: "uncertain", Message: err.Error()}
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
return result, errors.New("aqueous-config rejected the request")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AqueousError struct {
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AqueousError) Error() string { return e.Code + ": " + e.Message }
|
||||||
|
|
||||||
|
func requireAqueousCapabilities(result aqueousConfig, required ...string) error {
|
||||||
|
if result.Number("protocol") != 1 {
|
||||||
|
return errors.New("unsupported aqueous-config protocol")
|
||||||
|
}
|
||||||
|
values, ok := result["capabilities"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("unsupported aqueous-config: capability discovery required")
|
||||||
|
}
|
||||||
|
for _, capability := range required {
|
||||||
|
if !slices.Contains(values, any(capability)) {
|
||||||
|
return fmt.Errorf("unsupported aqueous-config capability: %s", capability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aqueousHelper(ctx context.Context, verb string, request aqueousConfig) (aqueousConfig, error) {
|
||||||
|
if verb == "snapshot" {
|
||||||
|
result, err := helperResult(ctx, verb, nil)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if err := requireAqueousCapabilities(result, "schema_fields", "shell_dms"); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if result.String("generation") == "" {
|
||||||
|
return result, errors.New("missing configuration generation")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if verb != "validate" && verb != "apply" {
|
||||||
|
return nil, errors.New("unsupported helper operation")
|
||||||
|
}
|
||||||
|
if request.String("expected_generation") == "" {
|
||||||
|
return nil, errors.New("missing expected_generation; reload configuration")
|
||||||
|
}
|
||||||
|
version, err := helperResult(ctx, "version", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
required := []string{"keybinds", "validate", "stdin_requests", "generation_check", "atomic_file_replace", "shell_dms"}
|
||||||
|
if err := requireAqueousCapabilities(version, required...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request["protocol"] = 1
|
||||||
|
if _, exists := request["backup_dir"]; !exists {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request["backup_dir"] = filepath.Join(configDir, "DankMaterialShell", "aqueous-backups")
|
||||||
|
}
|
||||||
|
input, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
validated, err := helperResult(ctx, "validate", input)
|
||||||
|
if err != nil || verb == "validate" {
|
||||||
|
return validated, err
|
||||||
|
}
|
||||||
|
return helperResult(ctx, "apply", input)
|
||||||
|
}
|
||||||
|
|
||||||
|
type aqueousConfig map[string]any
|
||||||
|
|
||||||
|
func (e aqueousConfig) String(key string) string { value, _ := e[key].(string); return value }
|
||||||
|
func (e aqueousConfig) Bool(key string) bool { value, _ := e[key].(bool); return value }
|
||||||
|
func (e aqueousConfig) Number(key string) float64 {
|
||||||
|
value, _ := e[key].(json.Number)
|
||||||
|
number, _ := value.Float64()
|
||||||
|
return number
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func aqueousFixture(t *testing.T) aqueousConfig {
|
||||||
|
t.Helper()
|
||||||
|
var snapshot aqueousConfig
|
||||||
|
err := utils.DecodeJSON([]byte(`{"ok":true,"protocol":1,"generation":"abc","capabilities":["keybinds"],"fields":[{"id":"spawn_terminal","category":"keybinds","type":"string_list","label":"Terminal","value":["Super+Return","Super+T"]},{"id":"close","category":"keybinds","type":"string_list","label":"Close","value":[]}],"custom_keybinds":[{"id":"custom:12","chord":"Super+R","command":"printf 'hello; $world'"}]}`), &snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAqueousCheatsheetIncludesUnboundAndCustom(t *testing.T) {
|
||||||
|
sheet, err := AqueousCheatSheet(aqueousFixture(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sheet.Generation != "abc" || len(sheet.Binds["Compositor"]) != 3 {
|
||||||
|
t.Fatal("lost generation or multiple bindings")
|
||||||
|
}
|
||||||
|
if sheet.Binds["Compositor"][2].Key != "" || sheet.Binds["Compositor"][2].Action != "close" {
|
||||||
|
t.Fatal("unbound action lost")
|
||||||
|
}
|
||||||
|
if sheet.Binds["Custom"][0].Action != "spawn printf 'hello; $world'" {
|
||||||
|
t.Fatal("custom command modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAqueousBindingReplacementIsOneRequest(t *testing.T) {
|
||||||
|
snapshot := aqueousFixture(t)
|
||||||
|
request, err := AqueousBindRequest(snapshot, AqueousBindEdit{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+Enter", Action: "spawn_terminal"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(request["changes"])
|
||||||
|
if string(data) != `[{"id":"spawn_terminal","value":["Super+T","Super+Enter"]}]` {
|
||||||
|
t.Fatalf("wrong atomic replacement: %s", data)
|
||||||
|
}
|
||||||
|
if request["expected_generation"] != "abc" {
|
||||||
|
t.Fatal("generation replaced")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(snapshot, aqueousFixture(t)) {
|
||||||
|
t.Fatal("draft mutated snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAqueousBindingConflictAndRemove(t *testing.T) {
|
||||||
|
for _, edit := range []AqueousBindEdit{
|
||||||
|
{Generation: "stale", Key: "Super+T", Remove: true},
|
||||||
|
{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+R", Action: "close"},
|
||||||
|
{Generation: "abc", Key: "Super+Q", Action: "invented_action"},
|
||||||
|
} {
|
||||||
|
if _, err := AqueousBindRequest(aqueousFixture(t), edit); err == nil {
|
||||||
|
t.Fatal("invalid edit accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request, err := AqueousBindRequest(aqueousFixture(t), AqueousBindEdit{Generation: "abc", Key: "Super+R", Remove: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(request["custom_keybind_changes"])
|
||||||
|
if string(data) != `[{"id":"custom:12","op":"delete"}]` {
|
||||||
|
t.Fatalf("wrong removal: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAqueousGenerationConflictCode(t *testing.T) {
|
||||||
|
for _, generation := range []string{"", "stale"} {
|
||||||
|
_, err := AqueousBindRequest(aqueousFixture(t), AqueousBindEdit{Generation: generation, Key: "Super+R", Remove: true})
|
||||||
|
var conflict *AqueousError
|
||||||
|
if !errors.As(err, &conflict) || conflict.Code != "external_change" {
|
||||||
|
t.Fatalf("expected structured generation conflict, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAqueousBindingRequiresUniqueOriginal(t *testing.T) {
|
||||||
|
for _, edit := range []AqueousBindEdit{
|
||||||
|
{Generation: "abc", Key: "Super+Missing", Remove: true},
|
||||||
|
{Generation: "abc", OriginalKey: "Super+Missing", Key: "Super+X", Action: "spawn_terminal"},
|
||||||
|
} {
|
||||||
|
_, err := AqueousBindRequest(aqueousFixture(t), edit)
|
||||||
|
var conflict *AqueousError
|
||||||
|
if !errors.As(err, &conflict) || conflict.Code != "target_removed" {
|
||||||
|
t.Fatalf("expected missing original, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshot := aqueousFixture(t)
|
||||||
|
snapshot["custom_keybinds"] = append(snapshot["custom_keybinds"].([]any), map[string]any{"id": "duplicate", "chord": "Super+T", "command": "echo duplicate"})
|
||||||
|
_, err := AqueousBindRequest(snapshot, AqueousBindEdit{Generation: "abc", Key: "Super+T", Remove: true})
|
||||||
|
var conflict *AqueousError
|
||||||
|
if !errors.As(err, &conflict) || conflict.Code != "ambiguous_target" {
|
||||||
|
t.Fatalf("expected ambiguous original, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAqueousHelperFailureCodes(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
script string
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"conflict", `printf '%s' '{"ok":false,"code":"external_change","message":"changed"}'; exit 1`, "external_change"},
|
||||||
|
{"missing_ack", "exit 0", "uncertain"},
|
||||||
|
{"failed_process", "exit 1", "uncertain"},
|
||||||
|
{"malformed_ack", "printf '{'", "uncertain"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "aqueous-config"), []byte("#!/bin/sh\n"+tc.script+"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
_, err := helperResult(context.Background(), "apply", nil)
|
||||||
|
var failure *AqueousError
|
||||||
|
if !errors.As(err, &failure) || failure.Code != tc.code {
|
||||||
|
t.Fatalf("expected %s, got %v", tc.code, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ type DMSBindsStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CheatSheet struct {
|
type CheatSheet struct {
|
||||||
|
Generation string `json:"generation,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
ModKey string `json:"modKey,omitempty"`
|
ModKey string `json:"modKey,omitempty"`
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ func buildOnce(opts *Options) (bool, error) {
|
|||||||
|
|
||||||
oldColors, _ := os.ReadFile(opts.ColorsOutput())
|
oldColors, _ := os.ReadFile(opts.ColorsOutput())
|
||||||
|
|
||||||
var primaryDark, primaryLight, surface string
|
var primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string
|
||||||
var dank16JSON string
|
var dank16JSON string
|
||||||
var importArgs []string
|
var importArgs []string
|
||||||
var sourceImage string
|
var sourceImage string
|
||||||
@@ -373,7 +373,10 @@ func buildOnce(opts *Options) (bool, error) {
|
|||||||
log.Info("Using stock/custom theme colors with matugen base")
|
log.Info("Using stock/custom theme colors with matugen base")
|
||||||
primaryDark = extractNestedColor(opts.StockColors, "primary", "dark")
|
primaryDark = extractNestedColor(opts.StockColors, "primary", "dark")
|
||||||
primaryLight = extractNestedColor(opts.StockColors, "primary", "light")
|
primaryLight = extractNestedColor(opts.StockColors, "primary", "light")
|
||||||
surface = extractNestedColor(opts.StockColors, "surface", "dark")
|
surfaceDark = extractNestedColor(opts.StockColors, "surface", "dark")
|
||||||
|
surfaceLight = extractNestedColor(opts.StockColors, "surface", "light")
|
||||||
|
containerDark = extractNestedColor(opts.StockColors, "primary_container", "dark")
|
||||||
|
containerLight = extractNestedColor(opts.StockColors, "primary_container", "light")
|
||||||
|
|
||||||
if primaryDark == "" {
|
if primaryDark == "" {
|
||||||
return false, fmt.Errorf("failed to extract primary dark from stock colors")
|
return false, fmt.Errorf("failed to extract primary dark from stock colors")
|
||||||
@@ -382,7 +385,7 @@ func buildOnce(opts *Options) (bool, error) {
|
|||||||
primaryLight = primaryDark
|
primaryLight = primaryDark
|
||||||
}
|
}
|
||||||
|
|
||||||
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
|
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
|
||||||
importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON)
|
importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON)
|
||||||
importArgs = []string{"--import-json-string", importData}
|
importArgs = []string{"--import-json-string", importData}
|
||||||
|
|
||||||
@@ -403,7 +406,10 @@ func buildOnce(opts *Options) (bool, error) {
|
|||||||
|
|
||||||
primaryDark = extractMatugenColor(matJSON, "primary", "dark")
|
primaryDark = extractMatugenColor(matJSON, "primary", "dark")
|
||||||
primaryLight = extractMatugenColor(matJSON, "primary", "light")
|
primaryLight = extractMatugenColor(matJSON, "primary", "light")
|
||||||
surface = extractMatugenColor(matJSON, "surface", "dark")
|
surfaceDark = extractMatugenColor(matJSON, "surface", "dark")
|
||||||
|
surfaceLight = extractMatugenColor(matJSON, "surface", "light")
|
||||||
|
containerDark = extractMatugenColor(matJSON, "primary_container", "dark")
|
||||||
|
containerLight = extractMatugenColor(matJSON, "primary_container", "light")
|
||||||
|
|
||||||
if primaryDark == "" {
|
if primaryDark == "" {
|
||||||
return false, fmt.Errorf("failed to extract primary color")
|
return false, fmt.Errorf("failed to extract primary color")
|
||||||
@@ -412,7 +418,7 @@ func buildOnce(opts *Options) (bool, error) {
|
|||||||
primaryLight = primaryDark
|
primaryLight = primaryDark
|
||||||
}
|
}
|
||||||
|
|
||||||
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
|
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
|
||||||
importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage)}
|
importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage)}
|
||||||
|
|
||||||
log.Infof("Running matugen %s with dank16 injection", opts.Kind)
|
log.Infof("Running matugen %s with dank16 injection", opts.Kind)
|
||||||
@@ -500,23 +506,25 @@ func buildImportData(dank16JSON, image string) string {
|
|||||||
return fmt.Sprintf(`{"dank16": %s, "image": %s}`, dank16JSON, path)
|
return fmt.Sprintf(`{"dank16": %s, "image": %s}`, dank16JSON, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func userConfigSection(opts *Options) string {
|
||||||
|
if !opts.RunUserTemplates || opts.ConfigDir == "" {
|
||||||
|
return "[config]\n\n"
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(opts.ConfigDir, "matugen", "config.toml"))
|
||||||
|
if err != nil {
|
||||||
|
return "[config]\n\n"
|
||||||
|
}
|
||||||
|
section := extractTOMLSection(string(data), "[config]", "[templates]")
|
||||||
|
if section == "" {
|
||||||
|
return "[config]\n\n"
|
||||||
|
}
|
||||||
|
return section + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
|
func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
|
||||||
userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml")
|
userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml")
|
||||||
|
|
||||||
wroteConfig := false
|
cfgFile.WriteString(userConfigSection(opts))
|
||||||
if opts.RunUserTemplates {
|
|
||||||
if data, err := os.ReadFile(userConfigPath); err == nil {
|
|
||||||
configSection := extractTOMLSection(string(data), "[config]", "[templates]")
|
|
||||||
if configSection != "" {
|
|
||||||
cfgFile.WriteString(configSection)
|
|
||||||
cfgFile.WriteString("\n")
|
|
||||||
wroteConfig = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !wroteConfig {
|
|
||||||
cfgFile.WriteString("[config]\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
baseConfigPath := filepath.Join(opts.ShellDir, "matugen", "configs", "base.toml")
|
baseConfigPath := filepath.Join(opts.ShellDir, "matugen", "configs", "base.toml")
|
||||||
if data, err := os.ReadFile(baseConfigPath); err == nil {
|
if data, err := os.ReadFile(baseConfigPath); err == nil {
|
||||||
@@ -983,7 +991,32 @@ func runMatugenDryRun(opts *Options) (string, error) {
|
|||||||
return execDryRun(opts, newFlags)
|
return execDryRun(opts, newFlags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matugen aborts on a config file it cannot deserialize, and without -c it
|
||||||
|
// reads the user's own ~/.config/matugen/config.toml, so a dry run gets a
|
||||||
|
// config of its own instead of inheriting whatever is there.
|
||||||
|
func writeDryRunConfig(opts *Options) (string, error) {
|
||||||
|
cfgFile, err := os.CreateTemp("", "matugen-dryrun-*.toml")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create dry-run config: %w", err)
|
||||||
|
}
|
||||||
|
defer cfgFile.Close()
|
||||||
|
|
||||||
|
section := userConfigSection(opts)
|
||||||
|
if idx := strings.Index(section, "\n[templates"); idx != -1 {
|
||||||
|
section = section[:idx+1]
|
||||||
|
}
|
||||||
|
cfgFile.WriteString(section)
|
||||||
|
cfgFile.WriteString("[templates]\n")
|
||||||
|
return cfgFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func execDryRun(opts *Options, flags matugenFlags) (string, error) {
|
func execDryRun(opts *Options, flags matugenFlags) (string, error) {
|
||||||
|
cfgPath, err := writeDryRunConfig(opts)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer os.Remove(cfgPath)
|
||||||
|
|
||||||
var baseArgs []string
|
var baseArgs []string
|
||||||
switch opts.Kind {
|
switch opts.Kind {
|
||||||
case "hex":
|
case "hex":
|
||||||
@@ -991,7 +1024,7 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
|
|||||||
default:
|
default:
|
||||||
baseArgs = []string{opts.Kind, opts.Value}
|
baseArgs = []string{opts.Kind, opts.Value}
|
||||||
}
|
}
|
||||||
baseArgs = append(baseArgs, "-m", string(opts.Mode), "-t", opts.MatugenType, "--json", "hex", "--dry-run")
|
baseArgs = append(baseArgs, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgPath, "--json", "hex", "--dry-run")
|
||||||
baseArgs = appendContrastArg(baseArgs, opts.Contrast)
|
baseArgs = appendContrastArg(baseArgs, opts.Contrast)
|
||||||
if flags.isV4 {
|
if flags.isV4 {
|
||||||
baseArgs = append(baseArgs, sourceSelectionArgs(opts.SourceMode, flags.supportsPrefer)...)
|
baseArgs = append(baseArgs, sourceSelectionArgs(opts.SourceMode, flags.supportsPrefer)...)
|
||||||
@@ -1098,13 +1131,16 @@ func extractNestedColor(jsonStr, colorName, variant string) string {
|
|||||||
return color
|
return color
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string {
|
func generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string, mode ColorMode) string {
|
||||||
variantOpts := dank16.VariantOptions{
|
variantOpts := dank16.VariantOptions{
|
||||||
PrimaryDark: primaryDark,
|
PrimaryDark: primaryDark,
|
||||||
PrimaryLight: primaryLight,
|
PrimaryLight: primaryLight,
|
||||||
Background: surface,
|
BackgroundDark: surfaceDark,
|
||||||
UseDPS: true,
|
BackgroundLight: surfaceLight,
|
||||||
IsLightMode: mode == ColorModeLight,
|
ContainerDark: containerDark,
|
||||||
|
ContainerLight: containerLight,
|
||||||
|
UseDPS: true,
|
||||||
|
IsLightMode: mode == ColorModeLight,
|
||||||
}
|
}
|
||||||
variantColors := dank16.GenerateVariantPalette(variantOpts)
|
variantColors := dank16.GenerateVariantPalette(variantOpts)
|
||||||
return dank16.GenerateVariantJSON(variantColors)
|
return dank16.GenerateVariantJSON(variantColors)
|
||||||
|
|||||||
@@ -1327,3 +1327,76 @@ func TestResolveSmartMode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeUserMatugenConfig(t *testing.T, configDir, content string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(configDir, "matugen")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create matugen config dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write user config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteDryRunConfigAlwaysDeclaresConfigAndTemplates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
userConfig string
|
||||||
|
runUserTemplates bool
|
||||||
|
wantContains []string
|
||||||
|
wantNotContains []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "comment only user config",
|
||||||
|
userConfig: "# nothing here\n",
|
||||||
|
runUserTemplates: true,
|
||||||
|
wantNotContains: []string{"# nothing here"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user config section is carried over",
|
||||||
|
userConfig: "[config]\nfallback_color = \"#ff0000\"\n\n[templates.mine]\ninput_path = 'in'\noutput_path = 'out'\n",
|
||||||
|
runUserTemplates: true,
|
||||||
|
wantContains: []string{"fallback_color = \"#ff0000\""},
|
||||||
|
wantNotContains: []string{"[templates.mine]"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user templates disabled",
|
||||||
|
userConfig: "[config]\nfallback_color = \"#ff0000\"\n",
|
||||||
|
wantNotContains: []string{"fallback_color"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
configDir := filepath.Join(t.TempDir(), "config")
|
||||||
|
writeUserMatugenConfig(t, configDir, tc.userConfig)
|
||||||
|
|
||||||
|
opts := &Options{ConfigDir: configDir, RunUserTemplates: tc.runUserTemplates}
|
||||||
|
cfgPath, err := writeDryRunConfig(opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeDryRunConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(cfgPath)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read dry-run config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
assert.Contains(t, content, "[config]")
|
||||||
|
assert.Contains(t, content, "[templates]")
|
||||||
|
for _, want := range tc.wantContains {
|
||||||
|
assert.Contains(t, content, want)
|
||||||
|
}
|
||||||
|
for _, unwanted := range tc.wantNotContains {
|
||||||
|
assert.NotContains(t, content, unwanted)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserConfigSectionWithoutConfigDir(t *testing.T) {
|
||||||
|
assert.Equal(t, "[config]\n\n", userConfigSection(&Options{RunUserTemplates: true}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf16"
|
"unicode/utf16"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -148,9 +149,5 @@ func readQString(r io.Reader) (string, error) {
|
|||||||
|
|
||||||
// SocketPathForPID returns the instance IPC socket used by qs --pid.
|
// SocketPathForPID returns the instance IPC socket used by qs --pid.
|
||||||
func SocketPathForPID(pid int) string {
|
func SocketPathForPID(pid int) string {
|
||||||
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
|
return filepath.Join(utils.RuntimeDir(), "quickshell", "by-pid", fmt.Sprint(pid), "ipc.sock")
|
||||||
if runtimeDir == "" {
|
|
||||||
runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid())
|
|
||||||
}
|
|
||||||
return filepath.Join(runtimeDir, "quickshell", "by-pid", fmt.Sprint(pid), "ipc.sock")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package screenshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
func aqueousWindowGeometry(model aqueousSnapshotModel, seatName string) (*WindowGeometry, error) {
|
||||||
|
seat, err := model.Seat(seatName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if seat.FocusKind != "window" {
|
||||||
|
return nil, errors.New("no active window: seat focus is not a window")
|
||||||
|
}
|
||||||
|
window := model.Entities["window:"+seat.Window]
|
||||||
|
if window == nil || !window.Visible || window.Minimized || !window.CanActivate {
|
||||||
|
return nil, errors.New("no eligible active window")
|
||||||
|
}
|
||||||
|
output := model.Entities["output:"+window.Output]
|
||||||
|
if output == nil || !output.Enabled || !output.Powered {
|
||||||
|
return nil, errors.New("active window output is unavailable")
|
||||||
|
}
|
||||||
|
box, bounds := window.OuterGeometry, output.Bounds
|
||||||
|
if box == nil || bounds == nil || !box.valid() || !bounds.valid() || box.Width <= 0 || box.Height <= 0 || output.Scale <= 0 || math.IsInf(output.Scale, 0) || math.IsNaN(output.Scale) {
|
||||||
|
return nil, errors.New("invalid active window geometry")
|
||||||
|
}
|
||||||
|
return &WindowGeometry{X: int32(box.X), Y: int32(box.Y), Width: int32(box.Width), Height: int32(box.Height), Output: output.Name, Scale: output.Scale, OutputX: int32(bounds.X), OutputY: int32(bounds.Y)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aqueousCropRect(geom *WindowGeometry, pixels image.Rectangle) image.Rectangle {
|
||||||
|
left := math.Floor((float64(geom.X) - float64(geom.OutputX)) * geom.Scale)
|
||||||
|
top := math.Floor((float64(geom.Y) - float64(geom.OutputY)) * geom.Scale)
|
||||||
|
right := math.Ceil((float64(geom.X) + float64(geom.Width) - float64(geom.OutputX)) * geom.Scale)
|
||||||
|
bottom := math.Ceil((float64(geom.Y) + float64(geom.Height) - float64(geom.OutputY)) * geom.Scale)
|
||||||
|
return image.Rect(int(left), int(top), int(right), int(bottom)).Intersect(pixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aqueousFocusedOutput(seatName string) (string, error) {
|
||||||
|
model, err := aqueousSnapshot(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
seat, err := model.Seat(seatName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
output := model.Entities["output:"+seat.Output]
|
||||||
|
if output == nil || !output.Enabled || !output.Powered {
|
||||||
|
return "", errors.New("selected output is unavailable")
|
||||||
|
}
|
||||||
|
return output.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Screenshoter) captureAqueousWindow() (*CaptureResult, error) {
|
||||||
|
model, err := aqueousSnapshot(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
geom, err := aqueousWindowGeometry(model, s.config.Seat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
region := Region{X: geom.X, Y: geom.Y, Width: geom.Width, Height: geom.Height, Output: geom.Output}
|
||||||
|
if s.config.Geometry {
|
||||||
|
return geometryResult(region), nil
|
||||||
|
}
|
||||||
|
output := s.findOutputByName(geom.Output)
|
||||||
|
if output == nil {
|
||||||
|
return nil, errors.New("active window output disappeared")
|
||||||
|
}
|
||||||
|
result, err := s.captureWholeOutput(output)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer result.Buffer.Close()
|
||||||
|
rect := aqueousCropRect(geom, image.Rect(0, 0, result.Buffer.Width, result.Buffer.Height))
|
||||||
|
if rect.Empty() {
|
||||||
|
return nil, errors.New("active window is outside the output")
|
||||||
|
}
|
||||||
|
bpp := PixelFormat(result.Format).BytesPerPixel()
|
||||||
|
cropped, err := CreateShmBuffer(rect.Dx(), rect.Dy(), rect.Dx()*bpp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
src, dst := result.Buffer.Data(), cropped.Data()
|
||||||
|
for y := 0; y < rect.Dy(); y++ {
|
||||||
|
start := (rect.Min.Y+y)*result.Buffer.Stride + rect.Min.X*bpp
|
||||||
|
copy(dst[y*cropped.Stride:y*cropped.Stride+rect.Dx()*bpp], src[start:start+rect.Dx()*bpp])
|
||||||
|
}
|
||||||
|
cropped.Format = PixelFormat(result.Format)
|
||||||
|
return &CaptureResult{Buffer: cropped, Region: region, Format: result.Format, Scale: geom.Scale, CICP: result.CICP}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package screenshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func aqueousSnapshot(ctx context.Context) (aqueousSnapshotModel, error) {
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return aqueousSnapshotModel{}, err
|
||||||
|
}
|
||||||
|
var data json.RawMessage
|
||||||
|
if err := utils.RunJSON(ctx, executable, []string{"ipc", "call", "aqueous", "snapshot", os.Getenv("AQUEOUS_SOCKET")}, nil, &data); err != nil {
|
||||||
|
return aqueousSnapshotModel{}, fmt.Errorf("get Aqueous state from DMS; a shell must be running in this session: %w", err)
|
||||||
|
}
|
||||||
|
return parseAqueousSnapshot(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type aqueousBox struct {
|
||||||
|
X, Y, Width, Height int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b aqueousBox) valid() bool {
|
||||||
|
for _, value := range []int64{b.X, b.Y, b.Width, b.Height} {
|
||||||
|
if value < math.MinInt32 || value > math.MaxInt32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type aqueousCaptureEntity struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
Window string `json:"window"`
|
||||||
|
FocusKind string `json:"focus_kind"`
|
||||||
|
Visible bool `json:"visible"`
|
||||||
|
Minimized bool `json:"minimized"`
|
||||||
|
CanActivate bool `json:"can_activate"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Powered bool `json:"powered"`
|
||||||
|
Scale float64 `json:"scale"`
|
||||||
|
Bounds *aqueousBox `json:"bounds"`
|
||||||
|
OuterGeometry *aqueousBox `json:"outer_geometry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aqueousSnapshotModel struct {
|
||||||
|
Entities map[string]*aqueousCaptureEntity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m aqueousSnapshotModel) Seat(name string) (*aqueousCaptureEntity, error) {
|
||||||
|
if name != "" {
|
||||||
|
if seat := m.Entities["seat:"+name]; seat != nil {
|
||||||
|
return seat, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("not_found: seat")
|
||||||
|
}
|
||||||
|
var seat *aqueousCaptureEntity
|
||||||
|
for _, entity := range m.Entities {
|
||||||
|
if entity.Kind != "seat" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seat != nil {
|
||||||
|
return nil, errors.New("ambiguous_seat")
|
||||||
|
}
|
||||||
|
seat = entity
|
||||||
|
}
|
||||||
|
if seat == nil {
|
||||||
|
return nil, errors.New("unavailable: no seat")
|
||||||
|
}
|
||||||
|
return seat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAqueousSnapshot(data []byte) (aqueousSnapshotModel, error) {
|
||||||
|
var batch struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Schema int `json:"schema"`
|
||||||
|
Session string `json:"session"`
|
||||||
|
Sequence string `json:"sequence"`
|
||||||
|
BaseSequence json.RawMessage `json:"base_sequence"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Upsert []aqueousCaptureEntity `json:"upsert"`
|
||||||
|
Removed []string `json:"removed"`
|
||||||
|
}
|
||||||
|
var model aqueousSnapshotModel
|
||||||
|
if err := utils.DecodeJSON(data, &batch); err != nil {
|
||||||
|
return model, err
|
||||||
|
}
|
||||||
|
if batch.Error != "" {
|
||||||
|
return model, errors.New("aqueous state is unavailable in the running DMS shell")
|
||||||
|
}
|
||||||
|
if batch.Schema != 1 || batch.Type != "snapshot" || !bytes.Equal(batch.BaseSequence, []byte("null")) || batch.Upsert == nil || batch.Removed == nil || len(batch.Removed) != 0 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(batch.Session) || !regexp.MustCompile(`^[0-9]+$`).MatchString(batch.Sequence) {
|
||||||
|
return model, errors.New("invalid Aqueous shell snapshot")
|
||||||
|
}
|
||||||
|
model.Entities = make(map[string]*aqueousCaptureEntity, len(batch.Upsert))
|
||||||
|
for i := range batch.Upsert {
|
||||||
|
entity := &batch.Upsert[i]
|
||||||
|
key := entity.Kind + ":" + entity.ID
|
||||||
|
if entity.ID == "" || entity.Kind == "" || model.Entities[key] != nil {
|
||||||
|
return aqueousSnapshotModel{}, errors.New("invalid or duplicate shell entity")
|
||||||
|
}
|
||||||
|
model.Entities[key] = entity
|
||||||
|
}
|
||||||
|
if model.Entities["session:session"] == nil {
|
||||||
|
return aqueousSnapshotModel{}, errors.New("missing session entity")
|
||||||
|
}
|
||||||
|
return model, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package screenshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAqueousCropRoundsOutwardAndClips(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
geometry WindowGeometry
|
||||||
|
pixels image.Rectangle
|
||||||
|
want image.Rectangle
|
||||||
|
}{
|
||||||
|
{WindowGeometry{X: -1919, Y: 1, Width: 101, Height: 51, OutputX: -1920, Scale: 1.25}, image.Rect(0, 0, 2400, 1350), image.Rect(1, 1, 128, 65)},
|
||||||
|
{WindowGeometry{X: -20, Y: -10, Width: 100, Height: 60, Scale: 1.5}, image.Rect(0, 0, 800, 600), image.Rect(0, 0, 120, 75)},
|
||||||
|
{WindowGeometry{X: 90, Y: 80, Width: 100, Height: 100, Scale: 1}, image.Rect(0, 0, 100, 100), image.Rect(90, 80, 100, 100)},
|
||||||
|
{WindowGeometry{X: 200, Y: 200, Width: 20, Height: 20, Scale: 1}, image.Rect(0, 0, 100, 100), image.Rectangle{}},
|
||||||
|
} {
|
||||||
|
if got := aqueousCropRect(&test.geometry, test.pixels); got != test.want {
|
||||||
|
t.Fatalf("got %v want %v", got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAqueousGeometryUsesSeatAndOuterBounds(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("testdata/aqueous-snapshot.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
model, err := parseAqueousSnapshot(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
geometry, err := aqueousWindowGeometry(model, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seat, _ := model.Seat("")
|
||||||
|
window := model.Entities["window:"+seat.Window]
|
||||||
|
if geometry.Width != int32(window.OuterGeometry.Width) || geometry.X != int32(window.OuterGeometry.X) {
|
||||||
|
t.Fatal("wrong bounds")
|
||||||
|
}
|
||||||
|
seat.FocusKind = "layer_surface"
|
||||||
|
if _, err := aqueousWindowGeometry(model, ""); err == nil {
|
||||||
|
t.Fatal("layer focus captured")
|
||||||
|
}
|
||||||
|
seat.FocusKind = "window"
|
||||||
|
model.Entities["seat:second"] = &aqueousCaptureEntity{Kind: "seat", ID: "second"}
|
||||||
|
if _, err := aqueousWindowGeometry(model, ""); err == nil {
|
||||||
|
t.Fatal("ambiguous seat captured")
|
||||||
|
}
|
||||||
|
if _, err := aqueousWindowGeometry(model, seat.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
delete(model.Entities, "window:"+seat.Window)
|
||||||
|
if _, err := aqueousWindowGeometry(model, seat.ID); err == nil {
|
||||||
|
t.Fatal("removed window captured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAqueousSnapshotRejectsInvalidInput(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("testdata/aqueous-snapshot.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, input := range [][]byte{
|
||||||
|
[]byte(`{}`),
|
||||||
|
bytes.Replace(data, []byte(`"schema": 1`), []byte(`"schema": 2`), 1),
|
||||||
|
bytes.Replace(data, []byte(`"type": "snapshot"`), []byte(`"type": "delta"`), 1),
|
||||||
|
append(data, []byte(`{}`)...),
|
||||||
|
{0xff},
|
||||||
|
} {
|
||||||
|
if _, err := parseAqueousSnapshot(input); err == nil {
|
||||||
|
t.Fatal("invalid snapshot accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package screenshot
|
package screenshot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -21,6 +22,7 @@ const (
|
|||||||
CompositorScroll
|
CompositorScroll
|
||||||
CompositorMiracle
|
CompositorMiracle
|
||||||
CompositorMango
|
CompositorMango
|
||||||
|
CompositorAqueous
|
||||||
)
|
)
|
||||||
|
|
||||||
var detectedCompositor Compositor = -1
|
var detectedCompositor Compositor = -1
|
||||||
@@ -35,6 +37,7 @@ func DetectCompositor() Compositor {
|
|||||||
needsStat bool
|
needsStat bool
|
||||||
compositor Compositor
|
compositor Compositor
|
||||||
}{
|
}{
|
||||||
|
{os.Getenv("AQUEOUS_SOCKET"), true, CompositorAqueous},
|
||||||
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
|
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
|
||||||
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
|
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
|
||||||
{os.Getenv("SCROLLSOCK"), true, CompositorScroll},
|
{os.Getenv("SCROLLSOCK"), true, CompositorScroll},
|
||||||
@@ -78,8 +81,14 @@ func GetActiveWindow() (*WindowGeometry, error) {
|
|||||||
return getHyprlandActiveWindow()
|
return getHyprlandActiveWindow()
|
||||||
case CompositorMango:
|
case CompositorMango:
|
||||||
return getMangoActiveWindow()
|
return getMangoActiveWindow()
|
||||||
|
case CompositorAqueous:
|
||||||
|
model, err := aqueousSnapshot(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return aqueousWindowGeometry(model, "")
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
|
return nil, fmt.Errorf("window capture requires Hyprland, Mango, niri, or Aqueous")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +364,9 @@ func getNiriFocusedMonitor() string {
|
|||||||
|
|
||||||
func GetFocusedMonitor() string {
|
func GetFocusedMonitor() string {
|
||||||
switch DetectCompositor() {
|
switch DetectCompositor() {
|
||||||
|
case CompositorAqueous:
|
||||||
|
name, _ := aqueousFocusedOutput("")
|
||||||
|
return name
|
||||||
case CompositorHyprland:
|
case CompositorHyprland:
|
||||||
return getHyprlandFocusedMonitor()
|
return getHyprlandFocusedMonitor()
|
||||||
case CompositorSway:
|
case CompositorSway:
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const niriScreenshotTimeout = 5 * time.Second
|
const niriScreenshotTimeout = 5 * time.Second
|
||||||
@@ -24,11 +26,7 @@ func CaptureNiriWindowImage(showPointer bool) (image.Image, error) {
|
|||||||
return nil, fmt.Errorf("NIRI_SOCKET not set")
|
return nil, fmt.Errorf("NIRI_SOCKET not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := os.Getenv("XDG_RUNTIME_DIR")
|
path := filepath.Join(utils.RuntimeDir(), fmt.Sprintf("dms-window-%d.png", os.Getpid()))
|
||||||
if dir == "" {
|
|
||||||
dir = os.TempDir()
|
|
||||||
}
|
|
||||||
path := filepath.Join(dir, fmt.Sprintf("dms-window-%d.png", os.Getpid()))
|
|
||||||
|
|
||||||
events, err := subscribeNiriEvents(socket)
|
events, err := subscribeNiriEvents(socket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_cursor_shape"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_cursor_shape"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
|
||||||
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
|
||||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,9 +85,10 @@ type OutputSurface struct {
|
|||||||
slots [3]*RenderSlot
|
slots [3]*RenderSlot
|
||||||
slotsReady bool
|
slotsReady bool
|
||||||
|
|
||||||
shown *overlay
|
shown *overlay
|
||||||
framePending bool
|
framePending bool
|
||||||
redrawQueued bool
|
redrawQueued bool
|
||||||
|
committedCursor bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreCapture struct {
|
type PreCapture struct {
|
||||||
@@ -107,7 +109,9 @@ type RegionSelector struct {
|
|||||||
shm *client.Shm
|
shm *client.Shm
|
||||||
seat *client.Seat
|
seat *client.Seat
|
||||||
pointer *client.Pointer
|
pointer *client.Pointer
|
||||||
|
touch *client.Touch
|
||||||
keyboard *client.Keyboard
|
keyboard *client.Keyboard
|
||||||
|
keymap *keymap.Keymap
|
||||||
layerShell *wlr_layer_shell.ZwlrLayerShellV1
|
layerShell *wlr_layer_shell.ZwlrLayerShellV1
|
||||||
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
|
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
|
||||||
viewporter *wp_viewporter.WpViewporter
|
viewporter *wp_viewporter.WpViewporter
|
||||||
@@ -136,6 +140,8 @@ type RegionSelector struct {
|
|||||||
selection SelectionState
|
selection SelectionState
|
||||||
pointerX float64
|
pointerX float64
|
||||||
pointerY float64
|
pointerY float64
|
||||||
|
hasTouchPoint bool
|
||||||
|
touchPointId int32
|
||||||
preSelect Region
|
preSelect Region
|
||||||
showCapturedCursor bool
|
showCapturedCursor bool
|
||||||
shiftHeld bool
|
shiftHeld bool
|
||||||
@@ -967,7 +973,12 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
|||||||
case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase, handles, shift):
|
case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase, handles, shift):
|
||||||
slot.shm.CopyFrom(srcBuf)
|
slot.shm.CopyFrom(srcBuf)
|
||||||
r.dimBackground(slot.shm)
|
r.dimBackground(slot.shm)
|
||||||
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat)
|
effectiveScale := 1.0
|
||||||
|
if os.output != nil {
|
||||||
|
effectiveScale = os.output.effectiveScale()
|
||||||
|
}
|
||||||
|
hudScale := r.hudScale(effectiveScale)
|
||||||
|
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat, hudScale)
|
||||||
slot.backgroundInitialized = true
|
slot.backgroundInitialized = true
|
||||||
slot.backgroundSource = srcBuf
|
slot.backgroundSource = srcBuf
|
||||||
slot.backgroundDragging = r.selection.dragging
|
slot.backgroundDragging = r.selection.dragging
|
||||||
@@ -979,7 +990,10 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
|||||||
case r.compositorVersion >= 4:
|
case r.compositorVersion >= 4:
|
||||||
fullDamage = false
|
fullDamage = false
|
||||||
damage = overlayDamage(os.shown, cur)
|
damage = overlayDamage(os.shown, cur)
|
||||||
if len(damage) == 0 {
|
if os.committedCursor != r.showCapturedCursor {
|
||||||
|
fullDamage = true
|
||||||
|
damage = nil
|
||||||
|
} else if len(damage) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1023,6 +1037,7 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ = os.wlSurface.Commit()
|
_ = os.wlSurface.Commit()
|
||||||
|
os.committedCursor = r.showCapturedCursor
|
||||||
|
|
||||||
// Mark this slot as busy until compositor releases it
|
// Mark this slot as busy until compositor releases it
|
||||||
slot.busy = true
|
slot.busy = true
|
||||||
@@ -1097,6 +1112,9 @@ func (r *RegionSelector) cleanup() {
|
|||||||
if r.pointer != nil {
|
if r.pointer != nil {
|
||||||
r.pointer.Release()
|
r.pointer.Release()
|
||||||
}
|
}
|
||||||
|
if r.touch != nil {
|
||||||
|
_ = r.touch.Release()
|
||||||
|
}
|
||||||
if r.keyboard != nil {
|
if r.keyboard != nil {
|
||||||
r.keyboard.Release()
|
r.keyboard.Release()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package screenshot
|
|||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
|
||||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +19,12 @@ func (r *RegionSelector) setupInput() {
|
|||||||
r.setupPointerHandlers()
|
r.setupPointerHandlers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if e.Capabilities&uint32(client.SeatCapabilityTouch) != 0 && r.touch == nil {
|
||||||
|
if touch, err := r.seat.GetTouch(); err == nil {
|
||||||
|
r.touch = touch
|
||||||
|
r.setupTouchHandlers()
|
||||||
|
}
|
||||||
|
}
|
||||||
if e.Capabilities&uint32(client.SeatCapabilityKeyboard) != 0 && r.keyboard == nil {
|
if e.Capabilities&uint32(client.SeatCapabilityKeyboard) != 0 && r.keyboard == nil {
|
||||||
if keyboard, err := r.seat.GetKeyboard(); err == nil {
|
if keyboard, err := r.seat.GetKeyboard(); err == nil {
|
||||||
r.keyboard = keyboard
|
r.keyboard = keyboard
|
||||||
@@ -145,6 +152,143 @@ func (r *RegionSelector) setupPointerHandlers() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) setupTouchHandlers() {
|
||||||
|
r.touch.SetDownHandler(func(e client.TouchDownEvent) {
|
||||||
|
r.handleTouchDown(e.Surface, e.Id, e.X, e.Y)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.touch.SetMotionHandler(func(e client.TouchMotionEvent) {
|
||||||
|
r.handleTouchMotion(e.Id, e.X, e.Y)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.touch.SetUpHandler(func(e client.TouchUpEvent) {
|
||||||
|
r.handleTouchUp(e.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.touch.SetCancelHandler(func(e client.TouchCancelEvent) {
|
||||||
|
r.handleTouchCancel()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) handleTouchDown(surface *client.Surface, touchId int32, x, y float64) {
|
||||||
|
if r.hasTouchPoint {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.activeSurface = nil
|
||||||
|
for _, os := range r.surfaces {
|
||||||
|
if os.wlSurface != nil && surface != nil && os.wlSurface.ID() == surface.ID() {
|
||||||
|
r.activeSurface = os
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.activeSurface == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.hasTouchPoint = true
|
||||||
|
r.touchPointId = touchId
|
||||||
|
|
||||||
|
if r.phase == phaseScroll {
|
||||||
|
if r.activeSurface != r.selection.surface {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.scrollBarHit(x, y) {
|
||||||
|
case "done", "preview":
|
||||||
|
r.finishScroll()
|
||||||
|
case "cancel":
|
||||||
|
r.cancelled = true
|
||||||
|
r.running = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
touchX := x + float64(r.activeSurface.output.x)
|
||||||
|
touchY := y + float64(r.activeSurface.output.y)
|
||||||
|
|
||||||
|
if r.ctrlHeld && r.selection.hasSelection {
|
||||||
|
if handle := r.resizeHandleAt(touchX, touchY); handle != handleNone {
|
||||||
|
if r.beginSelectionResize(handle, touchX, touchY) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.beginSelectionMove(touchX, touchY) {
|
||||||
|
r.selection.dragging = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.preSelect = Region{}
|
||||||
|
r.movingSelection = false
|
||||||
|
r.resizingHandle = handleNone
|
||||||
|
r.selection.hasSelection = true
|
||||||
|
r.selection.dragging = true
|
||||||
|
r.selection.surface = r.activeSurface
|
||||||
|
r.selection.anchorX = touchX
|
||||||
|
r.selection.anchorY = touchY
|
||||||
|
r.selection.currentX = r.selection.anchorX
|
||||||
|
r.selection.currentY = r.selection.anchorY
|
||||||
|
|
||||||
|
for _, os := range r.surfaces {
|
||||||
|
r.redrawSurface(os)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) handleTouchMotion(touchId int32, x, y float64) {
|
||||||
|
if !r.hasTouchPoint || touchId != r.touchPointId || r.activeSurface == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.phase == phaseScroll {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.selection.dragging {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.updateSelectionCurrent(r.activeSurface, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) handleTouchUp(touchId int32) {
|
||||||
|
if !r.hasTouchPoint || touchId != r.touchPointId {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.hasTouchPoint = false
|
||||||
|
r.selection.dragging = false
|
||||||
|
r.movingSelection = false
|
||||||
|
r.resizingHandle = handleNone
|
||||||
|
|
||||||
|
if r.phase == phaseScroll {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, os := range r.surfaces {
|
||||||
|
r.redrawSurface(os)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.screenshoter != nil && r.screenshoter.config.NoConfirm && r.selection.hasSelection {
|
||||||
|
r.finishSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) handleTouchCancel() {
|
||||||
|
if !r.hasTouchPoint {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.hasTouchPoint = false
|
||||||
|
r.selection.dragging = false
|
||||||
|
r.movingSelection = false
|
||||||
|
r.resizingHandle = handleNone
|
||||||
|
|
||||||
|
for _, os := range r.surfaces {
|
||||||
|
r.redrawSurface(os)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *RegionSelector) updateSelectionCurrent(os *OutputSurface, surfaceX, surfaceY float64) {
|
func (r *RegionSelector) updateSelectionCurrent(os *OutputSurface, surfaceX, surfaceY float64) {
|
||||||
if os == nil || os.output == nil || !r.selection.dragging {
|
if os == nil || os.output == nil || !r.selection.dragging {
|
||||||
return
|
return
|
||||||
@@ -432,52 +576,60 @@ func (r *RegionSelector) setupKeyboardHandlers() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.keyboard.SetKeymapHandler(func(e client.KeyboardKeymapEvent) {
|
||||||
|
r.keymap = keymap.FromEvent(e)
|
||||||
|
})
|
||||||
|
|
||||||
r.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
|
r.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
|
||||||
switch e.Key {
|
r.handleKey(r.keymap.Keysym(e.Key), e.State)
|
||||||
case 29, 97: // Ctrl left/right
|
})
|
||||||
ctrl := e.State != 0
|
}
|
||||||
if ctrl != r.ctrlHeld {
|
|
||||||
r.ctrlHeld = ctrl
|
func (r *RegionSelector) handleKey(sym string, state uint32) {
|
||||||
r.refreshCursor()
|
held := state != 0
|
||||||
if r.selection.hasSelection {
|
switch sym {
|
||||||
for _, os := range r.surfaces {
|
case "Control_L", "Control_R":
|
||||||
r.redrawSurface(os)
|
if held != r.ctrlHeld {
|
||||||
}
|
r.ctrlHeld = held
|
||||||
|
r.refreshCursor()
|
||||||
|
if r.selection.hasSelection {
|
||||||
|
for _, os := range r.surfaces {
|
||||||
|
r.redrawSurface(os)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 56, 100: // Alt left/right
|
|
||||||
r.altHeld = e.State != 0
|
|
||||||
}
|
|
||||||
if e.State != 1 {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
case "Alt_L", "Alt_R":
|
||||||
|
r.altHeld = held
|
||||||
|
}
|
||||||
|
if state != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if r.phase == phaseScroll {
|
if r.phase == phaseScroll {
|
||||||
switch e.Key {
|
switch sym {
|
||||||
case 1:
|
case "Escape":
|
||||||
r.cancelled = true
|
|
||||||
r.running = false
|
|
||||||
case 28, 96:
|
|
||||||
r.finishScroll()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch e.Key {
|
|
||||||
case 1:
|
|
||||||
r.cancelled = true
|
r.cancelled = true
|
||||||
r.running = false
|
r.running = false
|
||||||
case 25:
|
case "Return", "KP_Enter":
|
||||||
r.showCapturedCursor = !r.showCapturedCursor
|
r.finishScroll()
|
||||||
for _, os := range r.surfaces {
|
|
||||||
r.redrawSurface(os)
|
|
||||||
}
|
|
||||||
case 28, 57, 96:
|
|
||||||
if r.selection.hasSelection {
|
|
||||||
r.finishSelection()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sym {
|
||||||
|
case "Escape":
|
||||||
|
r.cancelled = true
|
||||||
|
r.running = false
|
||||||
|
case "p":
|
||||||
|
r.showCapturedCursor = !r.showCapturedCursor
|
||||||
|
for _, os := range r.surfaces {
|
||||||
|
r.redrawSurface(os)
|
||||||
|
}
|
||||||
|
case "Return", "space", "KP_Enter":
|
||||||
|
if r.selection.hasSelection {
|
||||||
|
r.finishSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, int) {
|
func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, int) {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package screenshot
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHandleKeyEscapeCancels(t *testing.T) {
|
||||||
|
for _, phase := range []selectorPhase{phaseSelect, phaseScroll} {
|
||||||
|
r := &RegionSelector{running: true, phase: phase}
|
||||||
|
r.handleKey("Escape", 1)
|
||||||
|
if !r.cancelled || r.running {
|
||||||
|
t.Errorf("phase %v: cancelled=%v running=%v after Escape", phase, r.cancelled, r.running)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleKeyIgnoresReleaseAndCapsLock(t *testing.T) {
|
||||||
|
r := &RegionSelector{running: true}
|
||||||
|
r.handleKey("Escape", 0)
|
||||||
|
r.handleKey("Caps_Lock", 1)
|
||||||
|
if r.cancelled || !r.running {
|
||||||
|
t.Errorf("cancelled=%v running=%v, want untouched", r.cancelled, r.running)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleKeyTogglesCapturedCursor(t *testing.T) {
|
||||||
|
r := &RegionSelector{running: true, showCapturedCursor: true}
|
||||||
|
r.handleKey("p", 1)
|
||||||
|
if r.showCapturedCursor {
|
||||||
|
t.Error("p did not toggle captured cursor off")
|
||||||
|
}
|
||||||
|
r.handleKey("p", 1)
|
||||||
|
if !r.showCapturedCursor {
|
||||||
|
t.Error("p did not toggle captured cursor back on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleKeyTracksModifiers(t *testing.T) {
|
||||||
|
r := &RegionSelector{running: true}
|
||||||
|
r.handleKey("Control_R", 1)
|
||||||
|
r.handleKey("Alt_L", 1)
|
||||||
|
if !r.ctrlHeld || !r.altHeld {
|
||||||
|
t.Errorf("ctrl=%v alt=%v after press, want both held", r.ctrlHeld, r.altHeld)
|
||||||
|
}
|
||||||
|
r.handleKey("Control_R", 0)
|
||||||
|
r.handleKey("Alt_L", 0)
|
||||||
|
if r.ctrlHeld || r.altHeld {
|
||||||
|
t.Errorf("ctrl=%v alt=%v after release, want both released", r.ctrlHeld, r.altHeld)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,25 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var fontGlyphs = map[rune][12]uint8{
|
const (
|
||||||
|
fontCharW = 8
|
||||||
|
fontCharH = 12
|
||||||
|
fontStep = fontCharW + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
func scaleFactor(scale float64) int {
|
||||||
|
s := int(math.Round(scale))
|
||||||
|
if s < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var fontGlyphs = map[rune][fontCharH]uint8{
|
||||||
'0': {0x3C, 0x66, 0x66, 0x6E, 0x76, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00},
|
'0': {0x3C, 0x66, 0x66, 0x6E, 0x76, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00},
|
||||||
'1': {0x18, 0x38, 0x78, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00, 0x00},
|
'1': {0x18, 0x38, 0x78, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00, 0x00},
|
||||||
'2': {0x3C, 0x66, 0x66, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x66, 0x7E, 0x00, 0x00},
|
'2': {0x3C, 0x66, 0x66, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x66, 0x7E, 0x00, 0x00},
|
||||||
@@ -593,12 +609,11 @@ func (r *RegionSelector) drawScrollPreview(data []byte, stride, bufW, bufH int,
|
|||||||
func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, format uint32) {
|
func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, format uint32) {
|
||||||
s := r.scroll
|
s := r.scroll
|
||||||
style := LoadOverlayStyle()
|
style := LoadOverlayStyle()
|
||||||
const charH = 12
|
|
||||||
|
|
||||||
r.fillRect(data, stride, bufW, bufH, s.barX, s.barY, s.barW, s.barH,
|
r.fillRect(data, stride, bufW, bufH, s.barX, s.barY, s.barW, s.barH,
|
||||||
style.BackgroundR, style.BackgroundG, style.BackgroundB, 245, format)
|
style.BackgroundR, style.BackgroundG, style.BackgroundB, 245, format)
|
||||||
|
|
||||||
labelY := s.doneY + (s.btnH-charH)/2
|
labelY := s.doneY + (s.btnH-fontCharH)/2
|
||||||
r.fillRect(data, stride, bufW, bufH, s.doneX, s.doneY, s.doneW, s.btnH,
|
r.fillRect(data, stride, bufW, bufH, s.doneX, s.doneY, s.doneW, s.btnH,
|
||||||
style.AccentR, style.AccentG, style.AccentB, 255, format)
|
style.AccentR, style.AccentG, style.AccentB, 255, format)
|
||||||
r.drawText(data, stride, bufW, bufH, s.doneX+12, labelY, "done", 10, 10, 10, format)
|
r.drawText(data, stride, bufW, bufH, s.doneX+12, labelY, "done", 10, 10, 10, format)
|
||||||
@@ -617,9 +632,41 @@ func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, form
|
|||||||
style.TextR, style.TextG, style.TextB, format)
|
style.TextR, style.TextG, style.TextB, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH int) {
|
const (
|
||||||
const charW, charH, padding, itemSpacing = 8, 12, 12, 24
|
minHUDScale = 1
|
||||||
|
maxHUDScale = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *RegionSelector) hudScale(effectiveScale float64) int {
|
||||||
|
hudOpt := "auto"
|
||||||
|
if r.screenshoter != nil && r.screenshoter.config.HUD != "" {
|
||||||
|
hudOpt = strings.ToLower(strings.TrimSpace(r.screenshoter.config.HUD))
|
||||||
|
}
|
||||||
|
switch hudOpt {
|
||||||
|
case "off", "none", "false", "0":
|
||||||
|
return 0
|
||||||
|
case "", "auto":
|
||||||
|
return min(scaleFactor(effectiveScale), maxHUDScale)
|
||||||
|
default:
|
||||||
|
if val, err := strconv.ParseFloat(hudOpt, 64); err == nil {
|
||||||
|
if val <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return min(max(scaleFactor(val), minHUDScale), maxHUDScale)
|
||||||
|
}
|
||||||
|
return min(scaleFactor(effectiveScale), maxHUDScale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type hudItem struct {
|
||||||
|
key, desc string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h hudItem) width(step int) int {
|
||||||
|
return (len(h.key) + 1 + len(h.desc)) * step
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) hudItems() []hudItem {
|
||||||
cursorLabel := "hide"
|
cursorLabel := "hide"
|
||||||
if !r.showCapturedCursor {
|
if !r.showCapturedCursor {
|
||||||
cursorLabel = "show"
|
cursorLabel = "show"
|
||||||
@@ -629,16 +676,28 @@ func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH i
|
|||||||
captureKey = "Drag+Release"
|
captureKey = "Drag+Release"
|
||||||
}
|
}
|
||||||
|
|
||||||
items := []struct{ key, desc string }{
|
return []hudItem{
|
||||||
{captureKey, "capture"},
|
{captureKey, "capture"},
|
||||||
{"Ctrl", "resize/move"},
|
{"Ctrl", "resize/move"},
|
||||||
{"P", cursorLabel + " cursor"},
|
{"P", cursorLabel + " cursor"},
|
||||||
{"Esc", "cancel"},
|
{"Esc", "cancel"},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) hudDimensions(bufW, bufH, scale int) (hudX, hudY, hudW, hudH int) {
|
||||||
|
if scale <= 0 {
|
||||||
|
return 0, 0, 0, 0
|
||||||
|
}
|
||||||
|
scale = scaleFactor(float64(scale))
|
||||||
|
charH := fontCharH * scale
|
||||||
|
padding, itemSpacing := 12*scale, 24*scale
|
||||||
|
margin := 20 * scale
|
||||||
|
|
||||||
|
items := r.hudItems()
|
||||||
totalW := 0
|
totalW := 0
|
||||||
|
step := fontStep * scale
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
totalW += len(item.key)*(charW+1) + (1+len(item.desc))*(charW+1)
|
totalW += item.width(step)
|
||||||
if i < len(items)-1 {
|
if i < len(items)-1 {
|
||||||
totalW += itemSpacing
|
totalW += itemSpacing
|
||||||
}
|
}
|
||||||
@@ -647,46 +706,32 @@ func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH i
|
|||||||
hudW = totalW + padding*2
|
hudW = totalW + padding*2
|
||||||
hudH = charH + padding*2
|
hudH = charH + padding*2
|
||||||
hudX = (bufW - hudW) / 2
|
hudX = (bufW - hudW) / 2
|
||||||
hudY = bufH - hudH - 20
|
hudY = bufH - hudH - margin
|
||||||
return hudX, hudY, hudW, hudH
|
return hudX, hudY, hudW, hudH
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
|
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32, scale int) {
|
||||||
if r.selection.dragging {
|
if r.selection.dragging || scale <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH)
|
scale = scaleFactor(float64(scale))
|
||||||
|
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
|
||||||
style := LoadOverlayStyle()
|
style := LoadOverlayStyle()
|
||||||
r.fillRect(data, stride, bufW, bufH, hudX, hudY, hudW, hudH,
|
r.fillRect(data, stride, bufW, bufH, hudX, hudY, hudW, hudH,
|
||||||
style.BackgroundR, style.BackgroundG, style.BackgroundB, style.BackgroundA, format)
|
style.BackgroundR, style.BackgroundG, style.BackgroundB, style.BackgroundA, format)
|
||||||
|
|
||||||
const charW, padding, itemSpacing = 8, 12, 24
|
padding, itemSpacing := 12*scale, 24*scale
|
||||||
cursorLabel := "hide"
|
step := fontStep * scale
|
||||||
if !r.showCapturedCursor {
|
items := r.hudItems()
|
||||||
cursorLabel = "show"
|
|
||||||
}
|
|
||||||
captureKey := "Space/Enter"
|
|
||||||
if r.screenshoter != nil && r.screenshoter.config.NoConfirm {
|
|
||||||
captureKey = "Drag+Release"
|
|
||||||
}
|
|
||||||
|
|
||||||
items := []struct{ key, desc string }{
|
|
||||||
{captureKey, "capture"},
|
|
||||||
{"Ctrl", "resize/move"},
|
|
||||||
{"P", cursorLabel + " cursor"},
|
|
||||||
{"Esc", "cancel"},
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, ty := hudX+padding, hudY+padding
|
tx, ty := hudX+padding, hudY+padding
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
r.drawText(data, stride, bufW, bufH, tx, ty, item.key,
|
r.drawTextScaled(data, stride, bufW, bufH, tx, ty, item.key,
|
||||||
style.AccentR, style.AccentG, style.AccentB, format)
|
style.AccentR, style.AccentG, style.AccentB, format, scale)
|
||||||
tx += len(item.key) * (charW + 1)
|
r.drawTextScaled(data, stride, bufW, bufH, tx+len(item.key)*step, ty, " "+item.desc,
|
||||||
|
style.TextR, style.TextG, style.TextB, format, scale)
|
||||||
r.drawText(data, stride, bufW, bufH, tx, ty, " "+item.desc,
|
tx += item.width(step)
|
||||||
style.TextR, style.TextG, style.TextB, format)
|
|
||||||
tx += (1 + len(item.desc)) * (charW + 1)
|
|
||||||
|
|
||||||
if i < len(items)-1 {
|
if i < len(items)-1 {
|
||||||
tx += itemSpacing
|
tx += itemSpacing
|
||||||
@@ -741,24 +786,29 @@ func (r *RegionSelector) drawVLine(data []byte, stride, bufW, bufH, x, y, length
|
|||||||
}
|
}
|
||||||
|
|
||||||
func labelRect(b selectionRenderBounds, bufW, bufH int) (dirtyRect, string) {
|
func labelRect(b selectionRenderBounds, bufW, bufH int) (dirtyRect, string) {
|
||||||
|
scale := scaleFactor(b.scaleX)
|
||||||
text := b.labelText
|
text := b.labelText
|
||||||
|
charH := fontCharH * scale
|
||||||
const charW, charH = 8, 12
|
step := fontStep * scale
|
||||||
textW := len(text) * (charW + 1)
|
textW := len(text) * step
|
||||||
|
padX := 4 * scale
|
||||||
|
padY := 2 * scale
|
||||||
|
offsetY := 8 * scale
|
||||||
|
|
||||||
tx := b.x + (b.w-textW)/2
|
tx := b.x + (b.w-textW)/2
|
||||||
ty := b.y + b.h + 8
|
ty := b.y + b.h + offsetY
|
||||||
if ty+charH > bufH {
|
if ty+charH > bufH {
|
||||||
ty = b.y - charH - 8
|
ty = b.y - charH - offsetY
|
||||||
}
|
}
|
||||||
tx = clamp(tx, 0, bufW-textW)
|
tx = clamp(tx, 0, bufW-textW)
|
||||||
return dirtyRect{x1: tx - 4, y1: ty - 2, x2: tx + textW + 4, y2: ty + charH + 2}, text
|
return dirtyRect{x1: tx - padX, y1: ty - padY, x2: tx + textW + padX, y2: ty + charH + padY}, text
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RegionSelector) drawLabel(data []byte, stride, bufW, bufH int, o *overlay, format uint32) {
|
func (r *RegionSelector) drawLabel(data []byte, stride, bufW, bufH int, o *overlay, format uint32) {
|
||||||
|
scale := scaleFactor(o.scaleX)
|
||||||
l := o.label
|
l := o.label
|
||||||
r.fillRect(data, stride, bufW, bufH, l.x1, l.y1, l.x2-l.x1, l.y2-l.y1, 0, 0, 0, 200, format)
|
r.fillRect(data, stride, bufW, bufH, l.x1, l.y1, l.x2-l.x1, l.y2-l.y1, 0, 0, 0, 200, format)
|
||||||
r.drawText(data, stride, bufW, bufH, l.x1+4, l.y1+2, o.text, 255, 255, 255, format)
|
r.drawTextScaled(data, stride, bufW, bufH, l.x1+4*scale, l.y1+2*scale, o.text, 255, 255, 255, format, scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatIsBGR(format uint32) bool {
|
func formatIsBGR(format uint32) bool {
|
||||||
@@ -795,41 +845,55 @@ func (r *RegionSelector) fillRect(data []byte, stride, bufW, bufH, x, y, w, h in
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *RegionSelector) drawText(data []byte, stride, bufW, bufH, x, y int, text string, cr, cg, cb uint8, format uint32) {
|
func (r *RegionSelector) drawText(data []byte, stride, bufW, bufH, x, y int, text string, cr, cg, cb uint8, format uint32) {
|
||||||
|
r.drawTextScaled(data, stride, bufW, bufH, x, y, text, cr, cg, cb, format, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) drawTextScaled(data []byte, stride, bufW, bufH, x, y int, text string, cr, cg, cb uint8, format uint32, scale int) {
|
||||||
|
scale = scaleFactor(float64(scale))
|
||||||
|
step := fontStep * scale
|
||||||
for i, ch := range text {
|
for i, ch := range text {
|
||||||
r.drawChar(data, stride, bufW, bufH, x+i*9, y, ch, cr, cg, cb, format)
|
r.drawChar(data, stride, bufW, bufH, x+i*step, y, ch, cr, cg, cb, format, scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RegionSelector) drawChar(data []byte, stride, bufW, bufH, x, y int, ch rune, cr, cg, cb uint8, format uint32) {
|
func (r *RegionSelector) drawChar(data []byte, stride, bufW, bufH, x, y int, ch rune, cr, cg, cb uint8, format uint32, scale int) {
|
||||||
glyph, ok := fontGlyphs[ch]
|
glyph, ok := fontGlyphs[ch]
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scale = scaleFactor(float64(scale))
|
||||||
|
|
||||||
c0, c2 := cb, cr
|
c0, c2 := cb, cr
|
||||||
if formatIsBGR(format) {
|
if formatIsBGR(format) {
|
||||||
c0, c2 = cr, cb
|
c0, c2 = cr, cb
|
||||||
}
|
}
|
||||||
|
|
||||||
for row := range 12 {
|
for row := range fontCharH {
|
||||||
py := y + row
|
|
||||||
if py < 0 || py >= bufH {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
bits := glyph[row]
|
bits := glyph[row]
|
||||||
for col := range 8 {
|
for dy := range scale {
|
||||||
if (bits & (1 << (7 - col))) == 0 {
|
py := y + row*scale + dy
|
||||||
|
if py < 0 || py >= bufH {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
px := x + col
|
rowOff := py * stride
|
||||||
if px < 0 || px >= bufW {
|
for col := range fontCharW {
|
||||||
continue
|
if (bits & (1 << (7 - col))) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
baseX := x + col*scale
|
||||||
|
for dx := range scale {
|
||||||
|
px := baseX + dx
|
||||||
|
if px < 0 || px >= bufW {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
off := rowOff + px*4
|
||||||
|
if off+3 >= len(data) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data[off], data[off+1], data[off+2], data[off+3] = c0, cg, c2, 255
|
||||||
|
}
|
||||||
}
|
}
|
||||||
off := py*stride + px*4
|
|
||||||
if off+3 >= len(data) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data[off], data[off+1], data[off+2], data[off+3] = c0, cg, c2, 255
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,18 +238,26 @@ func TestHUDDimensionsContainText(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH)
|
// When scale <= 0, hudDimensions should return zero
|
||||||
data := make([]byte, bufH*stride)
|
hx, hy, hw, hh := r.hudDimensions(bufW, bufH, 0)
|
||||||
r.drawHUD(data, stride, bufW, bufH, uint32(FormatARGB8888))
|
if hx != 0 || hy != 0 || hw != 0 || hh != 0 {
|
||||||
|
t.Fatalf("expected 0 dimensions for scale 0, got %d, %d, %d, %d", hx, hy, hw, hh)
|
||||||
|
}
|
||||||
|
|
||||||
// Assert no lit pixel falls outside the pill boundaries
|
for _, scale := range []int{1, 2} {
|
||||||
for y := range bufH {
|
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
|
||||||
for x := range bufW {
|
data := make([]byte, bufH*stride)
|
||||||
off := y*stride + x*4
|
r.drawHUD(data, stride, bufW, bufH, uint32(FormatARGB8888), scale)
|
||||||
if data[off] != 0 || data[off+1] != 0 || data[off+2] != 0 || data[off+3] != 0 {
|
|
||||||
if x < hudX || x >= hudX+hudW || y < hudY || y >= hudY+hudH {
|
// Assert no lit pixel falls outside the pill boundaries
|
||||||
t.Fatalf("noConfirm=%v showCursor=%v: pixel (%d, %d) is non-zero outside HUD pill [%d..%d, %d..%d]",
|
for y := range bufH {
|
||||||
noConfirm, showCursor, x, y, hudX, hudX+hudW, hudY, hudY+hudH)
|
for x := range bufW {
|
||||||
|
off := y*stride + x*4
|
||||||
|
if data[off] != 0 || data[off+1] != 0 || data[off+2] != 0 || data[off+3] != 0 {
|
||||||
|
if x < hudX || x >= hudX+hudW || y < hudY || y >= hudY+hudH {
|
||||||
|
t.Fatalf("scale=%d noConfirm=%v showCursor=%v: pixel (%d, %d) is non-zero outside HUD pill [%d..%d, %d..%d]",
|
||||||
|
scale, noConfirm, showCursor, x, y, hudX, hudX+hudW, hudY, hudY+hudH)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,6 +266,47 @@ func TestHUDDimensionsContainText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHUDScale(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
hudOpt string
|
||||||
|
effectiveScale float64
|
||||||
|
expectedScale int
|
||||||
|
}{
|
||||||
|
{"auto", 1.0, 1},
|
||||||
|
{"auto", 1.8, 2},
|
||||||
|
{"auto", 2.4, 2},
|
||||||
|
{"", 1.0, 1},
|
||||||
|
{"", 2.0, 2},
|
||||||
|
{"off", 1.0, 0},
|
||||||
|
{"OFF", 2.0, 0},
|
||||||
|
{"none", 1.5, 0},
|
||||||
|
{"false", 1.0, 0},
|
||||||
|
{"0", 2.0, 0},
|
||||||
|
{"0.0", 1.0, 0},
|
||||||
|
{"-1", 1.0, 0},
|
||||||
|
{"1", 1.0, 1},
|
||||||
|
{"1.5", 1.0, 2},
|
||||||
|
{"2", 1.0, 2},
|
||||||
|
{"3", 1.0, 3},
|
||||||
|
{"4", 1.0, 4},
|
||||||
|
{"1000", 1.0, 4},
|
||||||
|
{"0.2", 1.0, 1},
|
||||||
|
{"invalid", 1.7, 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
r := &RegionSelector{
|
||||||
|
screenshoter: &Screenshoter{
|
||||||
|
config: Config{HUD: tc.hudOpt},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := r.hudScale(tc.effectiveScale)
|
||||||
|
if got != tc.expectedScale {
|
||||||
|
t.Errorf("hudScale(%q, %v) = %d, expected %d", tc.hudOpt, tc.effectiveScale, got, tc.expectedScale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOverlayDeltaHandlesClampedMonitorEdge(t *testing.T) {
|
func TestOverlayDeltaHandlesClampedMonitorEdge(t *testing.T) {
|
||||||
prev := &overlay{
|
prev := &overlay{
|
||||||
interior: dirtyRect{100, 100, 1920, 1080},
|
interior: dirtyRect{100, 100, 1920, 1080},
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package screenshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestSurface(id uint32) *client.Surface {
|
||||||
|
s := &client.Surface{}
|
||||||
|
s.SetID(id)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTouchInputSequence(t *testing.T) {
|
||||||
|
surface := newTestSurface(100)
|
||||||
|
|
||||||
|
os := &OutputSurface{
|
||||||
|
output: &WaylandOutput{
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
logicalW: 1920,
|
||||||
|
logicalH: 1080,
|
||||||
|
wlSurface: surface,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &RegionSelector{
|
||||||
|
running: true,
|
||||||
|
surfaces: []*OutputSurface{os},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Touch Down
|
||||||
|
r.handleTouchDown(surface, 1, 100, 200)
|
||||||
|
|
||||||
|
if !r.hasTouchPoint || r.touchPointId != 1 {
|
||||||
|
t.Fatalf("expected hasTouchPoint=true, touchPointId=1, got hasTouchPoint=%v, id=%d", r.hasTouchPoint, r.touchPointId)
|
||||||
|
}
|
||||||
|
if !r.selection.dragging || !r.selection.hasSelection {
|
||||||
|
t.Fatalf("expected dragging=true, hasSelection=true")
|
||||||
|
}
|
||||||
|
if r.selection.anchorX != 100 || r.selection.anchorY != 200 {
|
||||||
|
t.Fatalf("expected anchor (100, 200), got (%.1f, %.1f)", r.selection.anchorX, r.selection.anchorY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ignore secondary touch point while primary is active
|
||||||
|
surface2 := newTestSurface(200)
|
||||||
|
r.handleTouchDown(surface2, 2, 500, 600)
|
||||||
|
if r.touchPointId != 1 {
|
||||||
|
t.Fatalf("expected touchPointId to stay 1, got %d", r.touchPointId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Touch Motion with matching id
|
||||||
|
r.handleTouchMotion(1, 400, 500)
|
||||||
|
if r.selection.currentX != 400 || r.selection.currentY != 500 {
|
||||||
|
t.Fatalf("expected current (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Touch Motion with mismatched id should be ignored
|
||||||
|
r.handleTouchMotion(2, 900, 900)
|
||||||
|
if r.selection.currentX != 400 || r.selection.currentY != 500 {
|
||||||
|
t.Fatalf("expected current to remain (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Touch Up with mismatched id should not release
|
||||||
|
r.handleTouchUp(2)
|
||||||
|
if !r.hasTouchPoint || !r.selection.dragging {
|
||||||
|
t.Fatalf("expected touch point to remain active on mismatched touch up")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Touch Up with matching id
|
||||||
|
r.handleTouchUp(1)
|
||||||
|
if r.hasTouchPoint || r.selection.dragging {
|
||||||
|
t.Fatalf("expected hasTouchPoint=false, dragging=false after touch up")
|
||||||
|
}
|
||||||
|
if !r.selection.hasSelection {
|
||||||
|
t.Fatalf("expected selection to be preserved after touch up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTouchCancel(t *testing.T) {
|
||||||
|
surface := newTestSurface(100)
|
||||||
|
|
||||||
|
os := &OutputSurface{
|
||||||
|
output: &WaylandOutput{
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
logicalW: 1920,
|
||||||
|
logicalH: 1080,
|
||||||
|
wlSurface: surface,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &RegionSelector{
|
||||||
|
running: true,
|
||||||
|
surfaces: []*OutputSurface{os},
|
||||||
|
}
|
||||||
|
|
||||||
|
r.handleTouchDown(surface, 5, 50, 60)
|
||||||
|
if !r.hasTouchPoint || !r.selection.dragging {
|
||||||
|
t.Fatalf("expected active touch point")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.handleTouchCancel()
|
||||||
|
if r.hasTouchPoint || r.selection.dragging {
|
||||||
|
t.Fatalf("expected touch point and dragging to be cleared on cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTouchUpScrollPhase(t *testing.T) {
|
||||||
|
surface := newTestSurface(100)
|
||||||
|
|
||||||
|
os := &OutputSurface{
|
||||||
|
output: &WaylandOutput{
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
logicalW: 1920,
|
||||||
|
logicalH: 1080,
|
||||||
|
wlSurface: surface,
|
||||||
|
}
|
||||||
|
|
||||||
|
sc := &Screenshoter{
|
||||||
|
config: Config{NoConfirm: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &RegionSelector{
|
||||||
|
screenshoter: sc,
|
||||||
|
phase: phaseScroll,
|
||||||
|
running: true,
|
||||||
|
surfaces: []*OutputSurface{os},
|
||||||
|
selection: SelectionState{
|
||||||
|
hasSelection: true,
|
||||||
|
surface: os,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
r.hasTouchPoint = true
|
||||||
|
r.touchPointId = 3
|
||||||
|
r.selection.dragging = true
|
||||||
|
|
||||||
|
r.handleTouchUp(3)
|
||||||
|
|
||||||
|
if r.hasTouchPoint || r.selection.dragging {
|
||||||
|
t.Fatalf("expected touch point and dragging cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// In scroll phase with NoConfirm, handleTouchUp must not call finishSelection / change running state
|
||||||
|
if !r.running {
|
||||||
|
t.Fatalf("expected running to stay true, did not expect finishSelection side-effects")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_color_management"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_color_management"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||||
)
|
)
|
||||||
@@ -177,6 +178,14 @@ func (s *Screenshoter) captureLastRegion() (*CaptureResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
|
func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
|
||||||
|
if !s.config.AllowMultiple {
|
||||||
|
lock, err := utils.LockSelectionOverlay()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer lock.Release()
|
||||||
|
}
|
||||||
|
|
||||||
if s.config.Reset {
|
if s.config.Reset {
|
||||||
if err := SaveLastRegion(Region{}); err != nil {
|
if err := SaveLastRegion(Region{}); err != nil {
|
||||||
log.Debug("failed to reset last region", "err", err)
|
log.Debug("failed to reset last region", "err", err)
|
||||||
@@ -211,6 +220,9 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
|
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
|
||||||
|
if DetectCompositor() == CompositorAqueous {
|
||||||
|
return s.captureAqueousWindow()
|
||||||
|
}
|
||||||
if DetectCompositor() == CompositorNiri {
|
if DetectCompositor() == CompositorNiri {
|
||||||
if s.config.Geometry {
|
if s.config.Geometry {
|
||||||
return nil, fmt.Errorf("window geometry mode is not supported on niri")
|
return nil, fmt.Errorf("window geometry mode is not supported on niri")
|
||||||
@@ -353,6 +365,13 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Screenshoter) captureFullScreen() (*CaptureResult, error) {
|
func (s *Screenshoter) captureFullScreen() (*CaptureResult, error) {
|
||||||
|
if DetectCompositor() == CompositorAqueous {
|
||||||
|
name, err := aqueousFocusedOutput(s.config.Seat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.captureOutput(name)
|
||||||
|
}
|
||||||
output := s.findFocusedOutput()
|
output := s.findFocusedOutput()
|
||||||
if output == nil {
|
if output == nil {
|
||||||
s.outputsMu.Lock()
|
s.outputsMu.Lock()
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
aqueous-snapshot.json contains focused-window state captured from Aqueous 2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb in an isolated session on 2026-09-05. The session token is sanitized.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"schema": 1, "session": "0123456789abcdef0123456789abcdef", "sequence": "5", "base_sequence": null, "type": "snapshot", "upsert": [{"kind": "workspace", "id": "12", "output": "2", "name": "same \"name\"", "number": 3, "active": true, "urgent": false}, {"kind": "workspace", "id": "2", "output": "1", "name": "2", "number": 2, "active": false, "urgent": false}, {"kind": "session", "id": "session", "locked": false, "default_seat": "default", "overview_output": null, "overview_window": null}, {"kind": "workspace", "id": "11", "output": "2", "name": "2", "number": 2, "active": false, "urgent": false}, {"kind": "output", "id": "1", "name": "HEADLESS-2", "enabled": true, "powered": true, "bounds": {"x": 0, "y": 0, "width": 1280, "height": 720}, "usable_bounds": {"x": 0, "y": 0, "width": 1280, "height": 720}, "scale": 1, "transform": "normal", "active_workspace": "1"}, {"kind": "workspace", "id": "18", "output": "2", "name": "9", "number": 9, "active": false, "urgent": false}, {"kind": "workspace", "id": "16", "output": "2", "name": "7", "number": 7, "active": false, "urgent": false}, {"kind": "workspace", "id": "15", "output": "2", "name": "6", "number": 6, "active": false, "urgent": false}, {"kind": "workspace", "id": "10", "output": "2", "name": "1", "number": 1, "active": false, "urgent": false}, {"kind": "workspace", "id": "5", "output": "1", "name": "5", "number": 5, "active": false, "urgent": false}, {"kind": "workspace", "id": "9", "output": "1", "name": "9", "number": 9, "active": false, "urgent": false}, {"kind": "workspace", "id": "4", "output": "1", "name": "4", "number": 4, "active": false, "urgent": false}, {"kind": "seat", "id": "default", "output": "1", "window": "ca89b2eb6850b81d12b92763969d82ca", "focus_kind": "window", "keyboard": "1"}, {"kind": "workspace", "id": "3", "output": "1", "name": "3", "number": 3, "active": false, "urgent": false}, {"kind": "workspace", "id": "14", "output": "2", "name": "5", "number": 5, "active": false, "urgent": false}, {"kind": "workspace", "id": "7", "output": "1", "name": "7", "number": 7, "active": false, "urgent": false}, {"kind": "workspace", "id": "1", "output": "1", "name": "1", "number": 1, "active": true, "urgent": false}, {"kind": "workspace", "id": "6", "output": "1", "name": "6", "number": 6, "active": false, "urgent": false}, {"kind": "workspace", "id": "8", "output": "1", "name": "8", "number": 8, "active": false, "urgent": false}, {"kind": "workspace", "id": "13", "output": "2", "name": "4", "number": 4, "active": false, "urgent": false}, {"kind": "workspace", "id": "17", "output": "2", "name": "8", "number": 8, "active": false, "urgent": false}, {"kind": "output", "id": "2", "name": "HEADLESS-1", "enabled": true, "powered": true, "bounds": {"x": 1280, "y": 0, "width": 1280, "height": 720}, "usable_bounds": {"x": 1280, "y": 0, "width": 1280, "height": 720}, "scale": 1, "transform": "normal", "active_workspace": "12"}, {"kind": "window", "id": "ca89b2eb6850b81d12b92763969d82ca", "backend": "xdg", "app_id": "aq-shell-test", "class": null, "title": "same title", "workspace": "1", "output": "1", "geometry": {"x": 10, "y": 10, "width": 628, "height": 700}, "outer_geometry": {"x": 8, "y": 8, "width": 632, "height": 704}, "focused": true, "visible": true, "floating": false, "minimized": false, "maximized": false, "fullscreen": false, "skip_taskbar": false, "skip_switcher": false, "always_above": false, "always_below": false, "snapped": false, "fixed_position": false, "layout": "tiled", "can_minimize": false, "can_maximize": false, "can_activate": true}, {"kind": "keyboard", "id": "1", "seat": "default", "layouts": ["English (US)", "German"], "index": 0}, {"kind": "keyboard_device", "id": "1", "name": "wlr_virtual_keyboard_v1", "seat": "default", "group": "1", "virtual": true}], "removed": []}
|
||||||
@@ -56,21 +56,24 @@ type Output struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Mode Mode
|
Seat string
|
||||||
OutputName string
|
Mode Mode
|
||||||
Cursor CursorMode
|
OutputName string
|
||||||
NoConfirm bool
|
Cursor CursorMode
|
||||||
Reset bool
|
NoConfirm bool
|
||||||
Format Format
|
Reset bool
|
||||||
Quality int
|
Format Format
|
||||||
OutputDir string
|
Quality int
|
||||||
Filename string
|
OutputDir string
|
||||||
Clipboard bool
|
Filename string
|
||||||
SaveFile bool
|
Clipboard bool
|
||||||
Notify bool
|
SaveFile bool
|
||||||
Stdout bool
|
Notify bool
|
||||||
Geometry bool
|
Stdout bool
|
||||||
IntervalMs int
|
Geometry bool
|
||||||
|
AllowMultiple bool
|
||||||
|
IntervalMs int
|
||||||
|
HUD string
|
||||||
// SelectorHook runs as the interactive selector starts (true) and ends (false).
|
// SelectorHook runs as the interactive selector starts (true) and ends (false).
|
||||||
SelectorHook func(begin bool)
|
SelectorHook func(begin bool)
|
||||||
}
|
}
|
||||||
@@ -88,5 +91,6 @@ func DefaultConfig() Config {
|
|||||||
Clipboard: true,
|
Clipboard: true,
|
||||||
SaveFile: true,
|
SaveFile: true,
|
||||||
Notify: true,
|
Notify: true,
|
||||||
|
HUD: "auto",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,15 +160,20 @@ func (b *DDCBackend) getDDCName(bus int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
|
func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
|
||||||
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
if err != nil {
|
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
|
||||||
|
if err == nil {
|
||||||
|
dev.max = cap.max
|
||||||
|
dev.lastBrightness = cap.current
|
||||||
|
log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if attempt < 2 {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
log.Debugf("failed to read initial brightness for %s: %v", dev.name, err)
|
log.Debugf("failed to read initial brightness for %s: %v", dev.name, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dev.max = cap.max
|
|
||||||
dev.lastBrightness = cap.current
|
|
||||||
log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *DDCBackend) GetDevices() ([]Device, error) {
|
func (b *DDCBackend) GetDevices() ([]Device, error) {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ func HandleRequest(conn *models.Conn, req models.Request, m *Manager) {
|
|||||||
handlePinEntry(conn, req, m)
|
handlePinEntry(conn, req, m)
|
||||||
case "clipboard.unpinEntry":
|
case "clipboard.unpinEntry":
|
||||||
handleUnpinEntry(conn, req, m)
|
handleUnpinEntry(conn, req, m)
|
||||||
|
case "clipboard.editEntry":
|
||||||
|
handleEditEntry(conn, req, m)
|
||||||
case "clipboard.getPinnedEntries":
|
case "clipboard.getPinnedEntries":
|
||||||
handleGetPinnedEntries(conn, req, m)
|
handleGetPinnedEntries(conn, req, m)
|
||||||
case "clipboard.getPinnedCount":
|
case "clipboard.getPinnedCount":
|
||||||
@@ -422,3 +424,24 @@ func handleCopyFile(conn *models.Conn, req models.Request, m *Manager) {
|
|||||||
|
|
||||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"})
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleEditEntry(conn *models.Conn, req models.Request, m *Manager) {
|
||||||
|
id, err := params.Int(req.Params, "id")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
text, err := params.String(req.Params, "text")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.EditEntry(uint64(id), text); err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry updated"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -795,6 +795,13 @@ func selectAltTextMimeType(mimes []string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTextMimeType(mime string) bool {
|
||||||
|
if mime == "" || strings.HasPrefix(mime, "text/plain") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return slices.Contains(altTextMimeTypes, mime)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) isImageMimeType(mime string) bool {
|
func (m *Manager) isImageMimeType(mime string) bool {
|
||||||
return strings.HasPrefix(mime, "image/")
|
return strings.HasPrefix(mime, "image/")
|
||||||
}
|
}
|
||||||
@@ -1963,6 +1970,113 @@ func (m *Manager) UnpinEntry(id uint64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) EditEntry(id uint64, text string) error {
|
||||||
|
if m.db == nil {
|
||||||
|
return fmt.Errorf("database not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []byte(text)
|
||||||
|
mimeType := "text/plain;charset=utf-8"
|
||||||
|
|
||||||
|
if len(bytes.TrimSpace(data)) == 0 {
|
||||||
|
return fmt.Errorf("cannot save empty entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := m.getConfig()
|
||||||
|
if int64(len(data)) > cfg.MaxEntrySize {
|
||||||
|
return fmt.Errorf("data too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash := computeHash(data)
|
||||||
|
preview := m.textPreview(data)
|
||||||
|
|
||||||
|
err := m.dbUpdate(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte("clipboard"))
|
||||||
|
if b == nil {
|
||||||
|
return fmt.Errorf("clipboard bucket missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldKey := itob(id)
|
||||||
|
v := b.Get(oldKey)
|
||||||
|
if v == nil {
|
||||||
|
return errEntryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := decodeEntry(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing.IsImage {
|
||||||
|
return errors.New("cannot edit image entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isTextMimeType(existing.MimeType) {
|
||||||
|
return errors.New("cannot edit non-text entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
wasPinned := existing.Pinned
|
||||||
|
|
||||||
|
if err := b.Delete(oldKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.deduplicateInTx(b, newHash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if wasPinned {
|
||||||
|
c := b.Cursor()
|
||||||
|
for k, val := c.First(); k != nil; k, val = c.Next() {
|
||||||
|
if extractHash(val) == newHash {
|
||||||
|
meta, err := decodeEntryMeta(val)
|
||||||
|
if err == nil && meta.Pinned {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newID, err := b.NextSequence()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newEntry := Entry{
|
||||||
|
ID: newID,
|
||||||
|
Data: data,
|
||||||
|
MimeType: mimeType,
|
||||||
|
Preview: preview,
|
||||||
|
Size: len(data),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
IsImage: false,
|
||||||
|
Hash: newHash,
|
||||||
|
Pinned: wasPinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeEntry(newEntry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.Put(itob(newID), encoded); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.trimLengthInTx(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
if clipErr := m.SetClipboard(data, mimeType); clipErr != nil {
|
||||||
|
log.Errorf("Failed to set clipboard selection: %v", clipErr)
|
||||||
|
}
|
||||||
|
m.updateState()
|
||||||
|
m.notifySubscribers()
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) GetPinnedEntries() []Entry {
|
func (m *Manager) GetPinnedEntries() []Entry {
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -45,9 +45,15 @@ func newTestManagerWithDB(t *testing.T) *Manager {
|
|||||||
db.Close()
|
db.Close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
||||||
|
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
|
||||||
|
fn()
|
||||||
|
}).Maybe()
|
||||||
|
|
||||||
return &Manager{
|
return &Manager{
|
||||||
config: DefaultConfig(),
|
config: DefaultConfig(),
|
||||||
db: db,
|
db: db,
|
||||||
|
wlCtx: mockCtx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +584,175 @@ func TestCreateHistoryEntryFromPinned_KeepsLatestUnpinnedDuplicate(t *testing.T)
|
|||||||
assert.NotEqual(t, firstDuplicate.ID, latestDuplicate.ID)
|
assert.NotEqual(t, firstDuplicate.ID, latestDuplicate.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEditEntry_UnpinnedEntry(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
|
||||||
|
id := storeTestEntry(t, m, "original unpinned")
|
||||||
|
require.NoError(t, m.EditEntry(id, "edited unpinned"))
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
assert.Equal(t, "edited unpinned", history[0].Preview)
|
||||||
|
assert.False(t, history[0].Pinned)
|
||||||
|
assert.NotEqual(t, id, history[0].ID)
|
||||||
|
|
||||||
|
// Old entry should not exist
|
||||||
|
oldEntry, err := m.GetEntry(id)
|
||||||
|
assert.ErrorIs(t, err, errEntryNotFound)
|
||||||
|
assert.Nil(t, oldEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditEntry_PinnedEntryRemainsPinned(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
|
||||||
|
id := storeTestEntry(t, m, "original pinned")
|
||||||
|
require.NoError(t, m.PinEntry(id))
|
||||||
|
assert.Equal(t, 1, m.GetPinnedCount())
|
||||||
|
|
||||||
|
require.NoError(t, m.EditEntry(id, "edited pinned"))
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
assert.Equal(t, "edited pinned", history[0].Preview)
|
||||||
|
assert.True(t, history[0].Pinned)
|
||||||
|
|
||||||
|
pinnedEntries := m.GetPinnedEntries()
|
||||||
|
require.Len(t, pinnedEntries, 1)
|
||||||
|
assert.Equal(t, "edited pinned", pinnedEntries[0].Preview)
|
||||||
|
assert.Equal(t, 1, m.GetPinnedCount())
|
||||||
|
|
||||||
|
// Old pinned entry should be deleted
|
||||||
|
oldEntry, err := m.GetEntry(id)
|
||||||
|
assert.ErrorIs(t, err, errEntryNotFound)
|
||||||
|
assert.Nil(t, oldEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditEntry_NotFound(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
|
||||||
|
err := m.EditEntry(99999, "new text")
|
||||||
|
assert.ErrorIs(t, err, errEntryNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditEntry_ImageReturnsError(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
|
||||||
|
imgEntry := Entry{
|
||||||
|
Data: []byte{0x89, 0x50, 0x4E, 0x47},
|
||||||
|
MimeType: "image/png",
|
||||||
|
Preview: "[[ image ]]",
|
||||||
|
Size: 4,
|
||||||
|
Timestamp: time.Now().Truncate(time.Second),
|
||||||
|
IsImage: true,
|
||||||
|
}
|
||||||
|
require.NoError(t, m.storeEntry(imgEntry))
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
id := history[0].ID
|
||||||
|
|
||||||
|
err := m.EditEntry(id, "replacement text")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "cannot edit image entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditEntry_NonTextReturnsError(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
|
||||||
|
uriEntry := Entry{
|
||||||
|
Data: []byte("file:///path/to/file\n"),
|
||||||
|
MimeType: "text/uri-list",
|
||||||
|
Preview: "file:///path/to/file",
|
||||||
|
Size: 21,
|
||||||
|
Timestamp: time.Now().Truncate(time.Second),
|
||||||
|
IsImage: false,
|
||||||
|
}
|
||||||
|
require.NoError(t, m.storeEntry(uriEntry))
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
id := history[0].ID
|
||||||
|
|
||||||
|
err := m.EditEntry(id, "replacement text")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "cannot edit non-text entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditEntry_AltTextMimeTypesAllowed(t *testing.T) {
|
||||||
|
for _, mime := range []string{"UTF8_STRING", "STRING", "TEXT", "text/plain;charset=utf-8", "text/plain"} {
|
||||||
|
t.Run(mime, func(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
entry := Entry{
|
||||||
|
Data: []byte("old text"),
|
||||||
|
MimeType: mime,
|
||||||
|
Preview: "old text",
|
||||||
|
Size: 8,
|
||||||
|
Timestamp: time.Now().Truncate(time.Second),
|
||||||
|
IsImage: false,
|
||||||
|
}
|
||||||
|
require.NoError(t, m.storeEntry(entry))
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
id := history[0].ID
|
||||||
|
|
||||||
|
err := m.EditEntry(id, "replacement text")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditEntry_EmptyOrWhitespaceReturnsError(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
id := storeTestEntry(t, m, "keep me")
|
||||||
|
|
||||||
|
for _, badText := range []string{"", " ", "\t\n\r"} {
|
||||||
|
err := m.EditEntry(id, badText)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "cannot save empty entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
assert.Equal(t, "keep me", history[0].Preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditEntry_DataTooLargeReturnsError(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
m.config.MaxEntrySize = 10
|
||||||
|
id := storeTestEntry(t, m, "small")
|
||||||
|
|
||||||
|
err := m.EditEntry(id, "this text exceeds the 10-byte limit")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "data too large")
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
assert.Equal(t, "small", history[0].Preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEditEntry_SuccessAndValidation(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
id := storeTestEntry(t, m, "before edit")
|
||||||
|
|
||||||
|
mc := newClipboardTestConn()
|
||||||
|
conn := models.NewConn(mc)
|
||||||
|
handleEditEntry(conn, models.Request{
|
||||||
|
ID: 1,
|
||||||
|
Params: map[string]any{
|
||||||
|
"id": float64(id),
|
||||||
|
"text": "after edit",
|
||||||
|
},
|
||||||
|
}, m)
|
||||||
|
|
||||||
|
var resp models.Response[models.SuccessResult]
|
||||||
|
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
|
||||||
|
assert.Empty(t, resp.Error)
|
||||||
|
require.NotNil(t, resp.Result)
|
||||||
|
assert.True(t, resp.Result.Success)
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
assert.Equal(t, "after edit", history[0].Preview)
|
||||||
|
}
|
||||||
|
|
||||||
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
subscribers: make(map[string]chan State),
|
subscribers: make(map[string]chan State),
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func findKeyboards() ([]EvdevDevice, error) {
|
|||||||
|
|
||||||
var keyboards []EvdevDevice
|
var keyboards []EvdevDevice
|
||||||
for _, path := range matches {
|
for _, path := range matches {
|
||||||
device, err := evdev.Open(path)
|
device, err := evdev.OpenWithFlags(path, os.O_RDONLY)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ func (m *Manager) watchForNewKeyboards() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
device, err := evdev.Open(event.Name)
|
device, err := evdev.OpenWithFlags(event.Name, os.O_RDONLY)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.devicesMutex.Unlock()
|
m.devicesMutex.Unlock()
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
@@ -21,15 +22,13 @@ const (
|
|||||||
|
|
||||||
var _ HotspotBackend = (*NetworkManagerBackend)(nil)
|
var _ HotspotBackend = (*NetworkManagerBackend)(nil)
|
||||||
|
|
||||||
// ConfigureHotspot validates only DMS-owned constraints: SSID presence, band
|
|
||||||
// names, device capability, and the not-active guard. SSID byte-length and
|
|
||||||
// WPA-PSK password policy are deliberately left to NetworkManager, whose
|
|
||||||
// AddConnection/Update errors are returned to the caller; duplicating its
|
|
||||||
// evolving rules here would only let them drift.
|
|
||||||
func (b *NetworkManagerBackend) ConfigureHotspot(req HotspotRequest) error {
|
func (b *NetworkManagerBackend) ConfigureHotspot(req HotspotRequest) error {
|
||||||
if req.SSID == "" {
|
if req.SSID == "" {
|
||||||
return fmt.Errorf("hotspot SSID cannot be empty")
|
return fmt.Errorf("hotspot SSID cannot be empty")
|
||||||
}
|
}
|
||||||
|
if err := validateHotspotPassword(req.Password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := validateHotspotBandName(req.Band); err != nil {
|
if err := validateHotspotBandName(req.Band); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -495,6 +494,22 @@ func isAPCapableWiFiDevice(devInfo *wifiDeviceInfo) (bool, error) {
|
|||||||
return caps&nmWiFiDeviceCapAP != 0, nil
|
return caps&nmWiFiDeviceCapAP != 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors NetworkManager's nm_utils_wpa_psk_valid: byte length, not rune count.
|
||||||
|
func validateHotspotPassword(password string) error {
|
||||||
|
if password == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(password) == 64 {
|
||||||
|
if _, err := hex.DecodeString(password); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(password) < 8 || len(password) > 63 {
|
||||||
|
return fmt.Errorf("hotspot password must be 8 to 63 characters, or a 64-digit hex key")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateHotspotBandName(band string) error {
|
func validateHotspotBandName(band string) error {
|
||||||
if band != "" && band != "bg" && band != "a" {
|
if band != "" && band != "bg" && band != "a" {
|
||||||
return fmt.Errorf("unsupported hotspot band: %s", band)
|
return fmt.Errorf("unsupported hotspot band: %s", band)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
|
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
|
||||||
@@ -916,3 +917,48 @@ func TestGetHotspotSecrets(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateHotspotPassword(t *testing.T) {
|
||||||
|
hex64 := strings.Repeat("0123456789abcdef", 4)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
password string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"open network", "", true},
|
||||||
|
{"three digits", "123", false},
|
||||||
|
{"seven chars", "testing", false},
|
||||||
|
{"eight chars", "testing1", true},
|
||||||
|
{"sixty three chars", strings.Repeat("a", 63), true},
|
||||||
|
{"sixty four hex", hex64, true},
|
||||||
|
{"sixty four hex uppercase", strings.ToUpper(hex64), true},
|
||||||
|
{"sixty four non-hex", strings.Repeat("g", 64), false},
|
||||||
|
{"sixty five chars", strings.Repeat("a", 65), false},
|
||||||
|
{"multibyte counts bytes", "ääää", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateHotspotPassword(tc.password)
|
||||||
|
if tc.valid {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "8 to 63 characters")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureHotspotRejectsShortPasswordBeforeTouchingNetworkManager(t *testing.T) {
|
||||||
|
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
|
||||||
|
mockSettings := mock_gonetworkmanager.NewMockSettings(t)
|
||||||
|
|
||||||
|
backend, err := NewNetworkManagerBackend(mockNM)
|
||||||
|
require.NoError(t, err)
|
||||||
|
backend.settings = mockSettings
|
||||||
|
|
||||||
|
err = backend.ConfigureHotspot(HotspotRequest{SSID: "DMS Hotspot", Password: "123", Device: "wlan0"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "8 to 63 characters")
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ func (b *NetworkManagerBackend) classifyNMStateReason(reason uint32) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NM raises NO_SECRETS once auth retries run out on a saved profile too, not only
|
||||||
|
// on a dismissed prompt (nm-device-wifi.c handle_8021x_or_psk_auth_fail, #3313).
|
||||||
|
func forgetOnConnectFailure(reasonCode string, preExisting bool) bool {
|
||||||
|
return reasonCode == errdefs.ErrUserCanceled && !preExisting
|
||||||
|
}
|
||||||
|
|
||||||
// With several adapters, state must follow the associated device, not whichever
|
// With several adapters, state must follow the associated device, not whichever
|
||||||
// enumerated first (#2460).
|
// enumerated first (#2460).
|
||||||
func (b *NetworkManagerBackend) wifiDeviceForState() (gonetworkmanager.Device, gonetworkmanager.DeviceWireless) {
|
func (b *NetworkManagerBackend) wifiDeviceForState() (gonetworkmanager.Device, gonetworkmanager.DeviceWireless) {
|
||||||
@@ -321,7 +327,7 @@ func (b *NetworkManagerBackend) updateWiFiState() error {
|
|||||||
b.state.ConnectingSSID = ""
|
b.state.ConnectingSSID = ""
|
||||||
b.state.LastError = reasonCode
|
b.state.LastError = reasonCode
|
||||||
|
|
||||||
if reasonCode == errdefs.ErrUserCanceled {
|
if forgetOnConnectFailure(reasonCode, b.state.ConnectingPreExisting) {
|
||||||
forgetSSID = connectingSSID
|
forgetSSID = connectingSSID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,3 +146,23 @@ func TestNetworkManagerBackend_UpdateWiFiState_PicksConnectedDevice(t *testing.T
|
|||||||
assert.True(t, backend.state.WiFiConnected)
|
assert.True(t, backend.state.WiFiConnected)
|
||||||
assert.Equal(t, "wlan1", backend.state.WiFiDevice)
|
assert.Equal(t, "wlan1", backend.state.WiFiDevice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestForgetOnConnectFailure(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reasonCode string
|
||||||
|
preExisting bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"cancelled prompt on freshly created profile", errdefs.ErrUserCanceled, false, true},
|
||||||
|
{"no-secrets failure on saved profile", errdefs.ErrUserCanceled, true, false},
|
||||||
|
{"bad credentials on freshly created profile", errdefs.ErrBadCredentials, false, false},
|
||||||
|
{"generic failure on saved profile", errdefs.ErrConnectionFailed, true, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, forgetOnConnectFailure(tt.reasonCode, tt.preExisting))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,15 +28,8 @@ type wpaCtrlConn struct {
|
|||||||
seq int
|
seq int
|
||||||
}
|
}
|
||||||
|
|
||||||
func wpaCtrlLocalSocketBase() string {
|
|
||||||
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
return os.TempDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
func newWpaCtrlConn(sockPath string) (*wpaCtrlConn, error) {
|
func newWpaCtrlConn(sockPath string) (*wpaCtrlConn, error) {
|
||||||
localDir, err := os.MkdirTemp(wpaCtrlLocalSocketBase(), "dms-wpa-")
|
localDir, err := os.MkdirTemp(utils.RuntimeDir(), "dms-wpa-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create wpa_ctrl socket dir: %w", err)
|
return nil, fmt.Errorf("create wpa_ctrl socket dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
"github.com/AvengeMedia/dankgo/ipc"
|
"github.com/AvengeMedia/dankgo/ipc"
|
||||||
"github.com/AvengeMedia/dankgo/paths"
|
|
||||||
"github.com/AvengeMedia/dankgo/syncmap"
|
"github.com/AvengeMedia/dankgo/syncmap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ var cupsMu sync.Mutex
|
|||||||
var cupsSubscriberCount int
|
var cupsSubscriberCount int
|
||||||
var cupsEverAvailable bool
|
var cupsEverAvailable bool
|
||||||
|
|
||||||
var appPaths = paths.New("danklinux")
|
var appPaths = utils.App()
|
||||||
|
|
||||||
func GetSocketPath() string {
|
func GetSocketPath() string {
|
||||||
return appPaths.SocketPath()
|
return appPaths.SocketPath()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
func init() {
|
func init() {
|
||||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
|
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
|
||||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
|
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
|
||||||
|
RegisterSystemBackend(func() Backend { return &shellyBackend{} })
|
||||||
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
|
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +54,7 @@ func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
|||||||
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
|
// Arch repository packages must upgrade together; AUR packages may be held.
|
||||||
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
|
|
||||||
// AUR packages are exempt — holding those cannot break the repo dependency graph.
|
|
||||||
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
|
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
|
||||||
if len(ignored) == 0 {
|
if len(ignored) == 0 {
|
||||||
return ignored
|
return ignored
|
||||||
@@ -81,7 +80,7 @@ func isPacmanFamily(b Backend) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch b.ID() {
|
switch b.ID() {
|
||||||
case "pacman", "paru", "yay":
|
case "pacman", "paru", "yay", "shelly":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type shellyBackend struct{}
|
||||||
|
|
||||||
|
func (shellyBackend) ID() string { return "shelly" }
|
||||||
|
func (shellyBackend) DisplayName() string { return "Shelly (AUR)" }
|
||||||
|
func (shellyBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (shellyBackend) NeedsAuth() bool { return true }
|
||||||
|
func (shellyBackend) RunsInTerminal() bool {
|
||||||
|
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shellyBackend) IsAvailable(ctx context.Context) bool {
|
||||||
|
if !commandExists("shelly") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := Capture(ctx, []string{"shelly", "--version", "--json"})
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var version struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
|
}
|
||||||
|
// Shelly 3.1.3 introduced this CLI identity response.
|
||||||
|
return json.Unmarshal([]byte(out), &version) == nil && version.Name == "shelly" && version.SchemaVersion == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b shellyBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
pkgs, err := b.checkUpdates(ctx, RepoSystem)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aur, err := b.checkUpdates(ctx, RepoAUR)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(pkgs, aur...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b shellyBackend) checkUpdates(ctx context.Context, repo RepoKind) ([]Package, error) {
|
||||||
|
kind := "standard"
|
||||||
|
if repo == RepoAUR {
|
||||||
|
kind = "aur"
|
||||||
|
}
|
||||||
|
out, err := Capture(ctx, []string{"shelly", "list-updates", kind, "--json"})
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
|
return nil, fmt.Errorf("shelly %s update check: %w: %s", kind, err, strings.TrimSpace(string(exitErr.Stderr)))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("shelly %s update check: %w", kind, err)
|
||||||
|
}
|
||||||
|
return parseShellyUpdates(out, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseShellyUpdates(out string, repo RepoKind) ([]Package, error) {
|
||||||
|
var updates []struct {
|
||||||
|
Name string
|
||||||
|
CurrentVersion string
|
||||||
|
Version string
|
||||||
|
NewVersion string
|
||||||
|
DownloadSize int64
|
||||||
|
PackageBase string
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(out), &updates); err != nil {
|
||||||
|
return nil, fmt.Errorf("shelly %s updates: invalid JSON: %w", repo, err)
|
||||||
|
}
|
||||||
|
if updates == nil {
|
||||||
|
return nil, fmt.Errorf("shelly %s updates: expected a JSON array", repo)
|
||||||
|
}
|
||||||
|
var pkgs []Package
|
||||||
|
for _, update := range updates {
|
||||||
|
fromVersion := update.CurrentVersion
|
||||||
|
if repo == RepoAUR {
|
||||||
|
fromVersion = update.Version
|
||||||
|
}
|
||||||
|
if update.Name == "" || fromVersion == "" || update.NewVersion == "" {
|
||||||
|
return nil, fmt.Errorf("shelly %s updates: missing package name or version", repo)
|
||||||
|
}
|
||||||
|
pkg := Package{
|
||||||
|
Name: update.Name,
|
||||||
|
Backend: "shelly",
|
||||||
|
Repo: repo,
|
||||||
|
FromVersion: fromVersion,
|
||||||
|
ToVersion: update.NewVersion,
|
||||||
|
SizeBytes: max(0, update.DownloadSize),
|
||||||
|
}
|
||||||
|
if repo == RepoAUR {
|
||||||
|
base := update.PackageBase
|
||||||
|
if base == "" {
|
||||||
|
base = update.Name
|
||||||
|
}
|
||||||
|
pkg.ChangelogURL = "https://aur.archlinux.org/packages/" + url.PathEscape(base)
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, pkg)
|
||||||
|
}
|
||||||
|
return pkgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b shellyBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var aur []Package
|
||||||
|
if opts.IncludeAUR && (opts.DryRun || len(opts.Ignored) > 0) {
|
||||||
|
// The manager has already removed held packages from opts.Targets.
|
||||||
|
var err error
|
||||||
|
aur, err = b.checkUpdates(ctx, RepoAUR)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, pkg := range aur {
|
||||||
|
if slices.Contains(opts.Ignored, pkg.Name) {
|
||||||
|
return fmt.Errorf("shelly cannot exclude held AUR package %q; disable AUR updates or remove its DMS hold before updating", pkg.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.DryRun {
|
||||||
|
pkgs, err := b.checkUpdates(ctx, RepoSystem)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, aur...)
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
if onLine != nil {
|
||||||
|
onLine(fmt.Sprintf("%s %s -> %s", pkg.Name, pkg.FromVersion, pkg.ToVersion))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
argv := shellyUpgradeArgv(opts.IncludeAUR)
|
||||||
|
if opts.AttachStdio {
|
||||||
|
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: true})
|
||||||
|
}
|
||||||
|
if !b.RunsInTerminal() {
|
||||||
|
return Run(ctx, argv, RunOptions{OnLine: onLine, Env: []string{"SHELLY_ELEVATOR=pkexec"}})
|
||||||
|
}
|
||||||
|
term := findTerminal(opts.Terminal)
|
||||||
|
if term == "" {
|
||||||
|
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||||
|
}
|
||||||
|
return Run(ctx, wrapInTerminal(term, "DMS — System Update (shelly)", strings.Join(argv, " "), opts.TerminalArgs), RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellyUpgradeArgv(includeAUR bool) []string {
|
||||||
|
if !includeAUR {
|
||||||
|
return []string{"shelly", "upgrade", "standard", "--no-confirm"}
|
||||||
|
}
|
||||||
|
return []string{"shelly", "upgrade", "all", "--no-flatpak", "--no-appimage", "--no-confirm"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const shellyRepoFixture = `[{"Name":"linux","CurrentVersion":"2:6.18-1","NewVersion":"2:6.19-1","DownloadSize":1024,"Repository":"core"}]`
|
||||||
|
const shellyAURFixture = `[{"Name":"example-git","Version":"r1-1","NewVersion":"r2-1","DownloadSize":-1,"PackageBase":"example"}]`
|
||||||
|
|
||||||
|
func TestParseShellyUpdates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
out string
|
||||||
|
repo RepoKind
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{"repository", shellyRepoFixture, RepoSystem, []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem, FromVersion: "2:6.18-1", ToVersion: "2:6.19-1", SizeBytes: 1024}}},
|
||||||
|
{"aur package base", shellyAURFixture, RepoAUR, []Package{{Name: "example-git", Backend: "shelly", Repo: RepoAUR, FromVersion: "r1-1", ToVersion: "r2-1", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
|
||||||
|
{"aur name fallback", `[{"Name":"example","Version":"1","NewVersion":"2"}]`, RepoAUR, []Package{{Name: "example", Backend: "shelly", Repo: RepoAUR, FromVersion: "1", ToVersion: "2", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
|
||||||
|
{"empty", "[]\n", RepoSystem, nil},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseShellyUpdates(tt.out, tt.repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("packages = %#v, want %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, out := range []string{"", "null", "{}", "[", "[] trailing", "[null]", `[{"Name":"linux"}]`, `[{"Name":"linux","CurrentVersion":"1","NewVersion":"2","DownloadSize":"large"}]`} {
|
||||||
|
t.Run(out, func(t *testing.T) {
|
||||||
|
if _, err := parseShellyUpdates(out, RepoSystem); err == nil {
|
||||||
|
t.Fatalf("expected error for %q", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUpdateExecutable(t *testing.T, dir, name, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellySelection(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
bins []string
|
||||||
|
version string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"shelly only", []string{"shelly"}, `{"schemaVersion":1,"name":"shelly","version":"3.1.3"}`, "shelly"},
|
||||||
|
{"shelly before pacman", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly"}`, "shelly"},
|
||||||
|
{"paru preferred", []string{"paru", "yay", "shelly", "pacman"}, "", "paru"},
|
||||||
|
{"yay preferred", []string{"yay", "shelly", "pacman"}, "", "yay"},
|
||||||
|
{"legacy shelly", []string{"shelly", "pacman"}, "2.4.1", "pacman"},
|
||||||
|
{"wrong binary", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly-ui"}`, "pacman"},
|
||||||
|
{"unknown schema", []string{"shelly", "pacman"}, `{"schemaVersion":2,"name":"shelly"}`, "pacman"},
|
||||||
|
{"pacman only", []string{"pacman"}, "", "pacman"},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
for _, bin := range tt.bins {
|
||||||
|
writeUpdateExecutable(t, dir, bin, "printf '%s\\n' '"+tt.version+"'")
|
||||||
|
}
|
||||||
|
selection := Select(t.Context())
|
||||||
|
if selection.System == nil || selection.System.ID() != tt.want {
|
||||||
|
t.Fatalf("selection = %#v, want %s", selection.System, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeShelly(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
log := filepath.Join(dir, "calls")
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
t.Setenv("TERMINAL", "")
|
||||||
|
t.Setenv("DMS_FORCE_PKEXEC", "")
|
||||||
|
t.Setenv("DMS_TEST_SHELLY_LOG", log)
|
||||||
|
t.Setenv("DMS_TEST_SHELLY_EXIT", "0")
|
||||||
|
t.Setenv("SHELLY_ELEVATOR", "")
|
||||||
|
writeUpdateExecutable(t, dir, "shelly", `printf '%s|%s\n' "$*" "$SHELLY_ELEVATOR" >> "$DMS_TEST_SHELLY_LOG"
|
||||||
|
case "$*" in
|
||||||
|
'list-updates standard --json') printf '%s\n' '`+shellyRepoFixture+`' ;;
|
||||||
|
'list-updates aur --json') printf '%s\n' '`+shellyAURFixture+`' ;;
|
||||||
|
'upgrade standard --no-confirm'|'upgrade all --no-flatpak --no-appimage --no-confirm') exit "$DMS_TEST_SHELLY_EXIT" ;;
|
||||||
|
*) echo "unexpected arguments: $*" >&2; exit 99 ;;
|
||||||
|
esac`)
|
||||||
|
return dir, log
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUpdateCalls(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellyCheckUpdates(t *testing.T) {
|
||||||
|
_, log := fakeShelly(t)
|
||||||
|
pkgs, err := (shellyBackend{}).CheckUpdates(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(pkgs) != 2 || pkgs[0].Repo != RepoSystem || pkgs[1].Repo != RepoAUR {
|
||||||
|
t.Fatalf("unexpected packages: %#v", pkgs)
|
||||||
|
}
|
||||||
|
if got, want := readUpdateCalls(t, log), "list-updates standard --json|\nlist-updates aur --json|\n"; got != want {
|
||||||
|
t.Fatalf("calls = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellyCheckFailures(t *testing.T) {
|
||||||
|
for _, code := range []string{"1", "2"} {
|
||||||
|
t.Run(code, func(t *testing.T) {
|
||||||
|
dir, _ := fakeShelly(t)
|
||||||
|
writeUpdateExecutable(t, dir, "shelly", "echo 'database unavailable' >&2; exit "+code)
|
||||||
|
if _, err := (shellyBackend{}).CheckUpdates(t.Context()); err == nil || !strings.Contains(err.Error(), "database unavailable") {
|
||||||
|
t.Fatalf("expected query failure with stderr, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("cancellation", func(t *testing.T) {
|
||||||
|
dir, _ := fakeShelly(t)
|
||||||
|
writeUpdateExecutable(t, dir, "shelly", "exec /bin/sleep 30")
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := (shellyBackend{}).CheckUpdates(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("expected deadline error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellyUpgrade(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
includeAUR bool
|
||||||
|
attached bool
|
||||||
|
terminal bool
|
||||||
|
}{
|
||||||
|
{"pkexec repository", false, false, false},
|
||||||
|
{"pkexec aur", true, false, false},
|
||||||
|
{"attached repository", false, true, false},
|
||||||
|
{"attached aur", true, true, false},
|
||||||
|
{"terminal repository", false, false, true},
|
||||||
|
{"terminal aur", true, false, true},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir, log := fakeShelly(t)
|
||||||
|
opts := UpgradeOptions{IncludeAUR: tt.includeAUR, AttachStdio: tt.attached, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||||
|
elevator := ""
|
||||||
|
if tt.terminal {
|
||||||
|
opts.Terminal = "fake-terminal"
|
||||||
|
writeUpdateExecutable(t, dir, opts.Terminal, `while [ "$1" != sh ] && [ "$#" -gt 0 ]; do shift; done
|
||||||
|
shift
|
||||||
|
exec /bin/sh "$@"`)
|
||||||
|
} else if !tt.attached {
|
||||||
|
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||||
|
elevator = "pkexec"
|
||||||
|
}
|
||||||
|
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := "upgrade standard --no-confirm"
|
||||||
|
if tt.includeAUR {
|
||||||
|
command = "upgrade all --no-flatpak --no-appimage --no-confirm"
|
||||||
|
}
|
||||||
|
if got, want := readUpdateCalls(t, log), command+"|"+elevator+"\n"; got != want {
|
||||||
|
t.Fatalf("calls = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellyUpgradeHolds(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
includeAUR bool
|
||||||
|
ignored []string
|
||||||
|
wantError bool
|
||||||
|
dryRun bool
|
||||||
|
}{
|
||||||
|
{"held aur", true, []string{"example-git"}, true, false},
|
||||||
|
{"repository only", false, []string{"example-git"}, false, false},
|
||||||
|
{"repository hold cannot exclude system upgrade", true, []string{"linux"}, false, false},
|
||||||
|
{"unrelated hold", true, []string{"org.example.Flatpak"}, false, false},
|
||||||
|
{"dry held aur", true, []string{"example-git"}, true, true},
|
||||||
|
{"dry repository only", false, []string{"example-git"}, false, true},
|
||||||
|
{"dry repository hold", true, []string{"linux"}, false, true},
|
||||||
|
{"dry unrelated hold", true, []string{"org.example.Flatpak"}, false, true},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, log := fakeShelly(t)
|
||||||
|
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||||
|
opts := UpgradeOptions{DryRun: tt.dryRun, IncludeAUR: tt.includeAUR, Ignored: tt.ignored, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||||
|
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
|
||||||
|
calls := readUpdateCalls(t, log)
|
||||||
|
if strings.Count(calls, "list-updates aur --json") > 1 {
|
||||||
|
t.Fatalf("duplicate AUR check: %q", calls)
|
||||||
|
}
|
||||||
|
if tt.wantError {
|
||||||
|
if err == nil || err.Error() != `shelly cannot exclude held AUR package "example-git"; disable AUR updates or remove its DMS hold before updating` || strings.Contains(calls, "upgrade ") {
|
||||||
|
t.Fatalf("held AUR package must block upgrade, got %v, calls %q", err, readUpdateCalls(t, log))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || strings.Contains(calls, "upgrade ") == tt.dryRun {
|
||||||
|
t.Fatalf("unexpected result for dryRun=%v: %v, calls %q", tt.dryRun, err, calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !isPacmanFamily(shellyBackend{}) {
|
||||||
|
t.Fatal("Shelly must enforce repository hold restrictions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellyHoldCheckFailure(t *testing.T) {
|
||||||
|
for _, dryRun := range []bool{false, true} {
|
||||||
|
dir, log := fakeShelly(t)
|
||||||
|
writeUpdateExecutable(t, dir, "shelly", `printf '%s\n' "$*" >> "$DMS_TEST_SHELLY_LOG"
|
||||||
|
echo 'AUR unavailable' >&2
|
||||||
|
exit 1`)
|
||||||
|
opts := UpgradeOptions{DryRun: dryRun, IncludeAUR: true, Ignored: []string{"example-git"}, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||||
|
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "AUR unavailable") {
|
||||||
|
t.Fatalf("dryRun=%v: expected AUR query error, got %v", dryRun, err)
|
||||||
|
}
|
||||||
|
if calls := readUpdateCalls(t, log); calls != "list-updates aur --json\n" {
|
||||||
|
t.Fatalf("query failure must stop the operation: %q", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellyDryRun(t *testing.T) {
|
||||||
|
for _, includeAUR := range []bool{false, true} {
|
||||||
|
_, log := fakeShelly(t)
|
||||||
|
var lines []string
|
||||||
|
opts := UpgradeOptions{DryRun: true, IncludeAUR: includeAUR, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||||
|
if err := (shellyBackend{}).Upgrade(t.Context(), opts, func(line string) { lines = append(lines, line) }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantCalls := "list-updates standard --json|\n"
|
||||||
|
wantLines := []string{"linux 2:6.18-1 -> 2:6.19-1"}
|
||||||
|
if includeAUR {
|
||||||
|
wantCalls = "list-updates aur --json|\n" + wantCalls
|
||||||
|
wantLines = append(wantLines, "example-git r1-1 -> r2-1")
|
||||||
|
}
|
||||||
|
if got := readUpdateCalls(t, log); got != wantCalls || !reflect.DeepEqual(lines, wantLines) {
|
||||||
|
t.Fatalf("calls = %q, lines = %q", got, lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellySkipsExcludedTargets(t *testing.T) {
|
||||||
|
for _, targets := range [][]Package{
|
||||||
|
nil,
|
||||||
|
{{Name: "example-git", Backend: "shelly", Repo: RepoAUR}},
|
||||||
|
{{Name: "org.example.App", Backend: "flatpak", Repo: RepoFlatpak}},
|
||||||
|
{{Name: "linux", Backend: "pacman", Repo: RepoSystem}},
|
||||||
|
} {
|
||||||
|
_, log := fakeShelly(t)
|
||||||
|
if err := (shellyBackend{}).Upgrade(t.Context(), UpgradeOptions{Targets: targets, IncludeFlatpak: true}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := readUpdateCalls(t, log); got != "" {
|
||||||
|
t.Fatalf("excluded targets executed commands: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellyUpgradeFailure(t *testing.T) {
|
||||||
|
_, _ = fakeShelly(t)
|
||||||
|
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||||
|
t.Setenv("DMS_TEST_SHELLY_EXIT", "126")
|
||||||
|
opts := UpgradeOptions{Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||||
|
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err == nil {
|
||||||
|
t.Fatal("authentication or upgrade failure must propagate")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ func Run(ctx context.Context, argv []string, opts RunOptions) error {
|
|||||||
}
|
}
|
||||||
return cmd.Process.Kill()
|
return cmd.Process.Kill()
|
||||||
}
|
}
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
@@ -125,7 +126,14 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
|||||||
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
||||||
title, shellCmd,
|
title, shellCmd,
|
||||||
)
|
)
|
||||||
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
|
closer := `dms_update_status=$?
|
||||||
|
if [ "$dms_update_status" -eq 0 ]; then
|
||||||
|
printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'
|
||||||
|
else
|
||||||
|
printf '\n\033[1;31m=== Update failed (exit %s). Press Enter to close. ===\033[0m\n' "$dms_update_status"
|
||||||
|
fi
|
||||||
|
read -r dms_update_reply
|
||||||
|
exit "$dms_update_status"`
|
||||||
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||||
full := export + banner + "; " + shellCmd + "; " + closer
|
full := export + banner + "; " + shellCmd + "; " + closer
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTerminalWrapperPreservesExitStatus(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
command string
|
||||||
|
status int
|
||||||
|
message string
|
||||||
|
}{
|
||||||
|
{"true", 0, "Done."},
|
||||||
|
{"false", 1, "Update failed (exit 1)"},
|
||||||
|
{"sh -c 'exit 42'", 42, "Update failed (exit 42)"},
|
||||||
|
} {
|
||||||
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
|
argv := wrapInTerminal("xterm", "Test", tt.command, nil)
|
||||||
|
cmd := exec.Command("sh", "-c", argv[len(argv)-1])
|
||||||
|
cmd.Stdin = strings.NewReader("\n")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if tt.status == 0 && err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tt.status != 0 {
|
||||||
|
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||||
|
if !ok || exitErr.ExitCode() != tt.status {
|
||||||
|
t.Fatalf("expected exit %d, got %v (%s)", tt.status, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), tt.message) {
|
||||||
|
t.Fatalf("missing status message %q: %s", tt.message, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAttachesStdin(t *testing.T) {
|
||||||
|
reader, writer, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
if _, err := writer.WriteString("confirmation\n"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writer.Close()
|
||||||
|
previous := os.Stdin
|
||||||
|
os.Stdin = reader
|
||||||
|
defer func() { os.Stdin = previous }()
|
||||||
|
if err := Run(t.Context(), []string{"sh", "-c", `read -r answer && [ "$answer" = confirmation ]`}, RunOptions{AttachStdio: true}); err != nil {
|
||||||
|
t.Fatalf("attached command did not receive stdin: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -420,8 +420,8 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
|||||||
m.failCustomUpgrade(ErrCodeCancelled, err)
|
m.failCustomUpgrade(ErrCodeCancelled, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// exit status reflects the trailing `read`, not the update command
|
m.failCustomUpgrade(ErrCodeBackendFailed, err)
|
||||||
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m.finishSuccessfulUpgrade(false)
|
m.finishSuccessfulUpgrade(false)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type customUpgradeTestBackend struct {
|
||||||
|
pacmanBackend
|
||||||
|
checks int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *customUpgradeTestBackend) CheckUpdates(context.Context) ([]Package, error) {
|
||||||
|
b.checks++
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomUpgradeFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
writeUpdateExecutable(t, dir, "fake-terminal", `while [ "$#" -gt 0 ] && [ "$1" != sh ]; do shift; done
|
||||||
|
[ "$#" -gt 0 ] || exit 99
|
||||||
|
shift
|
||||||
|
exec /bin/sh "$@"`)
|
||||||
|
|
||||||
|
backend := &customUpgradeTestBackend{}
|
||||||
|
m := &Manager{
|
||||||
|
selection: Selection{System: backend},
|
||||||
|
notifyDirty: make(chan struct{}, 1),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
m.runCustomUpgrade(t.Context(), UpgradeOptions{
|
||||||
|
Terminal: "fake-terminal",
|
||||||
|
CustomCommand: "false",
|
||||||
|
})
|
||||||
|
|
||||||
|
state := m.GetState()
|
||||||
|
if state.Phase != PhaseError {
|
||||||
|
t.Errorf("phase = %s, want %s", state.Phase, PhaseError)
|
||||||
|
}
|
||||||
|
if state.Error == nil || state.Error.Code != ErrCodeBackendFailed || !strings.Contains(state.Error.Message, "exit status 1") {
|
||||||
|
t.Errorf("error = %#v, want backend-failed with exit status 1", state.Error)
|
||||||
|
}
|
||||||
|
if slices.Contains(state.RecentLog, "Upgrade complete.") {
|
||||||
|
t.Error("failed custom upgrade logged success")
|
||||||
|
}
|
||||||
|
if backend.checks != 0 {
|
||||||
|
t.Errorf("failed custom upgrade refreshed %d times", backend.checks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ func convertStatus(status *ipnstate.Status) *TailscaleState {
|
|||||||
|
|
||||||
if status.Self != nil {
|
if status.Self != nil {
|
||||||
state.Self = convertPeerStatus(status.Self, users)
|
state.Self = convertPeerStatus(status.Self, users)
|
||||||
|
state.Self.Online = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(status.Peer) > 0 {
|
if len(status.Peer) > 0 {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func newManager(client tailscaleClient) *Manager {
|
|||||||
func (m *Manager) watchLoop(ctx context.Context) {
|
func (m *Manager) watchLoop(ctx context.Context) {
|
||||||
defer m.watchWG.Done()
|
defer m.watchWG.Done()
|
||||||
|
|
||||||
mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
|
mask := ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
|
||||||
backoff := time.Second
|
backoff := time.Second
|
||||||
unreachableSent := false
|
unreachableSent := false
|
||||||
|
|
||||||
@@ -122,8 +122,13 @@ func (m *Manager) watchLoop(ctx context.Context) {
|
|||||||
log.Info("[Tailscale] Connected to IPN bus")
|
log.Info("[Tailscale] Connected to IPN bus")
|
||||||
m.markAvailable()
|
m.markAvailable()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case m.dirty <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
notify, err := watcher.Next()
|
_, err := watcher.Next()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("[Tailscale] IPN bus error: %v", err)
|
log.Warnf("[Tailscale] IPN bus error: %v", err)
|
||||||
break
|
break
|
||||||
@@ -131,9 +136,6 @@ func (m *Manager) watchLoop(ctx context.Context) {
|
|||||||
|
|
||||||
backoff = time.Second
|
backoff = time.Second
|
||||||
|
|
||||||
if notify.State == nil && notify.NetMap == nil { //nolint:staticcheck // NetMap is deprecated upstream but still the only activity signal on some platforms
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
select {
|
select {
|
||||||
case m.dirty <- struct{}{}:
|
case m.dirty <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -418,3 +418,55 @@ func TestManager_Actions_PropagateError(t *testing.T) {
|
|||||||
assert.Error(t, m.SetExitNode("nABC123"))
|
assert.Error(t, m.SetExitNode("nABC123"))
|
||||||
assert.Error(t, m.SetAllowLANAccess(true))
|
assert.Error(t, m.SetAllowLANAccess(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type dynamicWatcher struct {
|
||||||
|
ctx context.Context
|
||||||
|
ch chan ipn.Notify
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dynamicWatcher) Next() (ipn.Notify, error) {
|
||||||
|
select {
|
||||||
|
case n, ok := <-w.ch:
|
||||||
|
if !ok {
|
||||||
|
return ipn.Notify{}, fmt.Errorf("watcher closed")
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
case <-w.ctx.Done():
|
||||||
|
return ipn.Notify{}, w.ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dynamicWatcher) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchLoop_NotifyWithoutStateOrNetMap(t *testing.T) {
|
||||||
|
var statusCalls atomic.Int32
|
||||||
|
notifyCh := make(chan ipn.Notify, 1)
|
||||||
|
|
||||||
|
client := &mockClient{
|
||||||
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
return &dynamicWatcher{ctx: ctx, ch: notifyCh}, nil
|
||||||
|
},
|
||||||
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
statusCalls.Add(1)
|
||||||
|
return runningStatus(), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := newManager(client)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
// Wait for the initial connect-time status call to complete.
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return statusCalls.Load() == 1
|
||||||
|
}, 2*time.Second, 10*time.Millisecond)
|
||||||
|
|
||||||
|
// Send an event with State == nil and NetMap == nil.
|
||||||
|
notifyCh <- ipn.Notify{Engine: &ipn.EngineStatus{}}
|
||||||
|
|
||||||
|
// Verify that the notification without State or NetMap triggers a second status call.
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return statusCalls.Load() >= 2
|
||||||
|
}, 2*time.Second, 10*time.Millisecond)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AppChecker interface {
|
type AppChecker interface {
|
||||||
@@ -115,3 +123,64 @@ func userBinDirs() []string {
|
|||||||
dirs = append(dirs, "/usr/local/bin")
|
dirs = append(dirs, "/usr/local/bin")
|
||||||
return dirs
|
return dirs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxJSONProcessBytes = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
type boundedWriter struct {
|
||||||
|
bytes.Buffer
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *boundedWriter) Write(p []byte) (int, error) {
|
||||||
|
if len(p) > w.limit-w.Len() {
|
||||||
|
return 0, errors.New("process output exceeds size bound")
|
||||||
|
}
|
||||||
|
return w.Buffer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunJSON(ctx context.Context, executable string, args []string, input []byte, result any) error {
|
||||||
|
if len(input) > maxJSONProcessBytes {
|
||||||
|
return errors.New("request exceeds size bound")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, executable, args...)
|
||||||
|
cmd.WaitDelay = time.Second
|
||||||
|
stdout := &boundedWriter{limit: maxJSONProcessBytes + 1}
|
||||||
|
stderr := &boundedWriter{limit: 16 * 1024}
|
||||||
|
cmd.Stdout, cmd.Stderr = stdout, stderr
|
||||||
|
if input != nil {
|
||||||
|
cmd.Stdin = bytes.NewReader(input)
|
||||||
|
}
|
||||||
|
err := cmd.Run()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return fmt.Errorf("unavailable: command completion uncertain: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
if decodeErr := DecodeJSON(bytes.TrimSuffix(stdout.Bytes(), []byte{'\n'}), result); decodeErr != nil {
|
||||||
|
return errors.Join(err, fmt.Errorf("invalid process JSON: %w", decodeErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", executable, err)
|
||||||
|
}
|
||||||
|
if stdout.Len() == 0 {
|
||||||
|
return errors.New("missing command acknowledgement")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeJSON(data []byte, target any) error {
|
||||||
|
if len(data) > maxJSONProcessBytes || !utf8.Valid(data) {
|
||||||
|
return errors.New("invalid UTF-8 or oversized JSON record")
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||||
|
return errors.New("trailing JSON data")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -67,3 +70,58 @@ func TestEnvWithUserBinPathPrependsLocalBin(t *testing.T) {
|
|||||||
assert.Contains(t, parts, "/usr/local/bin")
|
assert.Contains(t, parts, "/usr/local/bin")
|
||||||
assert.Contains(t, env, "OTHER=value")
|
assert.Contains(t, env, "OTHER=value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJSONProcessFixture(t *testing.T) {
|
||||||
|
mode := os.Getenv("DMS_JSON_PROCESS_FIXTURE")
|
||||||
|
if mode == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case "accepted":
|
||||||
|
fmt.Print(`{"ok":true,"status":"accepted","sequence":"9007199254740993"}`)
|
||||||
|
case "failure":
|
||||||
|
fmt.Print(`{"ok":false,"status":"locked","sequence":"1"}`)
|
||||||
|
os.Exit(1)
|
||||||
|
case "malformed":
|
||||||
|
fmt.Print("invalid JSON")
|
||||||
|
case "timeout":
|
||||||
|
time.Sleep(time.Minute)
|
||||||
|
case "oversize":
|
||||||
|
fmt.Print(strings.Repeat("x", maxJSONProcessBytes+2))
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunJSONResultsAndTimeout(t *testing.T) {
|
||||||
|
for _, mode := range []string{"accepted", "failure", "malformed", "oversize", "timeout"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
t.Setenv("DMS_JSON_PROCESS_FIXTURE", mode)
|
||||||
|
limit := 4 * time.Second
|
||||||
|
if mode == "timeout" {
|
||||||
|
limit = 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), limit)
|
||||||
|
defer cancel()
|
||||||
|
var result jsonProcessResult
|
||||||
|
err := RunJSON(ctx, os.Args[0], []string{"-test.run=^TestJSONProcessFixture$"}, nil, &result)
|
||||||
|
if mode == "accepted" {
|
||||||
|
if err != nil || !result.OK || result.Status != "accepted" || result.Sequence != "9007199254740993" {
|
||||||
|
t.Fatalf("lost acknowledgement: %v %#v", err, result)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("failure accepted")
|
||||||
|
}
|
||||||
|
if mode == "failure" && result.Status != "locked" {
|
||||||
|
t.Fatal("operation failure lost")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonProcessResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Sequence string `json:"sequence"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -10,22 +11,35 @@ func dconfPath(schema, key string) string {
|
|||||||
return "/" + strings.ReplaceAll(schema, ".", "/") + "/" + key
|
return "/" + strings.ReplaceAll(schema, ".", "/") + "/" + key
|
||||||
}
|
}
|
||||||
|
|
||||||
// GsettingsGet reads a gsettings value, falling back to dconf read.
|
// gsettings reaches whichever backend its own GLib links, not necessarily the
|
||||||
|
// dconf store GTK apps read, and it exits 0 either way.
|
||||||
|
func dconfSelected() bool {
|
||||||
|
backend := os.Getenv("GSETTINGS_BACKEND")
|
||||||
|
return backend == "" || backend == "dconf"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GsettingsGet reads a desktop setting from dconf, falling back to gsettings.
|
||||||
func GsettingsGet(schema, key string) (string, error) {
|
func GsettingsGet(schema, key string) (string, error) {
|
||||||
if out, err := exec.Command("gsettings", "get", schema, key).Output(); err == nil {
|
if dconfSelected() {
|
||||||
return strings.TrimSpace(string(out)), nil
|
if out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output(); err == nil {
|
||||||
|
if value := strings.TrimSpace(string(out)); value != "" {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output()
|
out, err := exec.Command("gsettings", "get", schema, key).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("gsettings/dconf get failed for %s %s: %w", schema, key, err)
|
return "", fmt.Errorf("dconf/gsettings get failed for %s %s: %w", schema, key, err)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out)), nil
|
return strings.TrimSpace(string(out)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GsettingsSet writes a gsettings value, falling back to dconf write.
|
// GsettingsSet writes a desktop setting to dconf, falling back to gsettings.
|
||||||
func GsettingsSet(schema, key, value string) error {
|
func GsettingsSet(schema, key, value string) error {
|
||||||
if err := exec.Command("gsettings", "set", schema, key, value).Run(); err == nil {
|
if dconfSelected() {
|
||||||
return nil
|
if err := exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run(); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run()
|
return exec.Command("gsettings", "set", schema, key, value).Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeTool struct {
|
||||||
|
name string
|
||||||
|
stdout string
|
||||||
|
exit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeTools(t *testing.T, tools ...fakeTool) string {
|
||||||
|
t.Helper()
|
||||||
|
binDir := t.TempDir()
|
||||||
|
logPath := filepath.Join(binDir, "calls.log")
|
||||||
|
for _, tool := range tools {
|
||||||
|
script := fmt.Sprintf("#!/bin/sh\necho \"%s $*\" >> %q\nprintf '%%s' %q\nexit %d\n", tool.name, logPath, tool.stdout, tool.exit)
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(binDir, tool.name), []byte(script), 0o755))
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
return logPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolCalls(t *testing.T, logPath string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(logPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGsettingsSetWritesDconf(t *testing.T) {
|
||||||
|
t.Setenv("GSETTINGS_BACKEND", "")
|
||||||
|
logPath := fakeTools(t,
|
||||||
|
fakeTool{name: "dconf"},
|
||||||
|
fakeTool{name: "gsettings"},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3"))
|
||||||
|
|
||||||
|
calls := toolCalls(t, logPath)
|
||||||
|
assert.Contains(t, calls, "dconf write /org/gnome/desktop/interface/gtk-theme 'adw-gtk3'")
|
||||||
|
assert.NotContains(t, calls, "gsettings")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGsettingsSetFallsBackWhenDconfFails(t *testing.T) {
|
||||||
|
t.Setenv("GSETTINGS_BACKEND", "")
|
||||||
|
logPath := fakeTools(t,
|
||||||
|
fakeTool{name: "dconf", exit: 1},
|
||||||
|
fakeTool{name: "gsettings"},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3"))
|
||||||
|
|
||||||
|
assert.Contains(t, toolCalls(t, logPath), "gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGsettingsSetHonorsBackendOverride(t *testing.T) {
|
||||||
|
t.Setenv("GSETTINGS_BACKEND", "keyfile")
|
||||||
|
logPath := fakeTools(t,
|
||||||
|
fakeTool{name: "dconf"},
|
||||||
|
fakeTool{name: "gsettings"},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3"))
|
||||||
|
|
||||||
|
calls := toolCalls(t, logPath)
|
||||||
|
assert.Contains(t, calls, "gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3")
|
||||||
|
assert.NotContains(t, calls, "dconf write")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGsettingsGetReadsDconf(t *testing.T) {
|
||||||
|
t.Setenv("GSETTINGS_BACKEND", "")
|
||||||
|
fakeTools(t,
|
||||||
|
fakeTool{name: "dconf", stdout: "'adw-gtk3-dark'"},
|
||||||
|
fakeTool{name: "gsettings", stdout: "'Adwaita'"},
|
||||||
|
)
|
||||||
|
|
||||||
|
value, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "'adw-gtk3-dark'", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGsettingsGetFallsBackWhenDconfUnset(t *testing.T) {
|
||||||
|
t.Setenv("GSETTINGS_BACKEND", "")
|
||||||
|
fakeTools(t,
|
||||||
|
fakeTool{name: "dconf"},
|
||||||
|
fakeTool{name: "gsettings", stdout: "'Adwaita'"},
|
||||||
|
)
|
||||||
|
|
||||||
|
value, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "'Adwaita'", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGsettingsGetFailsWhenNoToolWorks(t *testing.T) {
|
||||||
|
t.Setenv("GSETTINGS_BACKEND", "")
|
||||||
|
fakeTools(t,
|
||||||
|
fakeTool{name: "dconf", exit: 1},
|
||||||
|
fakeTool{name: "gsettings", exit: 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme")
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, "gtk-theme")
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Shared by the screenshot region selector and the color picker: one grabbing overlay at a time.
|
||||||
|
SelectionOverlayLock = "dms-selection-overlay"
|
||||||
|
// Matches what the wayland client dials when WAYLAND_DISPLAY is unset.
|
||||||
|
defaultWaylandDisplay = "wayland-0"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrLockHeld = errors.New("lock is held by another process")
|
||||||
|
ErrOverlayActive = errors.New("another dms selection overlay is already open")
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileLock struct {
|
||||||
|
file *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayScope() string {
|
||||||
|
name := filepath.Base(os.Getenv("WAYLAND_DISPLAY"))
|
||||||
|
switch name {
|
||||||
|
case ".", "..", string(filepath.Separator):
|
||||||
|
return defaultWaylandDisplay
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// One user's concurrent sessions share XDG_RUNTIME_DIR, so the lock is per wayland display.
|
||||||
|
func lockPath(name string) string {
|
||||||
|
return filepath.Join(RuntimeDir(), fmt.Sprintf("%s-%d-%s.lock", name, os.Getuid(), displayScope()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockHolderPID(name string) int {
|
||||||
|
data, err := os.ReadFile(lockPath(name))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return pid
|
||||||
|
}
|
||||||
|
|
||||||
|
func TryLock(name string) (*FileLock, error) {
|
||||||
|
f, err := os.OpenFile(lockPath(name), os.O_CREATE|os.O_RDWR, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||||
|
f.Close()
|
||||||
|
if errors.Is(err, syscall.EWOULDBLOCK) {
|
||||||
|
return nil, ErrLockHeld
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Truncate(0); err == nil {
|
||||||
|
_, _ = f.WriteString(strconv.Itoa(os.Getpid()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FileLock{file: f}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockSelectionOverlay only refuses on contention: a lock file that cannot be created or
|
||||||
|
// flocked yields a nil lock and no error, so the overlay still runs unguarded.
|
||||||
|
func LockSelectionOverlay() (*FileLock, error) {
|
||||||
|
lock, err := TryLock(SelectionOverlayLock)
|
||||||
|
if err == nil {
|
||||||
|
return lock, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrLockHeld) {
|
||||||
|
log.Debug("selection overlay lock unavailable", "err", err)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if pid := lockHolderPID(SelectionOverlayLock); pid > 0 {
|
||||||
|
return nil, fmt.Errorf("%w (pid %d)", ErrOverlayActive, pid)
|
||||||
|
}
|
||||||
|
return nil, ErrOverlayActive
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *FileLock) Release() {
|
||||||
|
if l == nil || l.file == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
|
||||||
|
l.file.Close()
|
||||||
|
l.file = nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTryLockRejectsSecondHolder(t *testing.T) {
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||||
|
|
||||||
|
held, err := TryLock(SelectionOverlayLock)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = TryLock(SelectionOverlayLock)
|
||||||
|
assert.ErrorIs(t, err, ErrLockHeld)
|
||||||
|
|
||||||
|
held.Release()
|
||||||
|
|
||||||
|
reacquired, err := TryLock(SelectionOverlayLock)
|
||||||
|
require.NoError(t, err)
|
||||||
|
reacquired.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryLockSeparatesNames(t *testing.T) {
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||||
|
|
||||||
|
first, err := TryLock("dms-test-a")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer first.Release()
|
||||||
|
|
||||||
|
second, err := TryLock("dms-test-b")
|
||||||
|
require.NoError(t, err)
|
||||||
|
second.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryLockSeparatesWaylandDisplays(t *testing.T) {
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||||
|
|
||||||
|
t.Setenv("WAYLAND_DISPLAY", "wayland-1")
|
||||||
|
first, err := TryLock(SelectionOverlayLock)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer first.Release()
|
||||||
|
|
||||||
|
t.Setenv("WAYLAND_DISPLAY", "wayland-2")
|
||||||
|
second, err := TryLock(SelectionOverlayLock)
|
||||||
|
require.NoError(t, err)
|
||||||
|
second.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryLockWithoutWaylandDisplay(t *testing.T) {
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||||
|
t.Setenv("WAYLAND_DISPLAY", "")
|
||||||
|
os.Unsetenv("WAYLAND_DISPLAY")
|
||||||
|
|
||||||
|
unset, err := TryLock(SelectionOverlayLock)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, fmt.Sprintf("%s-%d-wayland-0.lock", SelectionOverlayLock, os.Getuid()), filepath.Base(lockPath(SelectionOverlayLock)))
|
||||||
|
|
||||||
|
t.Setenv("WAYLAND_DISPLAY", "wayland-1")
|
||||||
|
other, err := TryLock(SelectionOverlayLock)
|
||||||
|
require.NoError(t, err)
|
||||||
|
other.Release()
|
||||||
|
|
||||||
|
os.Unsetenv("WAYLAND_DISPLAY")
|
||||||
|
_, err = TryLock(SelectionOverlayLock)
|
||||||
|
assert.ErrorIs(t, err, ErrLockHeld)
|
||||||
|
|
||||||
|
unset.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayScopeStaysASingleName(t *testing.T) {
|
||||||
|
cases := []struct{ display, want string }{
|
||||||
|
{"", "wayland-0"},
|
||||||
|
{"/", "wayland-0"},
|
||||||
|
{"..", "wayland-0"},
|
||||||
|
{"wayland-1", "wayland-1"},
|
||||||
|
{"/run/user/1000/wayland-2", "wayland-2"},
|
||||||
|
{"/run/user/1000/wayland-3/", "wayland-3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Setenv("WAYLAND_DISPLAY", tc.display)
|
||||||
|
assert.Equal(t, tc.want, displayScope(), tc.display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryLockExcludesOtherProcesses(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("flock"); err != nil {
|
||||||
|
t.Skip("flock not available")
|
||||||
|
}
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||||
|
|
||||||
|
held, err := TryLock(SelectionOverlayLock)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
path := lockPath(SelectionOverlayLock)
|
||||||
|
assert.Error(t, exec.Command("flock", "-n", "-x", path, "-c", "true").Run())
|
||||||
|
|
||||||
|
held.Release()
|
||||||
|
assert.NoError(t, exec.Command("flock", "-n", "-x", path, "-c", "true").Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockSelectionOverlayReportsHolder(t *testing.T) {
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||||
|
|
||||||
|
held, err := LockSelectionOverlay()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, held)
|
||||||
|
defer held.Release()
|
||||||
|
|
||||||
|
lock, err := LockSelectionOverlay()
|
||||||
|
assert.Nil(t, lock)
|
||||||
|
assert.ErrorIs(t, err, ErrOverlayActive)
|
||||||
|
assert.Contains(t, err.Error(), fmt.Sprintf("pid %d", os.Getpid()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockSelectionOverlayFallsBackWhenUnavailable(t *testing.T) {
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "missing"))
|
||||||
|
|
||||||
|
lock, err := LockSelectionOverlay()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, lock)
|
||||||
|
lock.Release()
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ import (
|
|||||||
"github.com/AvengeMedia/dankgo/paths"
|
"github.com/AvengeMedia/dankgo/paths"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var app = paths.New("danklinux")
|
||||||
|
|
||||||
|
func App() paths.App { return app }
|
||||||
|
|
||||||
|
func RuntimeDir() string { return app.SocketDir() }
|
||||||
|
|
||||||
func XDGStateHome() string { return paths.XDGStateHome() }
|
func XDGStateHome() string { return paths.XDGStateHome() }
|
||||||
|
|
||||||
func XDGDataHome() string { return paths.XDGDataHome() }
|
func XDGDataHome() string { return paths.XDGDataHome() }
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package keymap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormatXkbV1 = 1
|
||||||
|
|
||||||
|
// wl_keyboard.key carries evdev scancodes; xkb keycodes sit 8 above them.
|
||||||
|
const evdevOffset = 8
|
||||||
|
|
||||||
|
var pc105Codes = map[string]uint32{
|
||||||
|
"Escape": 1,
|
||||||
|
"p": 25,
|
||||||
|
"Return": 28,
|
||||||
|
"Control_L": 29,
|
||||||
|
"Shift_L": 42,
|
||||||
|
"v": 47,
|
||||||
|
"Alt_L": 56,
|
||||||
|
"space": 57,
|
||||||
|
"KP_Enter": 96,
|
||||||
|
"Control_R": 97,
|
||||||
|
"Alt_R": 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
var pc105Syms = invert(pc105Codes)
|
||||||
|
|
||||||
|
// xkbcommon may serialize keysyms as hex instead of names.
|
||||||
|
var keysymNames = map[uint32]string{
|
||||||
|
0x20: "space",
|
||||||
|
0x70: "p",
|
||||||
|
0x76: "v",
|
||||||
|
0xff0d: "Return",
|
||||||
|
0xff1b: "Escape",
|
||||||
|
0xff8d: "KP_Enter",
|
||||||
|
0xffe1: "Shift_L",
|
||||||
|
0xffe3: "Control_L",
|
||||||
|
0xffe4: "Control_R",
|
||||||
|
0xffe9: "Alt_L",
|
||||||
|
0xffea: "Alt_R",
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
|
||||||
|
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
|
||||||
|
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
|
||||||
|
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keymap maps evdev scancodes to their first-group, first-level keysym names.
|
||||||
|
// A nil Keymap answers with pc105 positions.
|
||||||
|
type Keymap struct {
|
||||||
|
symByCode map[uint32]string
|
||||||
|
codeBySym map[string]uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromEvent owns and closes the keymap fd; nil means no usable xkb keymap.
|
||||||
|
func FromEvent(e client.KeyboardKeymapEvent) *Keymap {
|
||||||
|
defer unix.Close(e.Fd)
|
||||||
|
if e.Format != FormatXkbV1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
text, err := Read(e.Fd, e.Size)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Parse(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Read(fd int, size uint32) (string, error) {
|
||||||
|
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
text := strings.TrimRight(string(data), "\x00")
|
||||||
|
return text, unix.Munmap(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(text string) *Keymap {
|
||||||
|
xkbCodes := map[string]uint32{}
|
||||||
|
for _, m := range keycodeDefRe.FindAllStringSubmatch(text, -1) {
|
||||||
|
if code, err := strconv.Atoi(m[2]); err == nil {
|
||||||
|
xkbCodes[m[1]] = uint32(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
k := &Keymap{symByCode: map[uint32]string{}, codeBySym: map[string]uint32{}}
|
||||||
|
for _, m := range keySymbolsRe.FindAllStringSubmatch(text, -1) {
|
||||||
|
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
|
||||||
|
if group == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
xkbCode, ok := xkbCodes[m[1]]
|
||||||
|
if !ok || xkbCode < evdevOffset {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sym := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
|
||||||
|
if sym == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code := xkbCode - evdevOffset
|
||||||
|
k.symByCode[code] = sym
|
||||||
|
if _, seen := k.codeBySym[sym]; !seen {
|
||||||
|
k.codeBySym[sym] = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Keymap) Keysym(code uint32) string {
|
||||||
|
if k == nil {
|
||||||
|
return pc105Syms[code]
|
||||||
|
}
|
||||||
|
if sym, ok := k.symByCode[code]; ok {
|
||||||
|
return sym
|
||||||
|
}
|
||||||
|
return pc105Syms[code]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Keymap) Keycode(sym string) uint32 {
|
||||||
|
if k == nil {
|
||||||
|
return pc105Codes[sym]
|
||||||
|
}
|
||||||
|
if code, ok := k.codeBySym[sym]; ok {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
return pc105Codes[sym]
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalKeysym(sym string) string {
|
||||||
|
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
|
||||||
|
return sym
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseUint(sym[2:], 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
return sym
|
||||||
|
}
|
||||||
|
if name, ok := keysymNames[uint32(value)]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return sym
|
||||||
|
}
|
||||||
|
|
||||||
|
func invert(m map[string]uint32) map[uint32]string {
|
||||||
|
out := make(map[uint32]string, len(m))
|
||||||
|
for sym, code := range m {
|
||||||
|
out[code] = sym
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package keymap
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
const qwertyKeymap = `xkb_keymap {
|
||||||
|
xkb_keycodes "(unnamed)" {
|
||||||
|
minimum = 8;
|
||||||
|
maximum = 708;
|
||||||
|
<ESC> = 9;
|
||||||
|
<AB04> = 55;
|
||||||
|
<LCTL> = 37;
|
||||||
|
<LFSH> = 50;
|
||||||
|
alias <AL01> = <AC01>;
|
||||||
|
indicator 1 = "Caps Lock";
|
||||||
|
};
|
||||||
|
xkb_types "(unnamed)" {
|
||||||
|
type "ALPHABETIC" {
|
||||||
|
modifiers = Shift+Lock;
|
||||||
|
map[Shift] = Level2;
|
||||||
|
level_name[Level1] = "Base";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
xkb_symbols "(unnamed)" {
|
||||||
|
key <ESC> { [ Escape ] };
|
||||||
|
key <AB04> { type= "ALPHABETIC", [ v, V ] };
|
||||||
|
key <LCTL> { [ Control_L ] };
|
||||||
|
key <LFSH> { [ Shift_L ] };
|
||||||
|
};
|
||||||
|
};`
|
||||||
|
|
||||||
|
const hexKeymap = `xkb_keymap {
|
||||||
|
xkb_keycodes "(unnamed)" {
|
||||||
|
<AB04> = 56;
|
||||||
|
<LCTL> = 38;
|
||||||
|
<LFSH> = 51;
|
||||||
|
};
|
||||||
|
xkb_symbols "(unnamed)" {
|
||||||
|
key <LCTL> { [ 0xffe3 ] };
|
||||||
|
key <LFSH> {
|
||||||
|
type= "PC_ALT_LEVEL2",
|
||||||
|
symbols[1]= [ 0xffe1, 0xfe08 ]
|
||||||
|
};
|
||||||
|
key <AB04> { [ 0x76, 0x56 ] };
|
||||||
|
};
|
||||||
|
};`
|
||||||
|
|
||||||
|
const dvorakKeymap = `xkb_keymap {
|
||||||
|
xkb_keycodes "(unnamed)" {
|
||||||
|
<AB09> = 60;
|
||||||
|
<LCTL> = 37;
|
||||||
|
<LFSH> = 50;
|
||||||
|
};
|
||||||
|
xkb_symbols "(unnamed)" {
|
||||||
|
key <AB09> { [ v, V ] };
|
||||||
|
key <LCTL> { [ Control_L ] };
|
||||||
|
key <LFSH> { [ Shift_L ] };
|
||||||
|
};
|
||||||
|
};`
|
||||||
|
|
||||||
|
const swapEscapeKeymap = `xkb_keymap {
|
||||||
|
xkb_keycodes "(unnamed)" {
|
||||||
|
<ESC> = 9;
|
||||||
|
<RTRN> = 36;
|
||||||
|
<CAPS> = 66;
|
||||||
|
<KPEN> = 104;
|
||||||
|
};
|
||||||
|
xkb_symbols "(unnamed)" {
|
||||||
|
key <ESC> { [ Caps_Lock ] };
|
||||||
|
key <RTRN> { [ Return ] };
|
||||||
|
key <CAPS> {
|
||||||
|
type= "ONE_LEVEL",
|
||||||
|
symbols[1]= [ Escape ]
|
||||||
|
};
|
||||||
|
key <KPEN> { [ KP_Enter ] };
|
||||||
|
};
|
||||||
|
};`
|
||||||
|
|
||||||
|
func TestKeycode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
keymap string
|
||||||
|
sym string
|
||||||
|
want uint32
|
||||||
|
}{
|
||||||
|
{"qwerty v", qwertyKeymap, "v", 47},
|
||||||
|
{"qwerty ctrl", qwertyKeymap, "Control_L", 29},
|
||||||
|
{"qwerty shift", qwertyKeymap, "Shift_L", 42},
|
||||||
|
{"dvorak v", dvorakKeymap, "v", 52},
|
||||||
|
{"hex v", hexKeymap, "v", 48},
|
||||||
|
{"hex ctrl", hexKeymap, "Control_L", 30},
|
||||||
|
{"hex shift", hexKeymap, "Shift_L", 43},
|
||||||
|
{"swapescape escape", swapEscapeKeymap, "Escape", 58},
|
||||||
|
{"missing falls back to pc105", dvorakKeymap, "Escape", 1},
|
||||||
|
{"empty falls back to pc105", "", "v", 47},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := Parse(tt.keymap).Keycode(tt.sym); got != tt.want {
|
||||||
|
t.Errorf("Keycode(%q) = %d, want %d", tt.sym, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeysym(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
keymap string
|
||||||
|
code uint32
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"swapescape caps yields Escape", swapEscapeKeymap, 58, "Escape"},
|
||||||
|
{"swapescape esc yields Caps_Lock", swapEscapeKeymap, 1, "Caps_Lock"},
|
||||||
|
{"swapescape return", swapEscapeKeymap, 28, "Return"},
|
||||||
|
{"swapescape keypad enter", swapEscapeKeymap, 96, "KP_Enter"},
|
||||||
|
{"qwerty escape", qwertyKeymap, 1, "Escape"},
|
||||||
|
{"hex ctrl", hexKeymap, 30, "Control_L"},
|
||||||
|
{"unmapped code falls back to pc105", qwertyKeymap, 28, "Return"},
|
||||||
|
{"unknown code is empty", qwertyKeymap, 200, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := Parse(tt.keymap).Keysym(tt.code); got != tt.want {
|
||||||
|
t.Errorf("Keysym(%d) = %q, want %q", tt.code, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNilKeymapUsesPc105(t *testing.T) {
|
||||||
|
var k *Keymap
|
||||||
|
if got := k.Keysym(1); got != "Escape" {
|
||||||
|
t.Errorf("Keysym(1) = %q, want Escape", got)
|
||||||
|
}
|
||||||
|
if got := k.Keycode("Control_L"); got != 29 {
|
||||||
|
t.Errorf("Keycode(Control_L) = %d, want 29", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
inherit version;
|
inherit version;
|
||||||
pname = "dms-shell";
|
pname = "dms-shell";
|
||||||
src = ./core;
|
src = ./core;
|
||||||
vendorHash = "sha256-nswS/ygp0oYMXp6YHbbIxBha4xvzjKAxcAS5yOpbslg=";
|
vendorHash = "sha256-ejDi+TzstUtxtWpcl5yYw4UZuTnZraOsIdJuXPKxJ6s=";
|
||||||
|
|
||||||
subPackages = [ "cmd/dms" ];
|
subPackages = [ "cmd/dms" ];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
function socketPath(path, runtime) {
|
||||||
|
if (!runtime.startsWith("/") || runtime.endsWith("/") || !path.startsWith(runtime + "/aqueous/"))
|
||||||
|
return false;
|
||||||
|
if (/[\r\n\u0000]/.test(path) || path.includes("//") || path.split("/").some(part => part === "." || part === ".."))
|
||||||
|
return false;
|
||||||
|
return /^\/aqueous\/[^/]+\/ipc\.sock$/.test(path.slice(runtime.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function byteLength(text) {
|
||||||
|
return encodeURIComponent(text).replace(/%[0-9A-F]{2}/g, "x").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(value) {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decimal(value) {
|
||||||
|
return typeof value === "string" && /^[0-9]{1,20}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextId(value) {
|
||||||
|
const digits = value.split("");
|
||||||
|
for (let i = digits.length - 1; i >= 0; i--) {
|
||||||
|
if (digits[i] !== "9") {
|
||||||
|
digits[i] = String(Number(digits[i]) + 1);
|
||||||
|
return digits.join("");
|
||||||
|
}
|
||||||
|
digits[i] = "0";
|
||||||
|
}
|
||||||
|
if (digits.length === 20)
|
||||||
|
throw new Error("IPC request IDs exhausted");
|
||||||
|
return "1" + digits.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hello(value) {
|
||||||
|
if (!object(value) || value.schema !== 1 || typeof value.session !== "string" || !/^[0-9a-f]{32}$/.test(value.session) || value.max_pending_requests !== 1)
|
||||||
|
throw new Error("unsupported: Aqueous IPC hello");
|
||||||
|
const limits = {max_request_bytes: 65536, max_frame_bytes: 4259840, max_batch_bytes: 4194304};
|
||||||
|
for (const key of Object.keys(limits)) {
|
||||||
|
if (!Number.isInteger(value[key]) || value[key] <= 0 || value[key] > limits[key])
|
||||||
|
throw new Error("unsupported: IPC " + key);
|
||||||
|
}
|
||||||
|
if (!object(value.capabilities))
|
||||||
|
throw new Error("unsupported: IPC capabilities");
|
||||||
|
for (const name of ["state", "commands", "keyboard", "overview", "shortcut_inhibition"]) {
|
||||||
|
if (typeof value.capabilities[name] !== "boolean")
|
||||||
|
throw new Error("unsupported: missing capability " + name);
|
||||||
|
}
|
||||||
|
if (!value.capabilities.state)
|
||||||
|
throw new Error("unsupported: shell state");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envelope(line, limit) {
|
||||||
|
if (byteLength(line) > limit)
|
||||||
|
throw new Error("IPC frame exceeds size bound");
|
||||||
|
let depth = 0, quoted = false, escaped = false;
|
||||||
|
for (const c of line) {
|
||||||
|
if (quoted) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (c) {
|
||||||
|
case "\\":
|
||||||
|
escaped = true;
|
||||||
|
break;
|
||||||
|
case '"':
|
||||||
|
quoted = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (c) {
|
||||||
|
case '"':
|
||||||
|
quoted = true;
|
||||||
|
break;
|
||||||
|
case "{":
|
||||||
|
case "[":
|
||||||
|
depth++;
|
||||||
|
break;
|
||||||
|
case "}":
|
||||||
|
case "]":
|
||||||
|
depth--;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (depth > 32)
|
||||||
|
throw new Error("IPC nesting exceeds limit");
|
||||||
|
}
|
||||||
|
const value = JSON.parse(line);
|
||||||
|
if (!object(value) || value.ipc !== 1)
|
||||||
|
throw new Error("unsupported: IPC envelope");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(value, id) {
|
||||||
|
if (value.id !== id || !decimal(value.id) || typeof value.ok !== "boolean" || value.event !== undefined)
|
||||||
|
throw new Error("invalid IPC response correlation");
|
||||||
|
if (value.ok) {
|
||||||
|
if (!object(value.result) || value.error !== undefined)
|
||||||
|
throw new Error("invalid IPC result");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!object(value.error) || typeof value.error.code !== "string" || !value.error.code || value.error.code.length > 128 || typeof value.error.message !== "string" || byteLength(value.error.message) > 4096 || value.result !== undefined)
|
||||||
|
throw new Error("malformed IPC error");
|
||||||
|
return value.error.code + ": " + value.error.message;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
.pragma library
|
||||||
|
|
||||||
|
// A calendar date with no time and no timezone.
|
||||||
|
//
|
||||||
|
// Arithmetic runs through Date.UTC, which has no DST gaps, so day and month
|
||||||
|
// rollover is exact. Instances are immutable: every operation returns a new one.
|
||||||
|
// month is 0-based, matching Javascripts' Date behavior.
|
||||||
|
|
||||||
|
class DateOnly {
|
||||||
|
constructor(year, month, day) {
|
||||||
|
this.year = year;
|
||||||
|
this.month = month;
|
||||||
|
this.day = day;
|
||||||
|
}
|
||||||
|
|
||||||
|
addDays(days) {
|
||||||
|
return _fromUtc(new Date(Date.UTC(this.year, this.month, this.day + days)));
|
||||||
|
}
|
||||||
|
|
||||||
|
addMonths(months) {
|
||||||
|
const target = new Date(Date.UTC(this.year, this.month + months, 1));
|
||||||
|
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
|
||||||
|
return new DateOnly(target.getUTCFullYear(), target.getUTCMonth(), Math.min(this.day, lastDay));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 = Sunday, matching Date.getDay().
|
||||||
|
dayOfWeek() {
|
||||||
|
return new Date(Date.UTC(this.year, this.month, this.day)).getUTCDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
daysUntil(other) {
|
||||||
|
const from = Date.UTC(this.year, this.month, this.day);
|
||||||
|
const to = Date.UTC(other.year, other.month, other.day);
|
||||||
|
return Math.round((to - from) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other) {
|
||||||
|
if (!other)
|
||||||
|
return false;
|
||||||
|
return this.year === other.year && this.month === other.month && this.day === other.day;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local noon, so consumers that read local calendar fields land on this date
|
||||||
|
// even on days where local midnight does not exist.
|
||||||
|
toDate() {
|
||||||
|
return new Date(this.year, this.month, this.day, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fromUtc(date) {
|
||||||
|
return new DateOnly(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function of(year, month, day) {
|
||||||
|
return new DateOnly(year, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromDate(date) {
|
||||||
|
return new DateOnly(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstOfMonth(year, month) {
|
||||||
|
return new DateOnly(year, month, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastOfMonth(year, month) {
|
||||||
|
return _fromUtc(new Date(Date.UTC(year, month + 1, 0)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.pragma library
|
||||||
|
|
||||||
|
function path(schema, key) {
|
||||||
|
return "/" + schema.replace(/\./g, "/") + "/" + key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// gsettings reaches whichever backend its own GLib links, not necessarily the
|
||||||
|
// dconf store GTK apps read, and it exits 0 either way.
|
||||||
|
function backendIsDconf() {
|
||||||
|
return "[ \"${GSETTINGS_BACKEND:-dconf}\" = dconf ]";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCmd(schema, key) {
|
||||||
|
return "{ { " + backendIsDconf() + " && dconf read " + path(schema, key) + " 2>/dev/null | grep .; } || gsettings get " + schema + " " + key + " 2>/dev/null; } | tr -d \"'\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCmd(schema, key, value) {
|
||||||
|
return "{ " + backendIsDconf() + " && dconf write " + path(schema, key) + " \"'" + value + "'\" 2>/dev/null; } || gsettings set " + schema + " " + key + " '" + value + "'";
|
||||||
|
}
|
||||||
@@ -189,6 +189,26 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly property var _termIndexes: new WeakMap()
|
||||||
|
|
||||||
|
function _termIndex(table) {
|
||||||
|
const cached = _termIndexes.get(table);
|
||||||
|
if (cached)
|
||||||
|
return cached;
|
||||||
|
const index = {};
|
||||||
|
for (const c in table) {
|
||||||
|
const bucket = table[c];
|
||||||
|
if (!bucket)
|
||||||
|
continue;
|
||||||
|
for (const term in bucket) {
|
||||||
|
if (!index[term] && bucket[term])
|
||||||
|
index[term] = bucket[term];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_termIndexes.set(table, index);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
function _lookup(table, term, context) {
|
function _lookup(table, term, context) {
|
||||||
if (!table)
|
if (!table)
|
||||||
return "";
|
return "";
|
||||||
@@ -196,11 +216,7 @@ Singleton {
|
|||||||
return table[context][term];
|
return table[context][term];
|
||||||
if (table[term] && table[term][term])
|
if (table[term] && table[term][term])
|
||||||
return table[term][term];
|
return table[term][term];
|
||||||
for (const c in table) {
|
return _termIndex(table)[term] || "";
|
||||||
if (table[c] && table[c][term])
|
|
||||||
return table[c][term];
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isRealContext is consumed by translations/extract_translations.py only:
|
// isRealContext is consumed by translations/extract_translations.py only:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Quickshell
|
|||||||
|
|
||||||
QtObject {
|
QtObject {
|
||||||
required property Singleton service
|
required property Singleton service
|
||||||
|
property var modules: null
|
||||||
property bool active: true
|
property bool active: true
|
||||||
|
|
||||||
property bool _held: false
|
property bool _held: false
|
||||||
@@ -11,7 +12,15 @@ QtObject {
|
|||||||
if (wanted === _held)
|
if (wanted === _held)
|
||||||
return;
|
return;
|
||||||
_held = wanted;
|
_held = wanted;
|
||||||
service.refCount += wanted ? 1 : -1;
|
if (modules === null) {
|
||||||
|
service.refCount += wanted ? 1 : -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wanted) {
|
||||||
|
service.addRef(modules);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service.removeRef(modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
onActiveChanged: sync(active)
|
onActiveChanged: sync(active)
|
||||||
|
|||||||
+147
-115
@@ -8,6 +8,7 @@ import Quickshell.Io
|
|||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Common.settings
|
import qs.Common.settings
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
import "GSettings.js" as GSettings
|
||||||
import "settings/SettingsSpec.js" as Spec
|
import "settings/SettingsSpec.js" as Spec
|
||||||
import "settings/SettingsStore.js" as Store
|
import "settings/SettingsStore.js" as Store
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ Singleton {
|
|||||||
id: root
|
id: root
|
||||||
readonly property var log: Log.scoped("SettingsData")
|
readonly property var log: Log.scoped("SettingsData")
|
||||||
|
|
||||||
readonly property int settingsConfigVersion: 17
|
readonly property int settingsConfigVersion: 18
|
||||||
|
|
||||||
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
||||||
|
|
||||||
@@ -987,37 +988,78 @@ Singleton {
|
|||||||
property var notificationRules: []
|
property var notificationRules: []
|
||||||
property bool notificationDndAllowCritical: true
|
property bool notificationDndAllowCritical: true
|
||||||
property bool notificationFocusedMonitor: false
|
property bool notificationFocusedMonitor: false
|
||||||
// Names the bar config rendered as Dank Island instead of a DankBar. Empty = no island.
|
// Island is a per-instance render mode: any bar config with island:true draws as an island
|
||||||
property string dankIslandBarId: ""
|
// instead of a DankBar, and carries its own island* look settings.
|
||||||
readonly property var islandBarConfig: dankIslandBarId ? (barConfigs.find(cfg => cfg.id === dankIslandBarId) || null) : null
|
readonly property var islandBarConfigs: {
|
||||||
readonly property bool dankIslandEnabled: !!islandBarConfig && (islandBarConfig.enabled ?? false)
|
barConfigs;
|
||||||
readonly property int dankIslandPosition: islandBarConfig?.position ?? SettingsData.Position.Top
|
return (barConfigs || []).filter(cfg => cfg && cfg.island === true);
|
||||||
readonly property string dankIslandEdge: dankIslandPosition === SettingsData.Position.Bottom ? "bottom" : "top"
|
}
|
||||||
readonly property int dankIslandReservedStripHeight: {
|
readonly property bool dankIslandEnabled: islandBarConfigs.some(cfg => cfg.enabled ?? false)
|
||||||
const reserve = Math.max(24, Math.min(128, dankIslandReserveHeight));
|
readonly property var islandDefaults: ({
|
||||||
const compact = Math.max(24, Math.min(72, dankIslandCompactHeight));
|
"islandFloating": false,
|
||||||
const gap = Math.max(0, Math.min(48, dankIslandOuterGap));
|
"islandUseOverlayLayer": false,
|
||||||
|
"islandReserveThickness": 40,
|
||||||
|
"islandCompactThickness": 38,
|
||||||
|
"islandOuterGap": 4,
|
||||||
|
"islandAlongOffset": 0,
|
||||||
|
"islandInteractionMode": "hybrid",
|
||||||
|
"islandHoverOpenDelay": 150,
|
||||||
|
"islandHoverCloseDelay": 150,
|
||||||
|
"islandPalette": "default",
|
||||||
|
"islandTransparency": 1,
|
||||||
|
"islandCornerRadius": 34,
|
||||||
|
"islandHighContrast": false,
|
||||||
|
"islandMediaClockVisible": true,
|
||||||
|
"islandNotificationBadgeClearOnOpen": false,
|
||||||
|
"islandNotificationExpand": false,
|
||||||
|
"islandHomeCompactTight": false,
|
||||||
|
"islandHomeClockDisplay": "both",
|
||||||
|
"islandHomeVolumeDisplay": "both",
|
||||||
|
"islandHomeBrightnessDisplay": "both",
|
||||||
|
"islandBatteryStyle": "solid",
|
||||||
|
"islandSatellitesEnabled": true,
|
||||||
|
"islandSatellitePosition": "edges",
|
||||||
|
"islandSatelliteGap": 12,
|
||||||
|
"islandSatelliteBackground": false,
|
||||||
|
"islandSatelliteGothCorners": true,
|
||||||
|
"islandSatelliteTransparency": 1,
|
||||||
|
"islandSatelliteSwoopRadius": 24,
|
||||||
|
"islandReducedMotion": false,
|
||||||
|
"islandSpringStiffness": 560,
|
||||||
|
"islandSpringDamping": 37,
|
||||||
|
"islandSpringMass": 1
|
||||||
|
})
|
||||||
|
|
||||||
|
function islandSetting(bc, key) {
|
||||||
|
const value = bc?.[key];
|
||||||
|
return value === undefined || value === null ? islandDefaults[key] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function islandLevelDisplay(bc, key) {
|
||||||
|
const value = islandSetting(bc, key);
|
||||||
|
return value === "icon" || value === "percentage" || value === "both" ? value : "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
function islandClockDisplay(bc) {
|
||||||
|
const value = islandSetting(bc, "islandHomeClockDisplay");
|
||||||
|
return value === "time" || value === "date" || value === "both" ? value : "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
function islandEdge(bc) {
|
||||||
|
return positionToSide(bc?.position ?? SettingsData.Position.Top) || "top";
|
||||||
|
}
|
||||||
|
|
||||||
|
function islandVertical(bc) {
|
||||||
|
const edge = islandEdge(bc);
|
||||||
|
return edge === "left" || edge === "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
function islandStripThickness(bc) {
|
||||||
|
const reserve = Math.max(24, Math.min(128, islandSetting(bc, "islandReserveThickness")));
|
||||||
|
const compact = Math.max(24, Math.min(72, islandSetting(bc, "islandCompactThickness")));
|
||||||
|
const gap = Math.max(0, Math.min(48, islandSetting(bc, "islandOuterGap")));
|
||||||
return Math.max(reserve, gap + compact);
|
return Math.max(reserve, gap + compact);
|
||||||
}
|
}
|
||||||
property bool dankIslandFloating: false
|
|
||||||
property bool dankIslandUseOverlayLayer: false
|
|
||||||
property int dankIslandReserveHeight: 40
|
|
||||||
property int dankIslandCompactHeight: 38
|
|
||||||
property int dankIslandOuterGap: 4
|
|
||||||
property int dankIslandHorizontalOffset: 0
|
|
||||||
property string dankIslandInteractionMode: "hybrid"
|
|
||||||
property int dankIslandHoverOpenDelay: 150
|
|
||||||
property int dankIslandHoverCloseDelay: 150
|
|
||||||
property string dankIslandPalette: "default"
|
|
||||||
property real dankIslandTransparency: 1
|
|
||||||
property int dankIslandCornerRadius: 34
|
|
||||||
property bool dankIslandHighContrast: false
|
|
||||||
property bool dankIslandMediaClockVisible: true
|
|
||||||
property bool dankIslandNotificationBadgeClearOnOpen: false
|
|
||||||
property bool dankIslandNotificationExpand: false
|
|
||||||
property bool dankIslandHomeCompactTight: false
|
|
||||||
property string dankIslandHomeVolumeDisplay: "both"
|
|
||||||
property string dankIslandHomeBrightnessDisplay: "both"
|
|
||||||
readonly property var _islandHomeGroupIds: ["media", "clock", "weather", "status", "volume", "brightness", "notifications"]
|
readonly property var _islandHomeGroupIds: ["media", "clock", "weather", "status", "volume", "brightness", "notifications"]
|
||||||
readonly property var _islandHomeLayoutDefault: [
|
readonly property var _islandHomeLayoutDefault: [
|
||||||
{
|
{
|
||||||
@@ -1049,10 +1091,8 @@ Singleton {
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
property var dankIslandHomeLayout: _islandHomeLayoutDefault
|
function getIslandHomeLayout(bc) {
|
||||||
|
const stored = Array.isArray(bc?.islandHomeLayout) ? bc.islandHomeLayout : [];
|
||||||
function getIslandHomeLayout() {
|
|
||||||
const stored = Array.isArray(dankIslandHomeLayout) ? dankIslandHomeLayout : [];
|
|
||||||
const result = [];
|
const result = [];
|
||||||
const seen = {};
|
const seen = {};
|
||||||
for (const entry of stored) {
|
for (const entry of stored) {
|
||||||
@@ -1075,41 +1115,33 @@ Singleton {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function islandHomeGroupEnabled(id) {
|
function islandHomeGroupEnabled(bc, id) {
|
||||||
const entry = getIslandHomeLayout().find(g => g.id === id);
|
const entry = getIslandHomeLayout(bc).find(g => g.id === id);
|
||||||
return entry ? entry.enabled : false;
|
return entry ? entry.enabled : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setIslandHomeLayoutOrder(ids) {
|
function setIslandHomeLayoutOrder(barId, ids) {
|
||||||
const current = getIslandHomeLayout();
|
const current = getIslandHomeLayout(getBarConfig(barId));
|
||||||
const ordered = ids.map(id => current.find(g => g.id === id)).filter(g => g);
|
const ordered = ids.map(id => current.find(g => g.id === id)).filter(g => g);
|
||||||
for (const entry of current) {
|
for (const entry of current) {
|
||||||
if (ids.indexOf(entry.id) < 0)
|
if (ids.indexOf(entry.id) < 0)
|
||||||
ordered.push(entry);
|
ordered.push(entry);
|
||||||
}
|
}
|
||||||
set("dankIslandHomeLayout", ordered);
|
updateBarConfig(barId, {
|
||||||
|
islandHomeLayout: ordered
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setIslandHomeGroupEnabled(id, on) {
|
function setIslandHomeGroupEnabled(barId, id, on) {
|
||||||
if (id === "clock")
|
if (id === "clock")
|
||||||
return;
|
return;
|
||||||
set("dankIslandHomeLayout", getIslandHomeLayout().map(g => g.id === id ? {
|
updateBarConfig(barId, {
|
||||||
"id": g.id,
|
islandHomeLayout: getIslandHomeLayout(getBarConfig(barId)).map(g => g.id === id ? {
|
||||||
"enabled": on
|
"id": g.id,
|
||||||
} : g));
|
"enabled": on
|
||||||
|
} : g)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
property string dankIslandBatteryStyle: "solid"
|
|
||||||
property bool dankIslandSatellitesEnabled: true
|
|
||||||
property string dankIslandSatellitePosition: "edges"
|
|
||||||
property int dankIslandSatelliteGap: 12
|
|
||||||
property bool dankIslandSatelliteBackground: false
|
|
||||||
property bool dankIslandSatelliteGothCorners: true
|
|
||||||
property real dankIslandSatelliteTransparency: 1
|
|
||||||
property int dankIslandSatelliteSwoopRadius: 24
|
|
||||||
property bool dankIslandReducedMotion: false
|
|
||||||
property real dankIslandSpringStiffness: 560
|
|
||||||
property real dankIslandSpringDamping: 37
|
|
||||||
property real dankIslandSpringMass: 1
|
|
||||||
|
|
||||||
property bool osdAlwaysShowValue: false
|
property bool osdAlwaysShowValue: false
|
||||||
property int osdPosition: SettingsData.Position.BottomCenter
|
property int osdPosition: SettingsData.Position.BottomCenter
|
||||||
@@ -1523,11 +1555,7 @@ Singleton {
|
|||||||
return;
|
return;
|
||||||
if (!SessionData.lastAppliedIconTheme)
|
if (!SessionData.lastAppliedIconTheme)
|
||||||
return;
|
return;
|
||||||
const script = `if command -v gsettings >/dev/null 2>&1; then
|
const script = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme");
|
||||||
gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g"
|
|
||||||
elif command -v dconf >/dev/null 2>&1; then
|
|
||||||
dconf read /org/gnome/desktop/interface/icon-theme 2>/dev/null | sed "s/'//g"
|
|
||||||
fi`;
|
|
||||||
|
|
||||||
Proc.runCommand("iconThemeDriftCheck", ["sh", "-c", script], (output, exitCode) => {
|
Proc.runCommand("iconThemeDriftCheck", ["sh", "-c", script], (output, exitCode) => {
|
||||||
const platform = (output || "").trim();
|
const platform = (output || "").trim();
|
||||||
@@ -1565,11 +1593,7 @@ Singleton {
|
|||||||
const resolved = resolveIconTheme();
|
const resolved = resolveIconTheme();
|
||||||
let cosmicThemeName = (resolved === "System Default") ? systemDefaultIconTheme : resolved;
|
let cosmicThemeName = (resolved === "System Default") ? systemDefaultIconTheme : resolved;
|
||||||
if (!cosmicThemeName || cosmicThemeName === "System Default") {
|
if (!cosmicThemeName || cosmicThemeName === "System Default") {
|
||||||
const detectScript = `if command -v gsettings >/dev/null 2>&1; then
|
const detectScript = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme");
|
||||||
gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g"
|
|
||||||
elif command -v dconf >/dev/null 2>&1; then
|
|
||||||
dconf read /org/gnome/desktop/interface/icon-theme 2>/dev/null | sed "s/'//g"
|
|
||||||
fi`;
|
|
||||||
|
|
||||||
Proc.runCommand("detectCosmicIconTheme", ["sh", "-c", detectScript], (output, exitCode) => {
|
Proc.runCommand("detectCosmicIconTheme", ["sh", "-c", detectScript], (output, exitCode) => {
|
||||||
if (exitCode !== 0)
|
if (exitCode !== 0)
|
||||||
@@ -1631,11 +1655,7 @@ Singleton {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if command -v gsettings >/dev/null 2>&1; then
|
${GSettings.setCmd("org.gnome.desktop.interface", "icon-theme", gtkThemeName)} || true
|
||||||
gsettings set org.gnome.desktop.interface icon-theme '${gtkThemeName}' 2>/dev/null || true
|
|
||||||
elif command -v dconf >/dev/null 2>&1; then
|
|
||||||
dconf write /org/gnome/desktop/interface/icon-theme "'${gtkThemeName}'" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
pkill -HUP -f 'gtk' 2>/dev/null || true`;
|
pkill -HUP -f 'gtk' 2>/dev/null || true`;
|
||||||
|
|
||||||
@@ -1738,13 +1758,21 @@ Singleton {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function set(key, value) {
|
function set(key, value) {
|
||||||
if (key === "dankIslandInteractionMode" && value !== "click" && value !== "hybrid")
|
|
||||||
value = "hybrid";
|
|
||||||
Spec.set(root, key, value, saveSettings, _hooks);
|
Spec.set(root, key, value, saveSettings, _hooks);
|
||||||
if (key === "dankIslandBarId" && value && frameEnabled)
|
if (key === "frameEnabled" && value)
|
||||||
set("frameEnabled", false);
|
clearIslandBars();
|
||||||
if (key === "frameEnabled" && value && dankIslandBarId)
|
}
|
||||||
set("dankIslandBarId", "");
|
|
||||||
|
// Frame mode hosts bars inside the frame surface, which has nowhere to put an island.
|
||||||
|
function clearIslandBars() {
|
||||||
|
const islands = islandBarConfigs;
|
||||||
|
if (islands.length === 0)
|
||||||
|
return;
|
||||||
|
const configs = JSON.parse(JSON.stringify(barConfigs));
|
||||||
|
for (const cfg of configs)
|
||||||
|
delete cfg.island;
|
||||||
|
barConfigs = configs;
|
||||||
|
updateBarConfigs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSettings() {
|
function loadSettings() {
|
||||||
@@ -1799,8 +1827,8 @@ Singleton {
|
|||||||
Store.parse(root, obj);
|
Store.parse(root, obj);
|
||||||
|
|
||||||
// set() enforces this pair, but a hand-edited settings.json bypasses set() entirely.
|
// set() enforces this pair, but a hand-edited settings.json bypasses set() entirely.
|
||||||
if (frameEnabled && dankIslandBarId)
|
if (frameEnabled && islandBarConfigs.length > 0)
|
||||||
dankIslandBarId = "";
|
clearIslandBars();
|
||||||
|
|
||||||
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
|
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
|
||||||
frameMode = "connected";
|
frameMode = "connected";
|
||||||
@@ -2131,7 +2159,7 @@ Singleton {
|
|||||||
const pathsArg = iconPaths.join(" ");
|
const pathsArg = iconPaths.join(" ");
|
||||||
|
|
||||||
const script = `
|
const script = `
|
||||||
echo "SYSDEFAULT:$(gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g" || echo '')"
|
echo "SYSDEFAULT:$(${GSettings.getCmd("org.gnome.desktop.interface", "icon-theme")})"
|
||||||
for dir in ${pathsArg}; do
|
for dir in ${pathsArg}; do
|
||||||
[ -d "$dir" ] || continue
|
[ -d "$dir" ] || continue
|
||||||
for theme in "$dir"/*/; do
|
for theme in "$dir"/*/; do
|
||||||
@@ -2170,7 +2198,7 @@ Singleton {
|
|||||||
const pathsArg = cursorPaths.join(" ");
|
const pathsArg = cursorPaths.join(" ");
|
||||||
|
|
||||||
const script = `
|
const script = `
|
||||||
echo "SYSDEFAULT:$(gsettings get org.gnome.desktop.interface cursor-theme 2>/dev/null | sed "s/'//g" || echo '')"
|
echo "SYSDEFAULT:$(${GSettings.getCmd("org.gnome.desktop.interface", "cursor-theme")})"
|
||||||
for dir in ${pathsArg}; do
|
for dir in ${pathsArg}; do
|
||||||
[ -d "$dir" ] || continue
|
[ -d "$dir" ] || continue
|
||||||
for theme in "$dir"/*/; do
|
for theme in "$dir"/*/; do
|
||||||
@@ -2329,12 +2357,10 @@ Singleton {
|
|||||||
|
|
||||||
// The island is not a bar, but it is chrome: popouts still have to clear its strip.
|
// The island is not a bar, but it is chrome: popouts still have to clear its strip.
|
||||||
if (!isIslandBarConfig(barConfig)) {
|
if (!isIslandBarConfig(barConfig)) {
|
||||||
const islandTop = dankIslandEdgeOffset(screen, "top");
|
topBar = Math.max(topBar, dankIslandEdgeOffset(screen, "top"));
|
||||||
const islandBottom = dankIslandEdgeOffset(screen, "bottom");
|
bottomBar = Math.max(bottomBar, dankIslandEdgeOffset(screen, "bottom"));
|
||||||
if (islandTop > 0)
|
leftBar = Math.max(leftBar, dankIslandEdgeOffset(screen, "left"));
|
||||||
topBar = Math.max(topBar, islandTop);
|
rightBar = Math.max(rightBar, dankIslandEdgeOffset(screen, "right"));
|
||||||
if (islandBottom > 0)
|
|
||||||
bottomBar = Math.max(bottomBar, islandBottom);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2470,21 +2496,20 @@ Singleton {
|
|||||||
return barConfigs.find(cfg => cfg.id === barId) || null;
|
return barConfigs.find(cfg => cfg.id === barId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setIslandBarId(barId) {
|
function setBarIsland(barId, on) {
|
||||||
const config = getBarConfig(barId);
|
const config = getBarConfig(barId);
|
||||||
if (!config)
|
if (!config || (config.island === true) === (on === true))
|
||||||
return;
|
return;
|
||||||
set("dankIslandBarId", barId);
|
const updates = {
|
||||||
const updates = {};
|
island: on === true
|
||||||
if (!config.enabled)
|
};
|
||||||
updates.enabled = true;
|
if (on === true) {
|
||||||
if ((config.screenPreferences ?? []).length === 0)
|
if (!config.enabled)
|
||||||
updates.screenPreferences = ["all"];
|
updates.enabled = true;
|
||||||
const position = config.position ?? SettingsData.Position.Top;
|
if ((config.screenPreferences ?? []).length === 0)
|
||||||
if (position !== SettingsData.Position.Top && position !== SettingsData.Position.Bottom)
|
updates.screenPreferences = ["all"];
|
||||||
updates.position = SettingsData.Position.Top;
|
}
|
||||||
if (Object.keys(updates).length > 0)
|
updateBarConfig(barId, updates);
|
||||||
updateBarConfig(barId, updates);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBarIpcRevealed(barId) {
|
function isBarIpcRevealed(barId) {
|
||||||
@@ -2494,7 +2519,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setBarIpcReveal(barId, revealed) {
|
function setBarIpcReveal(barId, revealed) {
|
||||||
if (!barId || barId === dankIslandBarId)
|
if (!barId || isIslandBarConfig(getBarConfig(barId)))
|
||||||
return;
|
return;
|
||||||
const nextRevealed = !!revealed;
|
const nextRevealed = !!revealed;
|
||||||
if (!!barIpcRevealStates[barId] === nextRevealed)
|
if (!!barIpcRevealStates[barId] === nextRevealed)
|
||||||
@@ -2528,8 +2553,6 @@ Singleton {
|
|||||||
const index = configs.findIndex(cfg => cfg.id === barId);
|
const index = configs.findIndex(cfg => cfg.id === barId);
|
||||||
if (index === -1)
|
if (index === -1)
|
||||||
return;
|
return;
|
||||||
if (isIslandBarConfig(configs[index]) && updates.position !== undefined && updates.position !== SettingsData.Position.Top && updates.position !== SettingsData.Position.Bottom)
|
|
||||||
delete updates.position;
|
|
||||||
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
|
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
|
||||||
if (updates.autoHide === false || updates.visible === false)
|
if (updates.autoHide === false || updates.visible === false)
|
||||||
setBarIpcReveal(barId, false);
|
setBarIpcReveal(barId, false);
|
||||||
@@ -2548,8 +2571,6 @@ Singleton {
|
|||||||
return;
|
return;
|
||||||
const configs = barConfigs.filter(cfg => cfg.id !== barId);
|
const configs = barConfigs.filter(cfg => cfg.id !== barId);
|
||||||
barConfigs = configs;
|
barConfigs = configs;
|
||||||
if (dankIslandBarId === barId)
|
|
||||||
dankIslandBarId = "";
|
|
||||||
if (connectedFrameBarStyleBackups?.[barId] !== undefined) {
|
if (connectedFrameBarStyleBackups?.[barId] !== undefined) {
|
||||||
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
|
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
|
||||||
delete nextBackups[barId];
|
delete nextBackups[barId];
|
||||||
@@ -2559,7 +2580,7 @@ Singleton {
|
|||||||
updateBarConfigs();
|
updateBarConfigs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bar-kind instances only. The island reserves its own edge via dankIslandOwnsEdge.
|
// Bar-kind instances only. Islands reserve their own edges via dankIslandOwnsEdge.
|
||||||
function getEnabledBarConfigs() {
|
function getEnabledBarConfigs() {
|
||||||
return barConfigs.filter(cfg => cfg.enabled && !isIslandBarConfig(cfg));
|
return barConfigs.filter(cfg => cfg.enabled && !isIslandBarConfig(cfg));
|
||||||
}
|
}
|
||||||
@@ -2728,34 +2749,45 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isIslandBarConfig(bc) {
|
function isIslandBarConfig(bc) {
|
||||||
return !!bc && !!dankIslandBarId && bc.id === dankIslandBarId;
|
return !!bc && bc.island === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIslandScreens() {
|
function activeIslandConfigsForScreen(screen) {
|
||||||
const cfg = islandBarConfig;
|
if (!screen)
|
||||||
if (!cfg || !(cfg.enabled ?? false))
|
|
||||||
return [];
|
return [];
|
||||||
return Quickshell.screens.filter(screen => barConfigCoversScreen(cfg, screen));
|
return islandBarConfigs.filter(cfg => (cfg.enabled ?? false) && barConfigCoversScreen(cfg, screen));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the first island claiming an edge renders there; the rest would stack on top of it.
|
||||||
|
function islandConfigForEdge(screen, edge) {
|
||||||
|
return activeIslandConfigsForScreen(screen).find(cfg => islandEdge(cfg) === edge) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dankIslandCoversScreen(screen) {
|
function dankIslandCoversScreen(screen) {
|
||||||
const cfg = islandBarConfig;
|
return activeIslandConfigsForScreen(screen).length > 0;
|
||||||
return !!screen && !!cfg && (cfg.enabled ?? false) && barConfigCoversScreen(cfg, screen);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dankIslandOwnsEdge(screen, edge) {
|
function dankIslandOwnsEdge(screen, edge) {
|
||||||
return dankIslandCoversScreen(screen) && dankIslandEdge === edge;
|
return islandConfigForEdge(screen, edge) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Painted strip thickness on `edge`, including when floating drops the exclusive zone.
|
// Painted strip thickness on `edge`, including when floating drops the exclusive zone.
|
||||||
function dankIslandEdgeOffset(screen, edge) {
|
function dankIslandEdgeOffset(screen, edge) {
|
||||||
return dankIslandOwnsEdge(screen, edge) ? dankIslandReservedStripHeight : 0;
|
const cfg = islandConfigForEdge(screen, edge);
|
||||||
|
return cfg ? islandStripThickness(cfg) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dankIslandIsSoleBarForScreen(screen) {
|
function dankIslandIsSoleBarForScreen(screen) {
|
||||||
return dankIslandCoversScreen(screen) && getActiveBarEdgesForScreen(screen).length === 0;
|
return dankIslandCoversScreen(screen) && getActiveBarEdgesForScreen(screen).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edges an island already holds on every screen this config covers, so a second island
|
||||||
|
// instance cannot be dropped on top of it.
|
||||||
|
function islandEdgeTakenFor(bc, screen, edge) {
|
||||||
|
const owner = islandConfigForEdge(screen, edge);
|
||||||
|
return !!owner && owner.id !== bc?.id;
|
||||||
|
}
|
||||||
|
|
||||||
function getActiveBarEdgesForScreen(screen) {
|
function getActiveBarEdgesForScreen(screen) {
|
||||||
return ["top", "bottom", "left", "right"].filter(edge => getActiveBarConfigsForEdge(screen, edge).length > 0);
|
return ["top", "bottom", "left", "right"].filter(edge => getActiveBarConfigsForEdge(screen, edge).length > 0);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-70
@@ -10,6 +10,7 @@ import qs.DankCommon.Common as DankCommon
|
|||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Modules.Greetd
|
import qs.Modules.Greetd
|
||||||
import "StockThemes.js" as StockThemes
|
import "StockThemes.js" as StockThemes
|
||||||
|
import "GSettings.js" as GSettings
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
@@ -144,10 +145,6 @@ Singleton {
|
|||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
Quickshell.execDetached(["mkdir", "-p", stateDir]);
|
Quickshell.execDetached(["mkdir", "-p", stateDir]);
|
||||||
// shellDir may be an embedded-UI extraction, which is read-only and
|
|
||||||
// unexecutable (dankgo shellapp/shellfs makeReadOnly chmods 0444)
|
|
||||||
if (typeof SessionData === "undefined" || !SessionData.isGreeterMode)
|
|
||||||
Quickshell.execDetached(["bash", shellDir + "/scripts/gtk.sh", configDir, "assets", "", shellDir]);
|
|
||||||
Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => {
|
Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => {
|
||||||
matugenAvailable = (code === 0) && !envDisableMatugen;
|
matugenAvailable = (code === 0) && !envDisableMatugen;
|
||||||
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
|
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
|
||||||
@@ -297,32 +294,7 @@ Singleton {
|
|||||||
if (currentTheme === "custom") {
|
if (currentTheme === "custom") {
|
||||||
return customThemeData || StockThemes.getThemeByName("purple", isLightMode);
|
return customThemeData || StockThemes.getThemeByName("purple", isLightMode);
|
||||||
} else if (currentTheme === dynamic) {
|
} else if (currentTheme === dynamic) {
|
||||||
return {
|
return buildExtractedDynamicMode(isLightMode ? "light" : "dark", "Dynamic");
|
||||||
"primary": getMatugenColor("primary", "#42a5f5"),
|
|
||||||
"primaryText": getMatugenColor("on_primary", "#ffffff"),
|
|
||||||
"primaryContainer": getMatugenColor("primary_container", "#1976d2"),
|
|
||||||
"secondary": getMatugenColor("secondary", "#8ab4f8"),
|
|
||||||
"secondaryContainer": getMatugenColor("secondary_container", getMatugenColor("surface_container_high", "#292b2f")),
|
|
||||||
"tertiary": getMatugenColor("tertiary", "#efb8c8"),
|
|
||||||
"tertiaryContainer": getMatugenColor("tertiary_container", getMatugenColor("surface_container_high", "#292b2f")),
|
|
||||||
"surface": getMatugenColor("surface", "#1a1c1e"),
|
|
||||||
"surfaceText": getMatugenColor("on_background", "#e3e8ef"),
|
|
||||||
"surfaceVariant": getMatugenColor("surface_variant", "#44464f"),
|
|
||||||
"surfaceVariantText": getMatugenColor("on_surface_variant", "#c4c7c5"),
|
|
||||||
"surfaceTint": getMatugenColor("surface_tint", "#8ab4f8"),
|
|
||||||
"background": getMatugenColor("background", "#1a1c1e"),
|
|
||||||
"backgroundText": getMatugenColor("on_background", "#e3e8ef"),
|
|
||||||
"outline": getMatugenColor("outline", "#8e918f"),
|
|
||||||
"surfaceContainerLowest": getMatugenColor("surface_container_lowest", "#0e1013"),
|
|
||||||
"surfaceContainerLow": getMatugenColor("surface_container_low", "#181a1d"),
|
|
||||||
"surfaceContainer": getMatugenColor("surface_container", "#1e2023"),
|
|
||||||
"surfaceContainerHigh": getMatugenColor("surface_container_high", "#292b2f"),
|
|
||||||
"surfaceContainerHighest": getMatugenColor("surface_container_highest", "#343740"),
|
|
||||||
"error": "#F2B8B5",
|
|
||||||
"warning": "#FF9800",
|
|
||||||
"info": "#2196F3",
|
|
||||||
"success": "#4CAF50"
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
return StockThemes.getThemeByName(currentTheme, isLightMode);
|
return StockThemes.getThemeByName(currentTheme, isLightMode);
|
||||||
}
|
}
|
||||||
@@ -1525,30 +1497,10 @@ Singleton {
|
|||||||
const dankBarScale = fontScale !== undefined ? fontScale : 1.0;
|
const dankBarScale = fontScale !== undefined ? fontScale : 1.0;
|
||||||
const maxScale = (maximizeText ?? false) ? 1.5 : 1.0;
|
const maxScale = (maximizeText ?? false) ? 1.5 : 1.0;
|
||||||
if (scale <= 0.75)
|
if (scale <= 0.75)
|
||||||
return evenLineBoxSize(Math.round(fontSizeSmall * 0.9 * dankBarScale * maxScale));
|
return Math.round(fontSizeSmall * 0.9 * dankBarScale * maxScale);
|
||||||
if (scale >= 1.25)
|
if (scale >= 1.25)
|
||||||
return evenLineBoxSize(Math.round(fontSizeMedium * dankBarScale * maxScale));
|
return Math.round(fontSizeMedium * dankBarScale * maxScale);
|
||||||
return evenLineBoxSize(Math.round(fontSizeSmall * dankBarScale * maxScale));
|
return Math.round(fontSizeSmall * dankBarScale * maxScale);
|
||||||
}
|
|
||||||
|
|
||||||
FontMetrics {
|
|
||||||
id: lineBoxProbe
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text.implicitHeight is ceil(FontMetrics.height); probe.font is written whole and read via boundingRect() so bindings capture no dependency on the probe
|
|
||||||
function lineBoxHeight(pixelSize) {
|
|
||||||
lineBoxProbe.font = Qt.font({
|
|
||||||
family: fontFamily,
|
|
||||||
weight: fontWeight,
|
|
||||||
pixelSize: pixelSize
|
|
||||||
});
|
|
||||||
return Math.ceil(lineBoxProbe.boundingRect("0").height);
|
|
||||||
}
|
|
||||||
|
|
||||||
function evenLineBoxSize(pixelSize) {
|
|
||||||
if (lineBoxHeight(pixelSize) % 2 === 0)
|
|
||||||
return pixelSize;
|
|
||||||
return lineBoxHeight(pixelSize + 1) % 2 === 0 ? pixelSize + 1 : pixelSize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBatteryIcon(level, isCharging, batteryAvailable) {
|
function getBatteryIcon(level, isCharging, batteryAvailable) {
|
||||||
@@ -1918,30 +1870,19 @@ Singleton {
|
|||||||
const theme = isLight ? "adw-gtk3" : "adw-gtk3-dark";
|
const theme = isLight ? "adw-gtk3" : "adw-gtk3-dark";
|
||||||
const schema = "org.gnome.desktop.interface";
|
const schema = "org.gnome.desktop.interface";
|
||||||
const key = "gtk-theme";
|
const key = "gtk-theme";
|
||||||
|
const reset = GSettings.setCmd(schema, key, "");
|
||||||
|
const apply = GSettings.setCmd(schema, key, theme);
|
||||||
|
|
||||||
const makeCmd = (tool, schema, val) => {
|
Proc.runCommand("gtkRefresher", ["sh", "-c", `${reset}; ${apply}`], (output, exitCode) => {
|
||||||
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) {
|
if (exitCode !== 0) {
|
||||||
Proc.runCommand("gtkRefreshFallback", ["sh", "-c", makeCmd("dconf", schema, theme)], (output, exitCode) => {
|
log.warn("Failed to refresh gtk-theme");
|
||||||
if (exitCode !== 0) {
|
|
||||||
log.warn("Failed to refresh gtk-theme");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchGtk3colors() {
|
function patchGtk3colors() {
|
||||||
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
|
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
|
||||||
Proc.runCommand("gtk3Patcher", ["bash", shellDir + "/scripts/gtk.sh", configDir, "patch", isLight, shellDir], (output, exitCode) => {
|
Proc.runCommand("gtk3Patcher", ["bash", shellDir + "/scripts/gtk.sh", configDir, "patch", isLight], (output, exitCode) => {
|
||||||
switch (exitCode) {
|
switch (exitCode) {
|
||||||
case 0:
|
case 0:
|
||||||
refreshGtkTheme();
|
refreshGtkTheme();
|
||||||
@@ -1963,7 +1904,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "true" : "false";
|
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, "apply", isLight], (output, exitCode) => {
|
||||||
if (exitCode === 0) {
|
if (exitCode === 0) {
|
||||||
if (typeof ToastService !== "undefined" && !root.matugenToastSuppressed) {
|
if (typeof ToastService !== "undefined" && !root.matugenToastSuppressed) {
|
||||||
ToastService.showInfo(I18n.tr("GTK colors applied successfully"));
|
ToastService.showInfo(I18n.tr("GTK colors applied successfully"));
|
||||||
|
|||||||
@@ -534,39 +534,6 @@ var SPEC = {
|
|||||||
notificationDndAllowCritical: { def: true },
|
notificationDndAllowCritical: { def: true },
|
||||||
notificationFocusedMonitor: { def: false },
|
notificationFocusedMonitor: { def: false },
|
||||||
|
|
||||||
dankIslandBarId: { def: "", onChange: "updateBarConfigs" },
|
|
||||||
dankIslandFloating: { def: false },
|
|
||||||
dankIslandUseOverlayLayer: { def: false },
|
|
||||||
dankIslandReserveHeight: { def: 40 },
|
|
||||||
dankIslandCompactHeight: { def: 38 },
|
|
||||||
dankIslandOuterGap: { def: 4 },
|
|
||||||
dankIslandHorizontalOffset: { def: 0 },
|
|
||||||
dankIslandInteractionMode: { def: "hybrid" },
|
|
||||||
dankIslandHoverOpenDelay: { def: 150 },
|
|
||||||
dankIslandHoverCloseDelay: { def: 150 },
|
|
||||||
dankIslandPalette: { def: "default" },
|
|
||||||
dankIslandTransparency: { def: 1 },
|
|
||||||
dankIslandCornerRadius: { def: 34 },
|
|
||||||
dankIslandHighContrast: { def: false },
|
|
||||||
dankIslandMediaClockVisible: { def: true },
|
|
||||||
dankIslandNotificationBadgeClearOnOpen: { def: false },
|
|
||||||
dankIslandNotificationExpand: { def: false },
|
|
||||||
dankIslandHomeCompactTight: { def: false },
|
|
||||||
dankIslandHomeVolumeDisplay: { def: "both" },
|
|
||||||
dankIslandHomeBrightnessDisplay: { def: "both" },
|
|
||||||
dankIslandHomeLayout: { def: [{ id: "media", enabled: true }, { id: "clock", enabled: true }, { id: "weather", enabled: false }, { id: "status", enabled: false }, { id: "volume", enabled: false }, { id: "brightness", enabled: false }, { id: "notifications", enabled: true }] },
|
|
||||||
dankIslandBatteryStyle: { def: "solid" },
|
|
||||||
dankIslandSatellitesEnabled: { def: true },
|
|
||||||
dankIslandSatellitePosition: { def: "edges" },
|
|
||||||
dankIslandSatelliteGap: { def: 12 },
|
|
||||||
dankIslandSatelliteBackground: { def: false },
|
|
||||||
dankIslandSatelliteGothCorners: { def: true },
|
|
||||||
dankIslandSatelliteTransparency: { def: 1 },
|
|
||||||
dankIslandSatelliteSwoopRadius: { def: 24 },
|
|
||||||
dankIslandReducedMotion: { def: false },
|
|
||||||
dankIslandSpringStiffness: { def: 560 },
|
|
||||||
dankIslandSpringDamping: { def: 37 },
|
|
||||||
dankIslandSpringMass: { def: 1 },
|
|
||||||
|
|
||||||
osdAlwaysShowValue: { def: false },
|
osdAlwaysShowValue: { def: false },
|
||||||
osdPosition: { def: 5 },
|
osdPosition: { def: 5 },
|
||||||
|
|||||||
@@ -14,6 +14,43 @@ var STALE_WIDGET_KEYS = ["desktopClockEnabled", "desktopClockStyle", "desktopClo
|
|||||||
|
|
||||||
var BAR_WIDGET_LIST_KEYS = ["leftWidgets", "centerWidgets", "rightWidgets"];
|
var BAR_WIDGET_LIST_KEYS = ["leftWidgets", "centerWidgets", "rightWidgets"];
|
||||||
|
|
||||||
|
// v18: the shell-wide island settings became per-bar-config island* keys
|
||||||
|
var ISLAND_KEY_MOVES = {
|
||||||
|
dankIslandFloating: "islandFloating",
|
||||||
|
dankIslandUseOverlayLayer: "islandUseOverlayLayer",
|
||||||
|
dankIslandReserveHeight: "islandReserveThickness",
|
||||||
|
dankIslandCompactHeight: "islandCompactThickness",
|
||||||
|
dankIslandOuterGap: "islandOuterGap",
|
||||||
|
dankIslandHorizontalOffset: "islandAlongOffset",
|
||||||
|
dankIslandInteractionMode: "islandInteractionMode",
|
||||||
|
dankIslandHoverOpenDelay: "islandHoverOpenDelay",
|
||||||
|
dankIslandHoverCloseDelay: "islandHoverCloseDelay",
|
||||||
|
dankIslandPalette: "islandPalette",
|
||||||
|
dankIslandTransparency: "islandTransparency",
|
||||||
|
dankIslandCornerRadius: "islandCornerRadius",
|
||||||
|
dankIslandHighContrast: "islandHighContrast",
|
||||||
|
dankIslandMediaClockVisible: "islandMediaClockVisible",
|
||||||
|
dankIslandNotificationBadgeClearOnOpen: "islandNotificationBadgeClearOnOpen",
|
||||||
|
dankIslandNotificationExpand: "islandNotificationExpand",
|
||||||
|
dankIslandHomeCompactTight: "islandHomeCompactTight",
|
||||||
|
dankIslandHomeClockDisplay: "islandHomeClockDisplay",
|
||||||
|
dankIslandHomeVolumeDisplay: "islandHomeVolumeDisplay",
|
||||||
|
dankIslandHomeBrightnessDisplay: "islandHomeBrightnessDisplay",
|
||||||
|
dankIslandHomeLayout: "islandHomeLayout",
|
||||||
|
dankIslandBatteryStyle: "islandBatteryStyle",
|
||||||
|
dankIslandSatellitesEnabled: "islandSatellitesEnabled",
|
||||||
|
dankIslandSatellitePosition: "islandSatellitePosition",
|
||||||
|
dankIslandSatelliteGap: "islandSatelliteGap",
|
||||||
|
dankIslandSatelliteBackground: "islandSatelliteBackground",
|
||||||
|
dankIslandSatelliteGothCorners: "islandSatelliteGothCorners",
|
||||||
|
dankIslandSatelliteTransparency: "islandSatelliteTransparency",
|
||||||
|
dankIslandSatelliteSwoopRadius: "islandSatelliteSwoopRadius",
|
||||||
|
dankIslandReducedMotion: "islandReducedMotion",
|
||||||
|
dankIslandSpringStiffness: "islandSpringStiffness",
|
||||||
|
dankIslandSpringDamping: "islandSpringDamping",
|
||||||
|
dankIslandSpringMass: "islandSpringMass"
|
||||||
|
};
|
||||||
|
|
||||||
function migrateBatteryPillStyle(target) {
|
function migrateBatteryPillStyle(target) {
|
||||||
if (!target || typeof target !== "object" || target.batteryPillStyle === undefined)
|
if (!target || typeof target !== "object" || target.batteryPillStyle === undefined)
|
||||||
return;
|
return;
|
||||||
@@ -433,5 +470,28 @@ function migrateToVersion(obj, targetVersion) {
|
|||||||
settings.configVersion = 17;
|
settings.configVersion = 17;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentVersion < 18) {
|
||||||
|
console.info("Migrating settings from version", currentVersion, "to version 18");
|
||||||
|
console.info("Moving the shell-wide Dank Island onto its bar config as a per-instance mode");
|
||||||
|
|
||||||
|
var islandId = settings.dankIslandBarId;
|
||||||
|
var islandBars = Array.isArray(settings.barConfigs) ? settings.barConfigs : [];
|
||||||
|
for (var ib = 0; ib < islandBars.length; ib++) {
|
||||||
|
if (!islandBars[ib] || islandBars[ib].id !== islandId)
|
||||||
|
continue;
|
||||||
|
islandBars[ib].island = true;
|
||||||
|
for (var oldKey in ISLAND_KEY_MOVES) {
|
||||||
|
if (!(oldKey in settings))
|
||||||
|
continue;
|
||||||
|
islandBars[ib][ISLAND_KEY_MOVES[oldKey]] = settings[oldKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var dropKey in ISLAND_KEY_MOVES)
|
||||||
|
delete settings[dropKey];
|
||||||
|
delete settings.dankIslandBarId;
|
||||||
|
|
||||||
|
settings.configVersion = 18;
|
||||||
|
}
|
||||||
|
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Item {
|
|||||||
|
|
||||||
property bool osdSurfacesLoaded: false
|
property bool osdSurfacesLoaded: false
|
||||||
property int pendingOsdResumeReloads: 0
|
property int pendingOsdResumeReloads: 0
|
||||||
readonly property var dankIslandScreens: SettingsData.getIslandScreens()
|
readonly property var dankIslandScreens: Quickshell.screens.filter(screen => SettingsData.dankIslandCoversScreen(screen))
|
||||||
readonly property var notificationPopupScreens: {
|
readonly property var notificationPopupScreens: {
|
||||||
const screens = SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications");
|
const screens = SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications");
|
||||||
return root.withoutDankIslandScreens(screens);
|
return root.withoutDankIslandScreens(screens);
|
||||||
|
|||||||
@@ -398,8 +398,8 @@ Item {
|
|||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
function getFocusedScreenName() {
|
function getFocusedScreenName() {
|
||||||
if (CompositorService.isHyprland && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor) {
|
if (CompositorService.isHyprland && Hyprland.focusedMonitor) {
|
||||||
return Hyprland.focusedWorkspace.monitor.name;
|
return Hyprland.focusedMonitor.name;
|
||||||
}
|
}
|
||||||
if (CompositorService.isNiri && NiriService.currentOutput) {
|
if (CompositorService.isNiri && NiriService.currentOutput) {
|
||||||
return NiriService.currentOutput;
|
return NiriService.currentOutput;
|
||||||
@@ -964,9 +964,6 @@ Item {
|
|||||||
const posValue = positionMap[position.toLowerCase()];
|
const posValue = positionMap[position.toLowerCase()];
|
||||||
if (posValue === undefined)
|
if (posValue === undefined)
|
||||||
return "BAR_INVALID_POSITION";
|
return "BAR_INVALID_POSITION";
|
||||||
// The island silhouette only anchors top or bottom.
|
|
||||||
if (SettingsData.isIslandBarConfig(barConfig) && posValue !== SettingsData.Position.Top && posValue !== SettingsData.Position.Bottom)
|
|
||||||
return "BAR_IS_ISLAND";
|
|
||||||
SettingsData.updateBarConfig(barConfig.id, {
|
SettingsData.updateBarConfig(barConfig.id, {
|
||||||
position: posValue
|
position: posValue
|
||||||
});
|
});
|
||||||
@@ -1866,6 +1863,8 @@ Item {
|
|||||||
function open(): string {
|
function open(): string {
|
||||||
root.workspaceRenameModalLoader.active = true;
|
root.workspaceRenameModalLoader.active = true;
|
||||||
if (root.workspaceRenameModalLoader.item) {
|
if (root.workspaceRenameModalLoader.item) {
|
||||||
|
if (CompositorService.isAqueous)
|
||||||
|
return root.workspaceRenameModalLoader.item.show("") ? "WORKSPACE_RENAME_MODAL_OPENED" : "WORKSPACE_RENAME_UNAVAILABLE";
|
||||||
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
|
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
|
||||||
root.workspaceRenameModalLoader.item.show(ws?.name || "");
|
root.workspaceRenameModalLoader.item.show(ws?.name || "");
|
||||||
return "WORKSPACE_RENAME_MODAL_OPENED";
|
return "WORKSPACE_RENAME_MODAL_OPENED";
|
||||||
@@ -1888,6 +1887,8 @@ Item {
|
|||||||
root.workspaceRenameModalLoader.item.hide();
|
root.workspaceRenameModalLoader.item.hide();
|
||||||
return "WORKSPACE_RENAME_MODAL_CLOSED";
|
return "WORKSPACE_RENAME_MODAL_CLOSED";
|
||||||
}
|
}
|
||||||
|
if (CompositorService.isAqueous)
|
||||||
|
return root.workspaceRenameModalLoader.item.show("") ? "WORKSPACE_RENAME_MODAL_OPENED" : "WORKSPACE_RENAME_UNAVAILABLE";
|
||||||
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
|
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
|
||||||
root.workspaceRenameModalLoader.item.show(ws?.name || "");
|
root.workspaceRenameModalLoader.item.show(ws?.name || "");
|
||||||
return "WORKSPACE_RENAME_MODAL_OPENED";
|
return "WORKSPACE_RENAME_MODAL_OPENED";
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Item {
|
|||||||
readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
|
readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
|
||||||
|
|
||||||
readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null
|
readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null
|
||||||
readonly property bool canEditEntry: !!entry && !(entry.isImage ?? false)
|
readonly property bool canEditEntry: ClipboardService.canEditEntry(entry)
|
||||||
readonly property bool hasTextAlternative: !!entry && (entry.altMimeType ?? "") !== ""
|
readonly property bool hasTextAlternative: !!entry && (entry.altMimeType ?? "") !== ""
|
||||||
readonly property string pinText: entry?.pinned || hasPinnedDuplicate ? I18n.tr("Unpin") : I18n.tr("Pin")
|
readonly property string pinText: entry?.pinned || hasPinnedDuplicate ? I18n.tr("Unpin") : I18n.tr("Pin")
|
||||||
readonly property string pinIcon: entry?.pinned || hasPinnedDuplicate ? "keep_off" : "push_pin"
|
readonly property string pinIcon: entry?.pinned || hasPinnedDuplicate ? "keep_off" : "push_pin"
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ Item {
|
|||||||
|
|
||||||
property var entry: null
|
property var entry: null
|
||||||
property string editorText: ""
|
property string editorText: ""
|
||||||
|
property bool textLoaded: false
|
||||||
|
property bool loadFailed: false
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: loadTimeoutTimer
|
||||||
|
interval: 5000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
if (!root.textLoaded) {
|
||||||
|
root.loadFailed = true;
|
||||||
|
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function releaseTextInputFocus() {
|
function releaseTextInputFocus() {
|
||||||
if (editField) {
|
if (editField) {
|
||||||
@@ -68,35 +82,22 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEntry(newEntry) {
|
function fetchEntry(requestedId) {
|
||||||
entry = newEntry;
|
loadFailed = false;
|
||||||
editorText = newEntry?.text ?? newEntry?.preview ?? "";
|
loadTimeoutTimer.restart();
|
||||||
if (editField) {
|
|
||||||
editField.text = editorText;
|
|
||||||
}
|
|
||||||
Qt.callLater(function () {
|
|
||||||
if (editField) {
|
|
||||||
editField.forceActiveFocus();
|
|
||||||
editField.cursorPosition = editField.text.length;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!newEntry || newEntry.isImage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedId = newEntry.id;
|
|
||||||
DMSService.sendRequest("clipboard.getEntry", {
|
DMSService.sendRequest("clipboard.getEntry", {
|
||||||
"id": requestedId
|
"id": requestedId
|
||||||
}, function (response) {
|
}, function (response) {
|
||||||
if (response.error) {
|
loadTimeoutTimer.stop();
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!root.entry || root.entry.id !== requestedId) {
|
if (!root.entry || root.entry.id !== requestedId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!response.result) {
|
if (response.error || !response.result) {
|
||||||
ClipboardService.refresh();
|
root.loadFailed = true;
|
||||||
|
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||||
|
if (!response.result) {
|
||||||
|
ClipboardService.refresh();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = response.result;
|
const result = response.result;
|
||||||
@@ -108,8 +109,12 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!fullText || fullText.length === 0) {
|
if (!fullText || fullText.length === 0) {
|
||||||
|
root.loadFailed = true;
|
||||||
|
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
root.loadFailed = false;
|
||||||
|
root.textLoaded = true;
|
||||||
root.editorText = fullText;
|
root.editorText = fullText;
|
||||||
if (editField) {
|
if (editField) {
|
||||||
if (fullText.length > 50000) {
|
if (fullText.length > 50000) {
|
||||||
@@ -127,15 +132,47 @@ Item {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setEntry(newEntry) {
|
||||||
|
entry = newEntry;
|
||||||
|
loadFailed = false;
|
||||||
|
const hasFullText = typeof newEntry?.text === "string";
|
||||||
|
textLoaded = !newEntry || !ClipboardService.canEditEntry(newEntry) || !(newEntry.id > 0) || hasFullText;
|
||||||
|
editorText = newEntry?.text ?? newEntry?.preview ?? "";
|
||||||
|
if (editField) {
|
||||||
|
editField.text = editorText;
|
||||||
|
}
|
||||||
|
Qt.callLater(function () {
|
||||||
|
if (editField) {
|
||||||
|
editField.forceActiveFocus();
|
||||||
|
editField.cursorPosition = editField.text.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasFullText || !newEntry || !ClipboardService.canEditEntry(newEntry) || !(newEntry.id > 0)) {
|
||||||
|
loadTimeoutTimer.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchEntry(newEntry.id);
|
||||||
|
}
|
||||||
|
|
||||||
function saveEntry(action) {
|
function saveEntry(action) {
|
||||||
const saveAction = action ?? "history";
|
const saveAction = action ?? "history";
|
||||||
DMSService.sendRequest("clipboard.copy", {
|
const entryId = root.entry?.id ?? 0;
|
||||||
"text": root.editorText
|
|
||||||
}, function (response) {
|
if (entryId > 0 && !root.textLoaded) {
|
||||||
if (response.error) {
|
if (root.loadFailed) {
|
||||||
ToastService.showError(I18n.tr("Failed to update clipboard"));
|
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||||
return;
|
root.fetchEntry(entryId);
|
||||||
|
} else {
|
||||||
|
ToastService.showWarning(I18n.tr("Loading full text, please wait...", "clipboard editor: save blocked while the entry's full text is still being fetched"));
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTimeoutTimer.stop();
|
||||||
|
|
||||||
|
const onComplete = function () {
|
||||||
if (saveAction === "history") {
|
if (saveAction === "history") {
|
||||||
modal.mode = "history";
|
modal.mode = "history";
|
||||||
Qt.callLater(function () {
|
Qt.callLater(function () {
|
||||||
@@ -154,6 +191,27 @@ Item {
|
|||||||
if (saveAction === "paste") {
|
if (saveAction === "paste") {
|
||||||
ClipboardService.pasteClipboard(modal.hide);
|
ClipboardService.pasteClipboard(modal.hide);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (entryId > 0) {
|
||||||
|
ClipboardService.editEntry(root.entry, root.editorText, function (response) {
|
||||||
|
if (response.error) {
|
||||||
|
ToastService.showError(I18n.tr("Failed to update clipboard"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onComplete();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DMSService.sendRequest("clipboard.copy", {
|
||||||
|
"text": root.editorText
|
||||||
|
}, function (response) {
|
||||||
|
if (response.error) {
|
||||||
|
ToastService.showError(I18n.tr("Failed to update clipboard"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onComplete();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +350,7 @@ Item {
|
|||||||
|
|
||||||
width: cancelButton.width
|
width: cancelButton.width
|
||||||
height: buttonHeight
|
height: buttonHeight
|
||||||
|
opacity: root.textLoaded ? 1 : 0.6
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|||||||
@@ -181,11 +181,44 @@ FocusScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editEntry(entry) {
|
function editEntry(entry) {
|
||||||
if (!entry || entry.isImage) {
|
if (!ClipboardService.canEditEntry(entry)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editorView.setEntry(entry);
|
|
||||||
mode = "editor";
|
const requestedId = entry.id;
|
||||||
|
if (!requestedId) {
|
||||||
|
editorView.setEntry(entry);
|
||||||
|
mode = "editor";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DMSService.sendRequest("clipboard.getEntry", {
|
||||||
|
"id": requestedId
|
||||||
|
}, function (response) {
|
||||||
|
if (response.error || !response.result) {
|
||||||
|
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||||
|
if (!response.result) {
|
||||||
|
ClipboardService.refresh();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = response.result;
|
||||||
|
let fullText = "";
|
||||||
|
if (result?.data) {
|
||||||
|
fullText = editorView.decodeEntryData(result.data);
|
||||||
|
} else {
|
||||||
|
fullText = result?.preview ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fullText && (entry.preview ?? "").length > 0) {
|
||||||
|
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryWithText = Object.assign({}, entry, { "text": fullText });
|
||||||
|
editorView.setEntry(entryWithText);
|
||||||
|
mode = "editor";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Item {
|
|||||||
|
|
||||||
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
||||||
|
|
||||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
|
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && effectiveModalLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
|
||||||
|
|
||||||
function _dockOccupiesSide(side) {
|
function _dockOccupiesSide(side) {
|
||||||
if (!SettingsData.showDock)
|
if (!SettingsData.showDock)
|
||||||
@@ -83,6 +83,12 @@ Item {
|
|||||||
property bool keepPopoutsOpen: false
|
property bool keepPopoutsOpen: false
|
||||||
property var customKeyboardFocus: null
|
property var customKeyboardFocus: null
|
||||||
property bool useOverlayLayer: false
|
property bool useOverlayLayer: false
|
||||||
|
readonly property var effectiveModalLayer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
|
||||||
|
"allow": ["top", "overlay"],
|
||||||
|
"invalidLayer": WlrLayer.Top,
|
||||||
|
"label": "modals",
|
||||||
|
"error": true
|
||||||
|
})
|
||||||
property real frozenMotionOffsetX: 0
|
property real frozenMotionOffsetX: 0
|
||||||
property real frozenMotionOffsetY: 0
|
property real frozenMotionOffsetY: 0
|
||||||
readonly property alias contentWindow: contentWindow
|
readonly property alias contentWindow: contentWindow
|
||||||
@@ -140,7 +146,7 @@ Item {
|
|||||||
"phase": phase,
|
"phase": phase,
|
||||||
"visible": presented,
|
"visible": presented,
|
||||||
"presented": presented,
|
"presented": presented,
|
||||||
"layer": root.useOverlayLayer ? "overlay" : "top",
|
"layer": root.effectiveModalLayer === WlrLayer.Overlay ? "overlay" : "top",
|
||||||
"barSide": resolvedConnectedBarSide,
|
"barSide": resolvedConnectedBarSide,
|
||||||
"bodyRect": bodyRect,
|
"bodyRect": bodyRect,
|
||||||
"animationOffset": animationOffset,
|
"animationOffset": animationOffset,
|
||||||
@@ -397,12 +403,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WlrLayershell.namespace: root.layerNamespace
|
WlrLayershell.namespace: root.layerNamespace
|
||||||
WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
|
WlrLayershell.layer: root.effectiveModalLayer
|
||||||
"allow": ["top", "overlay"],
|
|
||||||
"invalidLayer": WlrLayer.Top,
|
|
||||||
"label": "modals",
|
|
||||||
"error": true
|
|
||||||
})
|
|
||||||
WlrLayershell.exclusiveZone: -1
|
WlrLayershell.exclusiveZone: -1
|
||||||
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(shouldHaveFocus, customKeyboardFocus)
|
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(shouldHaveFocus, customKeyboardFocus)
|
||||||
|
|
||||||
|
|||||||
@@ -821,24 +821,15 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCategoryFiltered) {
|
if (isCategoryFiltered) {
|
||||||
var categoryPluginId = AppSearchService.getPluginIdForCategory(appCategory);
|
var rawApps = AppSearchService.getAppsInCategory(appCategory);
|
||||||
if (categoryPluginId) {
|
for (var i = 0; i < rawApps.length; i++) {
|
||||||
var pluginCategoryItems = getPluginItems(categoryPluginId, "");
|
allItems.push(getOrTransformApp(rawApps[i]));
|
||||||
for (var i = 0; i < pluginCategoryItems.length; i++) {
|
}
|
||||||
allItems.push(pluginCategoryItems[i]);
|
var allCoreApps = AppSearchService.getCoreApps("");
|
||||||
}
|
for (var i = 0; i < allCoreApps.length; i++) {
|
||||||
} else {
|
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
|
||||||
var rawApps = AppSearchService.getAppsInCategory(appCategory);
|
if (coreAppCats.indexOf(appCategory) !== -1)
|
||||||
for (var i = 0; i < rawApps.length; i++) {
|
allItems.push(transformCoreApp(allCoreApps[i]));
|
||||||
allItems.push(getOrTransformApp(rawApps[i]));
|
|
||||||
}
|
|
||||||
// Also include core apps (DMS Settings etc.) that match this category
|
|
||||||
var allCoreApps = AppSearchService.getCoreApps("");
|
|
||||||
for (var i = 0; i < allCoreApps.length; i++) {
|
|
||||||
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
|
|
||||||
if (coreAppCats.indexOf(appCategory) !== -1)
|
|
||||||
allItems.push(transformCoreApp(allCoreApps[i]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var apps = searchApps(searchQuery);
|
var apps = searchApps(searchQuery);
|
||||||
@@ -1688,18 +1679,60 @@ Item {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _diskCacheSectionDef(sectionId) {
|
||||||
|
for (var i = 0; i < sectionDefinitions.length; i++) {
|
||||||
|
if (sectionDefinitions[i].id === sectionId)
|
||||||
|
return sectionDefinitions[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _diskCacheSection(def, items) {
|
||||||
|
return {
|
||||||
|
id: def.id || "",
|
||||||
|
title: def.title || "",
|
||||||
|
icon: def.icon || "",
|
||||||
|
priority: def.priority || 0,
|
||||||
|
items: items,
|
||||||
|
collapsed: false,
|
||||||
|
flatStartIndex: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function _loadDiskCache() {
|
function _loadDiskCache() {
|
||||||
var cached = CacheData.loadLauncherCache();
|
var cached = CacheData.loadLauncherCache();
|
||||||
if (!cached || !Array.isArray(cached) || cached.length === 0)
|
if (!cached || !Array.isArray(cached) || cached.length === 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
var browseItems = getPluginBrowseItems();
|
||||||
|
var liveCoreItems = {};
|
||||||
|
var liveCoreApps = AppSearchService.getCoreApps("");
|
||||||
|
for (var c = 0; c < liveCoreApps.length; c++) {
|
||||||
|
var coreItem = transformCoreApp(liveCoreApps[c]);
|
||||||
|
liveCoreItems[coreItem.id] = coreItem;
|
||||||
|
}
|
||||||
|
|
||||||
var sectionsData = [];
|
var sectionsData = [];
|
||||||
|
var browseSectionSeen = false;
|
||||||
for (var i = 0; i < cached.length; i++) {
|
for (var i = 0; i < cached.length; i++) {
|
||||||
var s = cached[i];
|
var s = cached[i];
|
||||||
|
if (s.id === "browse_plugins") {
|
||||||
|
browseSectionSeen = true;
|
||||||
|
if (browseItems.length > 0)
|
||||||
|
sectionsData.push(_diskCacheSection(_diskCacheSectionDef(s.id) || s, browseItems));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
var items = [];
|
var items = [];
|
||||||
var srcItems = s.items || [];
|
var srcItems = s.items || [];
|
||||||
for (var j = 0; j < srcItems.length; j++) {
|
for (var j = 0; j < srcItems.length; j++) {
|
||||||
var it = srcItems[j];
|
var it = srcItems[j];
|
||||||
|
if (it.isCore) {
|
||||||
|
if (liveCoreItems[it.id])
|
||||||
|
items.push(liveCoreItems[it.id]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (it.type === "plugin_browse" || it.isBuiltInLauncher || it.pluginId)
|
||||||
|
continue;
|
||||||
items.push({
|
items.push({
|
||||||
id: it.id || "",
|
id: it.id || "",
|
||||||
type: it.type || "app",
|
type: it.type || "app",
|
||||||
@@ -1709,15 +1742,12 @@ Item {
|
|||||||
iconType: it.iconType || "image",
|
iconType: it.iconType || "image",
|
||||||
iconFull: it.iconFull || "",
|
iconFull: it.iconFull || "",
|
||||||
section: it.section || "",
|
section: it.section || "",
|
||||||
isCore: it.isCore || false,
|
|
||||||
isBuiltInLauncher: it.isBuiltInLauncher || false,
|
|
||||||
pluginId: it.pluginId || "",
|
|
||||||
source: it.source || "",
|
source: it.source || "",
|
||||||
data: {
|
data: {
|
||||||
id: it.id
|
id: it.id
|
||||||
},
|
},
|
||||||
actions: _actionsFromDesktopEntry(it.id),
|
actions: _actionsFromDesktopEntry(it.id),
|
||||||
primaryAction: it.type === "app" && !it.isCore ? {
|
primaryAction: it.type === "app" ? {
|
||||||
name: I18n.tr("Launch"),
|
name: I18n.tr("Launch"),
|
||||||
icon: "open_in_new",
|
icon: "open_in_new",
|
||||||
action: "launch"
|
action: "launch"
|
||||||
@@ -1729,16 +1759,22 @@ Item {
|
|||||||
_preScored: undefined
|
_preScored: undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
sectionsData.push({
|
if (items.length > 0)
|
||||||
id: s.id || "",
|
sectionsData.push(_diskCacheSection(s, items));
|
||||||
title: s.title || "",
|
|
||||||
icon: s.icon || "",
|
|
||||||
priority: s.priority || 0,
|
|
||||||
items: items,
|
|
||||||
collapsed: false,
|
|
||||||
flatStartIndex: 0
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!browseSectionSeen && browseItems.length > 0) {
|
||||||
|
var browseDef = _diskCacheSectionDef("browse_plugins");
|
||||||
|
if (browseDef)
|
||||||
|
sectionsData.push(_diskCacheSection(browseDef, browseItems));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionsData.length === 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
sectionsData.sort(function (a, b) {
|
||||||
|
return a.priority - b.priority;
|
||||||
|
});
|
||||||
return sectionsData;
|
return sectionsData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ Item {
|
|||||||
|
|
||||||
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
||||||
|
|
||||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
|
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && effectiveLauncherLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
|
||||||
readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
|
readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
|
||||||
|
|
||||||
function _dockOccupiesSide(side) {
|
function _dockOccupiesSide(side) {
|
||||||
@@ -246,7 +246,7 @@ Item {
|
|||||||
"phase": phase,
|
"phase": phase,
|
||||||
"visible": presented,
|
"visible": presented,
|
||||||
"presented": presented,
|
"presented": presented,
|
||||||
"layer": root.usesOverlayLayer ? "overlay" : "top",
|
"layer": root.effectiveLauncherLayer === WlrLayer.Overlay ? "overlay" : "top",
|
||||||
"barSide": resolvedConnectedBarSide,
|
"barSide": resolvedConnectedBarSide,
|
||||||
"bodyRect": bodyRect,
|
"bodyRect": bodyRect,
|
||||||
"animationOffset": animationOffset,
|
"animationOffset": animationOffset,
|
||||||
|
|||||||
@@ -47,23 +47,20 @@ Rectangle {
|
|||||||
Row {
|
Row {
|
||||||
id: leftContent
|
id: leftContent
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: Theme.spacingXS
|
anchors.leftMargin: hasAppCategories ? 0 : Theme.spacingXS
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: Theme.spacingS
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
// Whether the apps category picker should replace the plain title
|
|
||||||
readonly property bool hasAppCategories: root.section?.id === "apps" && (root.controller?.appCategories?.length ?? 0) > 0
|
readonly property bool hasAppCategories: root.section?.id === "apps" && (root.controller?.appCategories?.length ?? 0) > 0
|
||||||
|
|
||||||
DankIcon {
|
DankIcon {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
// Hide section icon when the category chip already shows one
|
|
||||||
visible: !leftContent.hasAppCategories
|
visible: !leftContent.hasAppCategories
|
||||||
name: root.section?.icon ?? "folder"
|
name: root.section?.icon ?? "folder"
|
||||||
size: 16
|
size: 16
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain title — hidden when the category chip is shown
|
|
||||||
StyledText {
|
StyledText {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
visible: !leftContent.hasAppCategories
|
visible: !leftContent.hasAppCategories
|
||||||
@@ -73,12 +70,10 @@ Rectangle {
|
|||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact inline category chip — only visible on the apps section
|
|
||||||
Item {
|
Item {
|
||||||
id: categoryChip
|
id: categoryChip
|
||||||
visible: leftContent.hasAppCategories
|
visible: leftContent.hasAppCategories
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
// Size to content with a fixed-min width so it doesn't jump around
|
|
||||||
width: chipRow.implicitWidth + Theme.spacingM * 2
|
width: chipRow.implicitWidth + Theme.spacingM * 2
|
||||||
height: 24
|
height: 24
|
||||||
|
|
||||||
@@ -237,7 +232,6 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size to list content, cap at 10 visible items
|
|
||||||
height: Math.min((root.controller?.appCategories?.length ?? 0) * 34, 10 * 34) + Theme.spacingS * 2 + 4
|
height: Math.min((root.controller?.appCategories?.length ?? 0) * 34, 10 * 34) + Theme.spacingS * 2 + 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ Item {
|
|||||||
if (modelData.repo)
|
if (modelData.repo)
|
||||||
Qt.openUrlExternally(modelData.repo);
|
Qt.openUrlExternally(modelData.repo);
|
||||||
}
|
}
|
||||||
onInstallRequested: PluginService.installFromRegistry(modelData.name, modelData.type === "desktop")
|
onInstallRequested: PluginService.installFromRegistry(modelData.id, modelData.name, modelData.type === "desktop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,14 @@ DankModal {
|
|||||||
|
|
||||||
const topChrome = Math.max(bar && barPosition === SettingsData.Position.Top ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "top"));
|
const topChrome = Math.max(bar && barPosition === SettingsData.Position.Top ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "top"));
|
||||||
const bottomChrome = Math.max(bar && barPosition === SettingsData.Position.Bottom ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "bottom"));
|
const bottomChrome = Math.max(bar && barPosition === SettingsData.Position.Bottom ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "bottom"));
|
||||||
|
const leftChrome = Math.max(bar && barPosition === SettingsData.Position.Left ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "left"));
|
||||||
|
const rightChrome = Math.max(bar && barPosition === SettingsData.Position.Right ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "right"));
|
||||||
const minY = topChrome + margin;
|
const minY = topChrome + margin;
|
||||||
const maxY = screenH - modalHeight - bottomChrome - margin;
|
const maxY = screenH - modalHeight - bottomChrome - margin;
|
||||||
|
const minX = leftChrome + margin;
|
||||||
|
const maxX = screenW - modalWidth - rightChrome - margin;
|
||||||
|
|
||||||
|
targetX = Math.max(minX, Math.min(maxX, targetX));
|
||||||
targetY = Math.max(minY, Math.min(maxY, targetY));
|
targetY = Math.max(minY, Math.min(maxY, targetY));
|
||||||
|
|
||||||
return Qt.point(targetX, targetY);
|
return Qt.point(targetX, targetY);
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ Rectangle {
|
|||||||
root.leaveHiddenTab(33);
|
root.leaveHiddenTab(33);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDankIslandBarIdChanged() {
|
function onIslandBarConfigsChanged() {
|
||||||
if (!SettingsData.dankIslandBarId)
|
if (SettingsData.islandBarConfigs.length === 0)
|
||||||
root.leaveHiddenTab(46);
|
root.leaveHiddenTab(46);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ Rectangle {
|
|||||||
return false;
|
return false;
|
||||||
if (item.frameOnly && !SettingsData.frameEnabled)
|
if (item.frameOnly && !SettingsData.frameEnabled)
|
||||||
return false;
|
return false;
|
||||||
if (item.islandOnly && !SettingsData.dankIslandBarId)
|
if (item.islandOnly && SettingsData.islandBarConfigs.length === 0)
|
||||||
return false;
|
return false;
|
||||||
if (item.cellularOnly && (NetworkService.cellularDevices?.length ?? 0) === 0)
|
if (item.cellularOnly && (NetworkService.cellularDevices?.length ?? 0) === 0)
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ DankFloatingWindow {
|
|||||||
readonly property var log: Log.scoped("WorkspaceRenameModal")
|
readonly property var log: Log.scoped("WorkspaceRenameModal")
|
||||||
|
|
||||||
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
|
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
|
||||||
|
property var aqueousWorkspace: null
|
||||||
|
property bool renaming: false
|
||||||
|
|
||||||
objectName: "workspaceRenameModal"
|
objectName: "workspaceRenameModal"
|
||||||
title: I18n.tr("Rename Workspace")
|
title: I18n.tr("Rename Workspace")
|
||||||
@@ -19,9 +21,24 @@ DankFloatingWindow {
|
|||||||
onClosed: hide()
|
onClosed: hide()
|
||||||
|
|
||||||
function show(name) {
|
function show(name) {
|
||||||
|
if (CompositorService.isAqueous) {
|
||||||
|
if (renaming)
|
||||||
|
return false;
|
||||||
|
const workspace = AqueousService.workspaces.find(ws => ws.output === AqueousService.seat?.output && ws.active);
|
||||||
|
if (!AqueousService.available || !AqueousService.seat || !workspace || !AqueousService.capabilities.commands || AqueousService.locked) {
|
||||||
|
ToastService.showError(I18n.tr("Error"), I18n.tr("Unavailable"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
aqueousWorkspace = {
|
||||||
|
id: workspace.id,
|
||||||
|
session: workspace.aqueousSession
|
||||||
|
};
|
||||||
|
name = workspace.name;
|
||||||
|
}
|
||||||
nameInput.text = name;
|
nameInput.text = name;
|
||||||
visible = true;
|
visible = true;
|
||||||
Qt.callLater(() => nameInput.forceActiveFocus());
|
Qt.callLater(() => nameInput.forceActiveFocus());
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hide() {
|
function hide() {
|
||||||
@@ -29,6 +46,24 @@ DankFloatingWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function submitAndClose() {
|
function submitAndClose() {
|
||||||
|
if (aqueousWorkspace || CompositorService.isAqueous) {
|
||||||
|
if (!aqueousWorkspace || renaming)
|
||||||
|
return;
|
||||||
|
const target = aqueousWorkspace;
|
||||||
|
renaming = true;
|
||||||
|
AqueousService.command("workspace.rename", {
|
||||||
|
id: target.id,
|
||||||
|
session: target.session,
|
||||||
|
name: nameInput.text
|
||||||
|
}, success => {
|
||||||
|
if (root.aqueousWorkspace !== target)
|
||||||
|
return;
|
||||||
|
root.renaming = false;
|
||||||
|
if (success)
|
||||||
|
root.hide();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
renameWorkspace(nameInput.text);
|
renameWorkspace(nameInput.text);
|
||||||
hide();
|
hide();
|
||||||
}
|
}
|
||||||
@@ -49,6 +84,8 @@ DankFloatingWindow {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nameInput.text = "";
|
nameInput.text = "";
|
||||||
|
aqueousWorkspace = null;
|
||||||
|
renaming = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
FocusScope {
|
FocusScope {
|
||||||
@@ -133,7 +170,7 @@ DankFloatingWindow {
|
|||||||
textColor: Theme.surfaceText
|
textColor: Theme.surfaceText
|
||||||
placeholderText: I18n.tr("Workspace name")
|
placeholderText: I18n.tr("Workspace name")
|
||||||
backgroundColor: "transparent"
|
backgroundColor: "transparent"
|
||||||
enabled: root.visible
|
enabled: root.visible && !root.renaming
|
||||||
onAccepted: submitAndClose()
|
onAccepted: submitAndClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +227,7 @@ DankFloatingWindow {
|
|||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: renameArea
|
id: renameArea
|
||||||
|
enabled: !root.renaming
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ PluginComponent {
|
|||||||
TailscaleService.connectTailscale(null);
|
TailscaleService.connectTailscale(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCcWidgetExpanded: {
|
||||||
|
TailscaleService.refresh(null);
|
||||||
|
}
|
||||||
|
|
||||||
ccDetailContent: Component {
|
ccDetailContent: Component {
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: detailRoot
|
id: detailRoot
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user