mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-22 11:14:01 -04:00
Compare commits
48
Commits
stable-2.0
...
v1.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,7 +531,7 @@ jobs:
|
||||
repo:
|
||||
- dgop
|
||||
- dankgo
|
||||
- dank-qml-common
|
||||
# - dank-qml-common
|
||||
- dankcalendar
|
||||
- danksearch
|
||||
- dank-greeter
|
||||
|
||||
@@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
|
||||
ASSETS_DIR=assets
|
||||
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
|
||||
|
||||
@@ -53,14 +53,6 @@ install-bin:
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
@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:
|
||||
@echo "Installing shell completions..."
|
||||
@mkdir -p $(DATA_DIR)/bash-completion/completions
|
||||
@@ -98,7 +90,7 @@ install-desktop:
|
||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||
@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 "Installation complete!"
|
||||
@echo ""
|
||||
@@ -111,7 +103,7 @@ uninstall-bin:
|
||||
@echo "Binary removed"
|
||||
|
||||
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)
|
||||
@echo "Shell files removed"
|
||||
|
||||
@@ -159,7 +151,6 @@ help:
|
||||
@echo "Install:"
|
||||
@echo " install - Build and install everything (requires sudo)"
|
||||
@echo " install-bin - Install only the binary"
|
||||
@echo " install-shell - Install only shell files"
|
||||
@echo " install-completions - Install only shell completions"
|
||||
@echo " install-systemd - Install only systemd service"
|
||||
@echo " install-icon - Install only icon"
|
||||
@@ -168,7 +159,7 @@ help:
|
||||
@echo "Uninstall:"
|
||||
@echo " uninstall - Remove everything (requires sudo)"
|
||||
@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-systemd - Remove only systemd service"
|
||||
@echo " uninstall-icon - Remove only icon"
|
||||
|
||||
@@ -9,7 +9,7 @@ Type=dbus
|
||||
BusName=org.freedesktop.Notifications
|
||||
ExecStart=/usr/bin/dms run --session
|
||||
ExecReload=/bin/kill -USR1 $MAINPID
|
||||
LimitNOFILE=16384
|
||||
LimitNOFILE=16384:infinity
|
||||
Restart=on-failure
|
||||
RestartForceExitStatus=TEMPFAIL
|
||||
SuccessExitStatus=TEMPFAIL
|
||||
|
||||
@@ -11,10 +11,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
colorOutputFmt string
|
||||
colorAutocopy bool
|
||||
colorNotify bool
|
||||
colorLowercase bool
|
||||
colorOutputFmt string
|
||||
colorAutocopy bool
|
||||
colorNotify bool
|
||||
colorLowercase bool
|
||||
colorAllowMulti bool
|
||||
)
|
||||
|
||||
var colorCmd = &cobra.Command{
|
||||
@@ -54,7 +55,8 @@ Examples:
|
||||
dms color pick --rgb # Output as RGB
|
||||
dms color pick --json # Output all formats as JSON
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -69,6 +71,7 @@ func init() {
|
||||
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(&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")
|
||||
|
||||
@@ -90,11 +93,12 @@ func runColorPick(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
config := colorpicker.Config{
|
||||
Format: format,
|
||||
CustomFormat: colorOutputFmt,
|
||||
Lowercase: colorLowercase,
|
||||
Autocopy: colorAutocopy,
|
||||
Notify: colorNotify,
|
||||
Format: format,
|
||||
CustomFormat: colorOutputFmt,
|
||||
Lowercase: colorLowercase,
|
||||
Autocopy: colorAutocopy,
|
||||
Notify: colorNotify,
|
||||
AllowMultiple: colorAllowMulti,
|
||||
}
|
||||
|
||||
// One-shot process holding a few screen-sized buffers: skip GC cycles while it runs.
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -46,7 +47,7 @@ var keybindsSetCmd = &cobra.Command{
|
||||
Short: "Set a keybind override",
|
||||
Long: "Create or update a keybind override for the specified provider",
|
||||
Args: cobra.ExactArgs(3),
|
||||
Run: runKeybindsSet,
|
||||
Run: runKeybindsEdit,
|
||||
}
|
||||
|
||||
var keybindsRemoveCmd = &cobra.Command{
|
||||
@@ -54,7 +55,7 @@ var keybindsRemoveCmd = &cobra.Command{
|
||||
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.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: runKeybindsRemove,
|
||||
Run: runKeybindsEdit,
|
||||
}
|
||||
|
||||
var keybindsResetCmd = &cobra.Command{
|
||||
@@ -62,10 +63,14 @@ var keybindsResetCmd = &cobra.Command{
|
||||
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.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: runKeybindsReset,
|
||||
Run: runKeybindsEdit,
|
||||
}
|
||||
|
||||
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")
|
||||
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
|
||||
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
|
||||
@@ -93,6 +98,9 @@ func initializeProviders() {
|
||||
registry := keybinds.GetDefaultRegistry()
|
||||
|
||||
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 {
|
||||
log.Warnf("Failed to register Hyprland provider: %v", err)
|
||||
}
|
||||
@@ -206,85 +214,92 @@ func runKeybindsShow(cmd *cobra.Command, args []string) {
|
||||
printCheatSheet(provider)
|
||||
}
|
||||
|
||||
func getWritableProvider(name string) keybinds.WritableProvider {
|
||||
provider, err := keybinds.GetDefaultRegistry().Get(name)
|
||||
func keybindEditFailure(err error) map[string]any {
|
||||
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 {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
func runKeybindsSet(cmd *cobra.Command, args []string) {
|
||||
providerName, key, action := args[0], args[1], args[2]
|
||||
writable := getWritableProvider(providerName)
|
||||
|
||||
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
|
||||
_ = writable.RemoveBind(replaceKey)
|
||||
}
|
||||
|
||||
options := make(map[string]any)
|
||||
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
|
||||
options["allow-when-locked"] = true
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
|
||||
options["cooldown-ms"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
||||
options["repeat"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
|
||||
options["allow-inhibiting"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
||||
options["flags"] = v
|
||||
}
|
||||
|
||||
desc, _ := cmd.Flags().GetString("desc")
|
||||
if err := writable.SetBind(key, action, desc, options); err != nil {
|
||||
log.Fatalf("Error setting keybind: %v", err)
|
||||
}
|
||||
|
||||
output, _ := json.MarshalIndent(map[string]any{
|
||||
"success": true,
|
||||
"key": key,
|
||||
"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))
|
||||
result := map[string]any{"success": true, "key": key}
|
||||
switch cmd.Name() {
|
||||
case "set":
|
||||
options := make(map[string]any)
|
||||
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
|
||||
options["allow-when-locked"] = true
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
|
||||
options["cooldown-ms"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
||||
options["repeat"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
|
||||
options["allow-inhibiting"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
||||
options["flags"] = v
|
||||
}
|
||||
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
|
||||
_ = writable.RemoveBind(replaceKey)
|
||||
}
|
||||
desc, _ := cmd.Flags().GetString("desc")
|
||||
err = writable.SetBind(key, args[2], desc, options)
|
||||
result["action"] = args[2]
|
||||
result["path"] = writable.GetOverridePath()
|
||||
case "remove":
|
||||
err = writable.RemoveBind(key)
|
||||
result["removed"] = true
|
||||
case "reset":
|
||||
err = writable.ResetBind(key)
|
||||
result["reset"] = true
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported keybind operation: %s", cmd.Name())
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
ssOutputName string
|
||||
ssSeat string
|
||||
ssCursor string
|
||||
ssFormat string
|
||||
ssQuality int
|
||||
@@ -33,6 +34,7 @@ var (
|
||||
ssStdout bool
|
||||
ssJSON bool
|
||||
ssGeometry bool
|
||||
ssAllowMulti bool
|
||||
)
|
||||
|
||||
type screenshotMetadata struct {
|
||||
@@ -57,7 +59,7 @@ Modes:
|
||||
full - Capture the focused output
|
||||
all - Capture all outputs combined
|
||||
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
|
||||
scroll - Select a region, then scroll to capture a stitched tall image
|
||||
|
||||
@@ -71,7 +73,7 @@ Examples:
|
||||
dms screenshot full # Full screen of focused output
|
||||
dms screenshot all # All screens combined
|
||||
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 --reset # Reset last region pre-selection
|
||||
dms screenshot --no-clipboard # Save file only
|
||||
@@ -80,6 +82,7 @@ Examples:
|
||||
dms screenshot --cursor=on # Include cursor
|
||||
dms screenshot -f jpg -q 85 # JPEG with quality 85
|
||||
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 scroll # Scroll capture, Enter finishes / Esc cancels
|
||||
dms screenshot scroll --interval 250`,
|
||||
@@ -120,7 +123,7 @@ If no previous region exists, falls back to interactive selection.`,
|
||||
var ssWindowCmd = &cobra.Command{
|
||||
Use: "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,
|
||||
}
|
||||
|
||||
@@ -163,6 +166,7 @@ var notifyActionCmd = &cobra.Command{
|
||||
|
||||
func init() {
|
||||
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().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
|
||||
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
|
||||
@@ -176,6 +180,7 @@ func init() {
|
||||
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().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")
|
||||
|
||||
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
|
||||
|
||||
@@ -195,6 +200,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
config := screenshot.DefaultConfig()
|
||||
config.Mode = mode
|
||||
config.OutputName = ssOutputName
|
||||
config.Seat = ssSeat
|
||||
if strings.EqualFold(ssCursor, "on") {
|
||||
config.Cursor = screenshot.CursorOn
|
||||
}
|
||||
@@ -205,6 +211,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
config.Reset = ssReset
|
||||
config.Stdout = ssStdout
|
||||
config.Geometry = ssGeometry
|
||||
config.AllowMultiple = ssAllowMulti
|
||||
|
||||
if ssGeometry {
|
||||
config.Clipboard = false
|
||||
|
||||
@@ -58,7 +58,7 @@ func init() {
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates")
|
||||
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
|
||||
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
||||
"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
|
||||
@@ -96,10 +97,7 @@ func tryServerRequest(req models.Request) (*models.Response[any], bool) {
|
||||
}
|
||||
|
||||
func getServerSocketPath() string {
|
||||
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if runtimeDir == "" {
|
||||
runtimeDir = os.TempDir()
|
||||
}
|
||||
runtimeDir := utils.RuntimeDir()
|
||||
|
||||
if sessionSock, ok := shellApp.SessionSocketPath(); ok {
|
||||
return sessionSock
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ 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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e h1:AUCBM5EF+hxCG55uJl30waRQHhR5+Wij9u8AfoTkQvs=
|
||||
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 h1:A2IuXIzhTQwvBZiS8pxU5RmWSszMd16Oh11gUlJ9OeU=
|
||||
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 v0.2.4-0.20260819141338-085d828cf577/go.mod h1:2kt1u9YgHwi2npqB5ago8kwZdFrHlPGXv3PM/vwRHfY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
|
||||
@@ -2,28 +2,19 @@ package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
|
||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
xkbKeymapFormatV1 = 1
|
||||
|
||||
keyStateReleased = 0
|
||||
keyStatePressed = 1
|
||||
|
||||
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
|
||||
shiftModMask = 1 << 0
|
||||
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
|
||||
@@ -50,25 +41,25 @@ func SendPasteKeystroke(withShift bool) (err error) {
|
||||
}
|
||||
defer keyboard.Release()
|
||||
|
||||
var keymap *wlclient.KeyboardKeymapEvent
|
||||
var seatKeymap *wlclient.KeyboardKeymapEvent
|
||||
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||
if keymap == nil {
|
||||
keymap = &e
|
||||
if seatKeymap == nil {
|
||||
seatKeymap = &e
|
||||
}
|
||||
})
|
||||
|
||||
s.display.Roundtrip()
|
||||
|
||||
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
|
||||
if seatKeymap == nil || seatKeymap.Format != keymap.FormatXkbV1 {
|
||||
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 {
|
||||
return fmt.Errorf("read keymap: %w", err)
|
||||
}
|
||||
keys := resolveKeycodes(keymapText)
|
||||
keys := keymap.Parse(keymapText)
|
||||
|
||||
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
|
||||
if err != nil {
|
||||
@@ -76,15 +67,15 @@ func SendPasteKeystroke(withShift bool) (err error) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
mods := uint32(ctrlModMask)
|
||||
held := []uint32{keys.ctrl}
|
||||
held := []uint32{keys.Keycode("Control_L")}
|
||||
if withShift {
|
||||
mods |= shiftModMask
|
||||
held = append(held, keys.shift)
|
||||
held = append(held, keys.Keycode("Shift_L"))
|
||||
}
|
||||
|
||||
t := uint32(0)
|
||||
@@ -121,93 +112,8 @@ func SendPasteKeystroke(withShift bool) (err error) {
|
||||
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
|
||||
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 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"
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
|
||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
@@ -32,20 +33,20 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
|
||||
}
|
||||
defer keyboard.Release()
|
||||
|
||||
var keymap *wlclient.KeyboardKeymapEvent
|
||||
var seatKeymap *wlclient.KeyboardKeymapEvent
|
||||
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||
if keymap == nil {
|
||||
keymap = &e
|
||||
if seatKeymap == nil {
|
||||
seatKeymap = &e
|
||||
}
|
||||
})
|
||||
s.display.Roundtrip()
|
||||
|
||||
if keymap == nil {
|
||||
if seatKeymap == nil {
|
||||
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 {
|
||||
t.Fatalf("read keymap: %v", err)
|
||||
}
|
||||
@@ -56,10 +57,7 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
keys := resolveKeycodes(text)
|
||||
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.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")
|
||||
}
|
||||
keys := keymap.Parse(text)
|
||||
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"))
|
||||
}
|
||||
|
||||
@@ -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/wp_color_management"
|
||||
"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"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Format OutputFormat
|
||||
CustomFormat string
|
||||
Lowercase bool
|
||||
Autocopy bool
|
||||
Notify bool
|
||||
Format OutputFormat
|
||||
CustomFormat string
|
||||
Lowercase bool
|
||||
Autocopy bool
|
||||
Notify bool
|
||||
AllowMultiple bool
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
@@ -62,6 +65,7 @@ type Picker struct {
|
||||
seat *client.Seat
|
||||
pointer *client.Pointer
|
||||
keyboard *client.Keyboard
|
||||
keymap *keymap.Keymap
|
||||
layerShell *wlr_layer_shell.ZwlrLayerShellV1
|
||||
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
|
||||
viewporter *wp_viewporter.WpViewporter
|
||||
@@ -91,6 +95,14 @@ func New(config Config) *Picker {
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
@@ -728,9 +740,14 @@ func (p *Picker) setupPointerHandlers() {
|
||||
}
|
||||
|
||||
func (p *Picker) setupKeyboardHandlers() {
|
||||
p.keyboard.SetKeymapHandler(func(e client.KeyboardKeymapEvent) {
|
||||
p.keymap = keymap.FromEvent(e)
|
||||
})
|
||||
|
||||
p.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
|
||||
sym := p.keymap.Keysym(e.Key)
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -274,10 +274,10 @@ func (s *SurfaceState) OnKey(key, state uint32) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
switch key {
|
||||
case 1: // KEY_ESC
|
||||
switch sym {
|
||||
case "Escape":
|
||||
s.cancelled = true
|
||||
case 28: // KEY_ENTER
|
||||
case "Return", "KP_Enter":
|
||||
if s.readyForDisplay && s.screenBuf != nil {
|
||||
s.colorPicked = true
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func TestSurfaceState_ConcurrentIsDone(t *testing.T) {
|
||||
for range goroutines / 3 {
|
||||
wg.Go(func() {
|
||||
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 != "" {
|
||||
existingConfig = existingData
|
||||
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
|
||||
|
||||
result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
|
||||
if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil {
|
||||
}
|
||||
// A legacy hyprland.conf is only backed up once hyprland.lua is in place; Hyprland
|
||||
// watches the live config and regenerates a stub one if it disappears first.
|
||||
if existingData != "" && !strings.EqualFold(filepath.Ext(existingPath), ".conf") {
|
||||
result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, filepath.Base(existingPath)), []byte(existingData))
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||
return result, result.Error
|
||||
}
|
||||
@@ -711,11 +714,15 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
||||
}
|
||||
}
|
||||
|
||||
movedLegacy, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||
movedLegacy, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
|
||||
return result, result.Error
|
||||
}
|
||||
if mainConfBackup != "" && result.BackupPath == "" {
|
||||
result.BackupPath = mainConfBackup
|
||||
cd.log(fmt.Sprintf("Backed up existing config to %s", mainConfBackup))
|
||||
}
|
||||
if movedLegacy > 0 {
|
||||
if result.BackupPath == "" {
|
||||
result.BackupPath = backupDir
|
||||
@@ -737,42 +744,53 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func backupHyprlandConfigFile(src, dst string, data []byte, removeSource bool) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
func uniqueBackupPath(dst string) string {
|
||||
if _, err := os.Lstat(dst); err != nil {
|
||||
return dst
|
||||
}
|
||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if removeSource {
|
||||
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
for i := 1; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s.%d", dst, i)
|
||||
if _, err := os.Lstat(candidate); err != nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return fmt.Sprintf("%s.%d", dst, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, error) {
|
||||
legacyPaths := []string{filepath.Join(configDir, "hyprland.conf")}
|
||||
func backupHyprlandConfigFile(dst string, data []byte) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst = uniqueBackupPath(dst)
|
||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, string, error) {
|
||||
mainConf := filepath.Join(configDir, "hyprland.conf")
|
||||
legacyPaths := []string{mainConf}
|
||||
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, "", err
|
||||
}
|
||||
legacyPaths = append(legacyPaths, dmsConfPaths...)
|
||||
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, "", err
|
||||
}
|
||||
legacyPaths = append(legacyPaths, backupPaths...)
|
||||
|
||||
moved := 0
|
||||
mainConfBackup := ""
|
||||
for _, src := range legacyPaths {
|
||||
info, err := os.Lstat(src)
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return moved, err
|
||||
return moved, mainConfBackup, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
continue
|
||||
@@ -782,21 +800,28 @@ func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, er
|
||||
if err != nil {
|
||||
rel = filepath.Base(src)
|
||||
}
|
||||
dst := filepath.Join(backupDir, rel)
|
||||
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
||||
return moved, err
|
||||
dst, err := moveHyprlandConfigFile(src, filepath.Join(backupDir, rel))
|
||||
if err != nil {
|
||||
return moved, mainConfBackup, err
|
||||
}
|
||||
if src == mainConf {
|
||||
mainConfBackup = dst
|
||||
}
|
||||
moved++
|
||||
}
|
||||
|
||||
return moved, nil
|
||||
return moved, mainConfBackup, nil
|
||||
}
|
||||
|
||||
func moveHyprlandConfigFile(src, dst string) error {
|
||||
func moveHyprlandConfigFile(src, dst string) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
return os.Rename(src, dst)
|
||||
dst = uniqueBackupPath(dst)
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
|
||||
|
||||
@@ -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 {
|
||||
rel = filepath.Base(src)
|
||||
}
|
||||
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
|
||||
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
||||
dst, err := moveHyprlandConfigFile(src, filepath.Join(configDir, hyprlandBackupDirName, ts, rel))
|
||||
if err != nil {
|
||||
if logFn != nil {
|
||||
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
Generation string `json:"generation,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Provider string `json:"provider"`
|
||||
ModKey string `json:"modKey,omitempty"`
|
||||
|
||||
@@ -500,23 +500,25 @@ func buildImportData(dank16JSON, image string) string {
|
||||
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 {
|
||||
userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml")
|
||||
|
||||
wroteConfig := false
|
||||
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")
|
||||
}
|
||||
cfgFile.WriteString(userConfigSection(opts))
|
||||
|
||||
baseConfigPath := filepath.Join(opts.ShellDir, "matugen", "configs", "base.toml")
|
||||
if data, err := os.ReadFile(baseConfigPath); err == nil {
|
||||
@@ -983,7 +985,32 @@ func runMatugenDryRun(opts *Options) (string, error) {
|
||||
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) {
|
||||
cfgPath, err := writeDryRunConfig(opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(cfgPath)
|
||||
|
||||
var baseArgs []string
|
||||
switch opts.Kind {
|
||||
case "hex":
|
||||
@@ -991,7 +1018,7 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
|
||||
default:
|
||||
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)
|
||||
if flags.isV4 {
|
||||
baseArgs = append(baseArgs, sourceSelectionArgs(opts.SourceMode, flags.supportsPrefer)...)
|
||||
|
||||
@@ -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"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -148,9 +149,5 @@ func readQString(r io.Reader) (string, error) {
|
||||
|
||||
// SocketPathForPID returns the instance IPC socket used by qs --pid.
|
||||
func SocketPathForPID(pid int) string {
|
||||
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if runtimeDir == "" {
|
||||
runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid())
|
||||
}
|
||||
return filepath.Join(runtimeDir, "quickshell", "by-pid", fmt.Sprint(pid), "ipc.sock")
|
||||
return filepath.Join(utils.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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
CompositorScroll
|
||||
CompositorMiracle
|
||||
CompositorMango
|
||||
CompositorAqueous
|
||||
)
|
||||
|
||||
var detectedCompositor Compositor = -1
|
||||
@@ -35,6 +37,7 @@ func DetectCompositor() Compositor {
|
||||
needsStat bool
|
||||
compositor Compositor
|
||||
}{
|
||||
{os.Getenv("AQUEOUS_SOCKET"), true, CompositorAqueous},
|
||||
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
|
||||
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
|
||||
{os.Getenv("SCROLLSOCK"), true, CompositorScroll},
|
||||
@@ -78,8 +81,14 @@ func GetActiveWindow() (*WindowGeometry, error) {
|
||||
return getHyprlandActiveWindow()
|
||||
case CompositorMango:
|
||||
return getMangoActiveWindow()
|
||||
case CompositorAqueous:
|
||||
model, err := aqueousSnapshot(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aqueousWindowGeometry(model, "")
|
||||
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 {
|
||||
switch DetectCompositor() {
|
||||
case CompositorAqueous:
|
||||
name, _ := aqueousFocusedOutput("")
|
||||
return name
|
||||
case CompositorHyprland:
|
||||
return getHyprlandFocusedMonitor()
|
||||
case CompositorSway:
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
const niriScreenshotTimeout = 5 * time.Second
|
||||
@@ -24,11 +26,7 @@ func CaptureNiriWindowImage(showPointer bool) (image.Image, error) {
|
||||
return nil, fmt.Errorf("NIRI_SOCKET not set")
|
||||
}
|
||||
|
||||
dir := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if dir == "" {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
path := filepath.Join(dir, fmt.Sprintf("dms-window-%d.png", os.Getpid()))
|
||||
path := filepath.Join(utils.RuntimeDir(), fmt.Sprintf("dms-window-%d.png", os.Getpid()))
|
||||
|
||||
events, err := subscribeNiriEvents(socket)
|
||||
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_viewporter"
|
||||
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
)
|
||||
|
||||
@@ -84,9 +85,10 @@ type OutputSurface struct {
|
||||
slots [3]*RenderSlot
|
||||
slotsReady bool
|
||||
|
||||
shown *overlay
|
||||
framePending bool
|
||||
redrawQueued bool
|
||||
shown *overlay
|
||||
framePending bool
|
||||
redrawQueued bool
|
||||
committedCursor bool
|
||||
}
|
||||
|
||||
type PreCapture struct {
|
||||
@@ -107,7 +109,9 @@ type RegionSelector struct {
|
||||
shm *client.Shm
|
||||
seat *client.Seat
|
||||
pointer *client.Pointer
|
||||
touch *client.Touch
|
||||
keyboard *client.Keyboard
|
||||
keymap *keymap.Keymap
|
||||
layerShell *wlr_layer_shell.ZwlrLayerShellV1
|
||||
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
|
||||
viewporter *wp_viewporter.WpViewporter
|
||||
@@ -136,6 +140,8 @@ type RegionSelector struct {
|
||||
selection SelectionState
|
||||
pointerX float64
|
||||
pointerY float64
|
||||
hasTouchPoint bool
|
||||
touchPointId int32
|
||||
preSelect Region
|
||||
showCapturedCursor bool
|
||||
shiftHeld bool
|
||||
@@ -967,7 +973,11 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
||||
case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase, handles, shift):
|
||||
slot.shm.CopyFrom(srcBuf)
|
||||
r.dimBackground(slot.shm)
|
||||
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat)
|
||||
scale := 1
|
||||
if os.output != nil {
|
||||
scale = scaleFactor(os.output.effectiveScale())
|
||||
}
|
||||
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat, scale)
|
||||
slot.backgroundInitialized = true
|
||||
slot.backgroundSource = srcBuf
|
||||
slot.backgroundDragging = r.selection.dragging
|
||||
@@ -979,7 +989,10 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
||||
case r.compositorVersion >= 4:
|
||||
fullDamage = false
|
||||
damage = overlayDamage(os.shown, cur)
|
||||
if len(damage) == 0 {
|
||||
if os.committedCursor != r.showCapturedCursor {
|
||||
fullDamage = true
|
||||
damage = nil
|
||||
} else if len(damage) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1023,6 +1036,7 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
||||
})
|
||||
}
|
||||
_ = os.wlSurface.Commit()
|
||||
os.committedCursor = r.showCapturedCursor
|
||||
|
||||
// Mark this slot as busy until compositor releases it
|
||||
slot.busy = true
|
||||
@@ -1097,6 +1111,9 @@ func (r *RegionSelector) cleanup() {
|
||||
if r.pointer != nil {
|
||||
r.pointer.Release()
|
||||
}
|
||||
if r.touch != nil {
|
||||
_ = r.touch.Release()
|
||||
}
|
||||
if r.keyboard != nil {
|
||||
r.keyboard.Release()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package screenshot
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,12 @@ func (r *RegionSelector) setupInput() {
|
||||
r.setupPointerHandlers()
|
||||
}
|
||||
}
|
||||
if e.Capabilities&uint32(client.SeatCapabilityTouch) != 0 && r.touch == nil {
|
||||
if touch, err := r.seat.GetTouch(); err == nil {
|
||||
r.touch = touch
|
||||
r.setupTouchHandlers()
|
||||
}
|
||||
}
|
||||
if e.Capabilities&uint32(client.SeatCapabilityKeyboard) != 0 && r.keyboard == nil {
|
||||
if keyboard, err := r.seat.GetKeyboard(); err == nil {
|
||||
r.keyboard = keyboard
|
||||
@@ -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) {
|
||||
if os == nil || os.output == nil || !r.selection.dragging {
|
||||
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) {
|
||||
switch e.Key {
|
||||
case 29, 97: // Ctrl left/right
|
||||
ctrl := e.State != 0
|
||||
if ctrl != r.ctrlHeld {
|
||||
r.ctrlHeld = ctrl
|
||||
r.refreshCursor()
|
||||
if r.selection.hasSelection {
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
r.handleKey(r.keymap.Keysym(e.Key), e.State)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *RegionSelector) handleKey(sym string, state uint32) {
|
||||
held := state != 0
|
||||
switch sym {
|
||||
case "Control_L", "Control_R":
|
||||
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 {
|
||||
switch e.Key {
|
||||
case 1:
|
||||
r.cancelled = true
|
||||
r.running = false
|
||||
case 28, 96:
|
||||
r.finishScroll()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch e.Key {
|
||||
case 1:
|
||||
if r.phase == phaseScroll {
|
||||
switch sym {
|
||||
case "Escape":
|
||||
r.cancelled = true
|
||||
r.running = false
|
||||
case 25:
|
||||
r.showCapturedCursor = !r.showCapturedCursor
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
case 28, 57, 96:
|
||||
if r.selection.hasSelection {
|
||||
r.finishSelection()
|
||||
}
|
||||
case "Return", "KP_Enter":
|
||||
r.finishScroll()
|
||||
}
|
||||
})
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,21 @@ import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
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},
|
||||
'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},
|
||||
@@ -593,12 +607,11 @@ func (r *RegionSelector) drawScrollPreview(data []byte, stride, bufW, bufH int,
|
||||
func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, format uint32) {
|
||||
s := r.scroll
|
||||
style := LoadOverlayStyle()
|
||||
const charH = 12
|
||||
|
||||
r.fillRect(data, stride, bufW, bufH, s.barX, s.barY, s.barW, s.barH,
|
||||
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,
|
||||
style.AccentR, style.AccentG, style.AccentB, 255, format)
|
||||
r.drawText(data, stride, bufW, bufH, s.doneX+12, labelY, "done", 10, 10, 10, format)
|
||||
@@ -617,9 +630,15 @@ func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, form
|
||||
style.TextR, style.TextG, style.TextB, format)
|
||||
}
|
||||
|
||||
func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH int) {
|
||||
const charW, charH, padding, itemSpacing = 8, 12, 12, 24
|
||||
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"
|
||||
if !r.showCapturedCursor {
|
||||
cursorLabel = "show"
|
||||
@@ -629,16 +648,25 @@ func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH i
|
||||
captureKey = "Drag+Release"
|
||||
}
|
||||
|
||||
items := []struct{ key, desc string }{
|
||||
return []hudItem{
|
||||
{captureKey, "capture"},
|
||||
{"Ctrl", "resize/move"},
|
||||
{"P", cursorLabel + " cursor"},
|
||||
{"Esc", "cancel"},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RegionSelector) hudDimensions(bufW, bufH, scale int) (hudX, hudY, hudW, hudH int) {
|
||||
scale = scaleFactor(float64(scale))
|
||||
charH := fontCharH * scale
|
||||
padding, itemSpacing := 12*scale, 24*scale
|
||||
margin := 20 * scale
|
||||
|
||||
items := r.hudItems()
|
||||
totalW := 0
|
||||
step := fontStep * scale
|
||||
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 {
|
||||
totalW += itemSpacing
|
||||
}
|
||||
@@ -647,46 +675,32 @@ func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH i
|
||||
hudW = totalW + padding*2
|
||||
hudH = charH + padding*2
|
||||
hudX = (bufW - hudW) / 2
|
||||
hudY = bufH - hudH - 20
|
||||
hudY = bufH - hudH - margin
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH)
|
||||
scale = scaleFactor(float64(scale))
|
||||
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
|
||||
style := LoadOverlayStyle()
|
||||
r.fillRect(data, stride, bufW, bufH, hudX, hudY, hudW, hudH,
|
||||
style.BackgroundR, style.BackgroundG, style.BackgroundB, style.BackgroundA, format)
|
||||
|
||||
const charW, padding, itemSpacing = 8, 12, 24
|
||||
cursorLabel := "hide"
|
||||
if !r.showCapturedCursor {
|
||||
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"},
|
||||
}
|
||||
padding, itemSpacing := 12*scale, 24*scale
|
||||
step := fontStep * scale
|
||||
items := r.hudItems()
|
||||
|
||||
tx, ty := hudX+padding, hudY+padding
|
||||
for i, item := range items {
|
||||
r.drawText(data, stride, bufW, bufH, tx, ty, item.key,
|
||||
style.AccentR, style.AccentG, style.AccentB, format)
|
||||
tx += len(item.key) * (charW + 1)
|
||||
|
||||
r.drawText(data, stride, bufW, bufH, tx, ty, " "+item.desc,
|
||||
style.TextR, style.TextG, style.TextB, format)
|
||||
tx += (1 + len(item.desc)) * (charW + 1)
|
||||
r.drawTextScaled(data, stride, bufW, bufH, tx, ty, item.key,
|
||||
style.AccentR, style.AccentG, style.AccentB, format, scale)
|
||||
r.drawTextScaled(data, stride, bufW, bufH, tx+len(item.key)*step, ty, " "+item.desc,
|
||||
style.TextR, style.TextG, style.TextB, format, scale)
|
||||
tx += item.width(step)
|
||||
|
||||
if i < len(items)-1 {
|
||||
tx += itemSpacing
|
||||
@@ -741,24 +755,29 @@ func (r *RegionSelector) drawVLine(data []byte, stride, bufW, bufH, x, y, length
|
||||
}
|
||||
|
||||
func labelRect(b selectionRenderBounds, bufW, bufH int) (dirtyRect, string) {
|
||||
scale := scaleFactor(b.scaleX)
|
||||
text := b.labelText
|
||||
|
||||
const charW, charH = 8, 12
|
||||
textW := len(text) * (charW + 1)
|
||||
charH := fontCharH * scale
|
||||
step := fontStep * scale
|
||||
textW := len(text) * step
|
||||
padX := 4 * scale
|
||||
padY := 2 * scale
|
||||
offsetY := 8 * scale
|
||||
|
||||
tx := b.x + (b.w-textW)/2
|
||||
ty := b.y + b.h + 8
|
||||
ty := b.y + b.h + offsetY
|
||||
if ty+charH > bufH {
|
||||
ty = b.y - charH - 8
|
||||
ty = b.y - charH - offsetY
|
||||
}
|
||||
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) {
|
||||
scale := scaleFactor(o.scaleX)
|
||||
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.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 {
|
||||
@@ -795,41 +814,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) {
|
||||
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 {
|
||||
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]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
scale = scaleFactor(float64(scale))
|
||||
|
||||
c0, c2 := cb, cr
|
||||
if formatIsBGR(format) {
|
||||
c0, c2 = cr, cb
|
||||
}
|
||||
|
||||
for row := range 12 {
|
||||
py := y + row
|
||||
if py < 0 || py >= bufH {
|
||||
continue
|
||||
}
|
||||
for row := range fontCharH {
|
||||
bits := glyph[row]
|
||||
for col := range 8 {
|
||||
if (bits & (1 << (7 - col))) == 0 {
|
||||
for dy := range scale {
|
||||
py := y + row*scale + dy
|
||||
if py < 0 || py >= bufH {
|
||||
continue
|
||||
}
|
||||
px := x + col
|
||||
if px < 0 || px >= bufW {
|
||||
continue
|
||||
rowOff := py * stride
|
||||
for col := range fontCharW {
|
||||
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,20 @@ func TestHUDDimensionsContainText(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH)
|
||||
data := make([]byte, bufH*stride)
|
||||
r.drawHUD(data, stride, bufW, bufH, uint32(FormatARGB8888))
|
||||
for _, scale := range []int{1, 2} {
|
||||
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
|
||||
data := make([]byte, bufH*stride)
|
||||
r.drawHUD(data, stride, bufW, bufH, uint32(FormatARGB8888), scale)
|
||||
|
||||
// Assert no lit pixel falls outside the pill boundaries
|
||||
for y := range bufH {
|
||||
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("noConfirm=%v showCursor=%v: pixel (%d, %d) is non-zero outside HUD pill [%d..%d, %d..%d]",
|
||||
noConfirm, showCursor, x, y, hudX, hudX+hudW, hudY, hudY+hudH)
|
||||
// Assert no lit pixel falls outside the pill boundaries
|
||||
for y := range bufH {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/proto/wlr_screencopy"
|
||||
"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"
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
)
|
||||
@@ -177,6 +178,14 @@ func (s *Screenshoter) captureLastRegion() (*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 err := SaveLastRegion(Region{}); err != nil {
|
||||
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) {
|
||||
if DetectCompositor() == CompositorAqueous {
|
||||
return s.captureAqueousWindow()
|
||||
}
|
||||
if DetectCompositor() == CompositorNiri {
|
||||
if s.config.Geometry {
|
||||
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) {
|
||||
if DetectCompositor() == CompositorAqueous {
|
||||
name, err := aqueousFocusedOutput(s.config.Seat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.captureOutput(name)
|
||||
}
|
||||
output := s.findFocusedOutput()
|
||||
if output == nil {
|
||||
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,23 @@ type Output struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Mode Mode
|
||||
OutputName string
|
||||
Cursor CursorMode
|
||||
NoConfirm bool
|
||||
Reset bool
|
||||
Format Format
|
||||
Quality int
|
||||
OutputDir string
|
||||
Filename string
|
||||
Clipboard bool
|
||||
SaveFile bool
|
||||
Notify bool
|
||||
Stdout bool
|
||||
Geometry bool
|
||||
IntervalMs int
|
||||
Seat string
|
||||
Mode Mode
|
||||
OutputName string
|
||||
Cursor CursorMode
|
||||
NoConfirm bool
|
||||
Reset bool
|
||||
Format Format
|
||||
Quality int
|
||||
OutputDir string
|
||||
Filename string
|
||||
Clipboard bool
|
||||
SaveFile bool
|
||||
Notify bool
|
||||
Stdout bool
|
||||
Geometry bool
|
||||
AllowMultiple bool
|
||||
IntervalMs int
|
||||
// SelectorHook runs as the interactive selector starts (true) and ends (false).
|
||||
SelectorHook func(begin bool)
|
||||
}
|
||||
|
||||
@@ -160,15 +160,20 @@ func (b *DDCBackend) getDDCName(bus int) string {
|
||||
}
|
||||
|
||||
func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
|
||||
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
|
||||
if err != nil {
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
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)
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
@@ -21,15 +22,13 @@ const (
|
||||
|
||||
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 {
|
||||
if req.SSID == "" {
|
||||
return fmt.Errorf("hotspot SSID cannot be empty")
|
||||
}
|
||||
if err := validateHotspotPassword(req.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateHotspotBandName(req.Band); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -495,6 +494,22 @@ func isAPCapableWiFiDevice(devInfo *wifiDeviceInfo) (bool, error) {
|
||||
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 {
|
||||
if band != "" && band != "bg" && band != "a" {
|
||||
return fmt.Errorf("unsupported hotspot band: %s", band)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
// enumerated first (#2460).
|
||||
func (b *NetworkManagerBackend) wifiDeviceForState() (gonetworkmanager.Device, gonetworkmanager.DeviceWireless) {
|
||||
@@ -321,7 +327,7 @@ func (b *NetworkManagerBackend) updateWiFiState() error {
|
||||
b.state.ConnectingSSID = ""
|
||||
b.state.LastError = reasonCode
|
||||
|
||||
if reasonCode == errdefs.ErrUserCanceled {
|
||||
if forgetOnConnectFailure(reasonCode, b.state.ConnectingPreExisting) {
|
||||
forgetSSID = connectingSSID
|
||||
}
|
||||
|
||||
|
||||
@@ -146,3 +146,23 @@ func TestNetworkManagerBackend_UpdateWiFiState_PicksConnectedDevice(t *testing.T
|
||||
assert.True(t, backend.state.WiFiConnected)
|
||||
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"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,15 +28,8 @@ type wpaCtrlConn struct {
|
||||
seq int
|
||||
}
|
||||
|
||||
func wpaCtrlLocalSocketBase() string {
|
||||
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return os.TempDir()
|
||||
}
|
||||
|
||||
func newWpaCtrlConn(sockPath string) (*wpaCtrlConn, error) {
|
||||
localDir, err := os.MkdirTemp(wpaCtrlLocalSocketBase(), "dms-wpa-")
|
||||
localDir, err := os.MkdirTemp(utils.RuntimeDir(), "dms-wpa-")
|
||||
if err != nil {
|
||||
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/wlcontext"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/AvengeMedia/dankgo/ipc"
|
||||
"github.com/AvengeMedia/dankgo/paths"
|
||||
"github.com/AvengeMedia/dankgo/syncmap"
|
||||
)
|
||||
|
||||
@@ -86,7 +86,7 @@ var cupsMu sync.Mutex
|
||||
var cupsSubscriberCount int
|
||||
var cupsEverAvailable bool
|
||||
|
||||
var appPaths = paths.New("danklinux")
|
||||
var appPaths = utils.App()
|
||||
|
||||
func GetSocketPath() string {
|
||||
return appPaths.SocketPath()
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
func init() {
|
||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
|
||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
|
||||
RegisterSystemBackend(func() Backend { return &shellyBackend{} })
|
||||
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
|
||||
}
|
||||
|
||||
@@ -53,9 +54,7 @@ func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
||||
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
||||
}
|
||||
|
||||
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
|
||||
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
|
||||
// AUR packages are exempt — holding those cannot break the repo dependency graph.
|
||||
// Arch repository packages must upgrade together; AUR packages may be held.
|
||||
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
|
||||
if len(ignored) == 0 {
|
||||
return ignored
|
||||
@@ -81,7 +80,7 @@ func isPacmanFamily(b Backend) bool {
|
||||
return false
|
||||
}
|
||||
switch b.ID() {
|
||||
case "pacman", "paru", "yay":
|
||||
case "pacman", "paru", "yay", "shelly":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type shellyBackend struct{}
|
||||
|
||||
func (shellyBackend) ID() string { return "shelly" }
|
||||
func (shellyBackend) DisplayName() string { return "Shelly (AUR)" }
|
||||
func (shellyBackend) Repo() RepoKind { return RepoSystem }
|
||||
func (shellyBackend) NeedsAuth() bool { return true }
|
||||
func (shellyBackend) RunsInTerminal() bool {
|
||||
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
|
||||
}
|
||||
|
||||
func (shellyBackend) IsAvailable(ctx context.Context) bool {
|
||||
if !commandExists("shelly") {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
out, err := Capture(ctx, []string{"shelly", "--version", "--json"})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var version struct {
|
||||
Name string `json:"name"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
}
|
||||
// Shelly 3.1.3 introduced this CLI identity response.
|
||||
return json.Unmarshal([]byte(out), &version) == nil && version.Name == "shelly" && version.SchemaVersion == 1
|
||||
}
|
||||
|
||||
func (b shellyBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
pkgs, err := b.checkUpdates(ctx, RepoSystem)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aur, err := b.checkUpdates(ctx, RepoAUR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(pkgs, aur...), nil
|
||||
}
|
||||
|
||||
func (b shellyBackend) checkUpdates(ctx context.Context, repo RepoKind) ([]Package, error) {
|
||||
kind := "standard"
|
||||
if repo == RepoAUR {
|
||||
kind = "aur"
|
||||
}
|
||||
out, err := Capture(ctx, []string{"shelly", "list-updates", kind, "--json"})
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
return nil, fmt.Errorf("shelly %s update check: %w: %s", kind, err, strings.TrimSpace(string(exitErr.Stderr)))
|
||||
}
|
||||
return nil, fmt.Errorf("shelly %s update check: %w", kind, err)
|
||||
}
|
||||
return parseShellyUpdates(out, repo)
|
||||
}
|
||||
|
||||
func parseShellyUpdates(out string, repo RepoKind) ([]Package, error) {
|
||||
var updates []struct {
|
||||
Name string
|
||||
CurrentVersion string
|
||||
Version string
|
||||
NewVersion string
|
||||
DownloadSize int64
|
||||
PackageBase string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &updates); err != nil {
|
||||
return nil, fmt.Errorf("shelly %s updates: invalid JSON: %w", repo, err)
|
||||
}
|
||||
if updates == nil {
|
||||
return nil, fmt.Errorf("shelly %s updates: expected a JSON array", repo)
|
||||
}
|
||||
var pkgs []Package
|
||||
for _, update := range updates {
|
||||
fromVersion := update.CurrentVersion
|
||||
if repo == RepoAUR {
|
||||
fromVersion = update.Version
|
||||
}
|
||||
if update.Name == "" || fromVersion == "" || update.NewVersion == "" {
|
||||
return nil, fmt.Errorf("shelly %s updates: missing package name or version", repo)
|
||||
}
|
||||
pkg := Package{
|
||||
Name: update.Name,
|
||||
Backend: "shelly",
|
||||
Repo: repo,
|
||||
FromVersion: fromVersion,
|
||||
ToVersion: update.NewVersion,
|
||||
SizeBytes: max(0, update.DownloadSize),
|
||||
}
|
||||
if repo == RepoAUR {
|
||||
base := update.PackageBase
|
||||
if base == "" {
|
||||
base = update.Name
|
||||
}
|
||||
pkg.ChangelogURL = "https://aur.archlinux.org/packages/" + url.PathEscape(base)
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
func (b shellyBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||
return nil
|
||||
}
|
||||
var aur []Package
|
||||
if opts.IncludeAUR && (opts.DryRun || len(opts.Ignored) > 0) {
|
||||
// The manager has already removed held packages from opts.Targets.
|
||||
var err error
|
||||
aur, err = b.checkUpdates(ctx, RepoAUR)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, pkg := range aur {
|
||||
if slices.Contains(opts.Ignored, pkg.Name) {
|
||||
return fmt.Errorf("shelly cannot exclude held AUR package %q; disable AUR updates or remove its DMS hold before updating", pkg.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if opts.DryRun {
|
||||
pkgs, err := b.checkUpdates(ctx, RepoSystem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkgs = append(pkgs, aur...)
|
||||
for _, pkg := range pkgs {
|
||||
if onLine != nil {
|
||||
onLine(fmt.Sprintf("%s %s -> %s", pkg.Name, pkg.FromVersion, pkg.ToVersion))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
argv := shellyUpgradeArgv(opts.IncludeAUR)
|
||||
if opts.AttachStdio {
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: true})
|
||||
}
|
||||
if !b.RunsInTerminal() {
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine, Env: []string{"SHELLY_ELEVATOR=pkexec"}})
|
||||
}
|
||||
term := findTerminal(opts.Terminal)
|
||||
if term == "" {
|
||||
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||
}
|
||||
return Run(ctx, wrapInTerminal(term, "DMS — System Update (shelly)", strings.Join(argv, " "), opts.TerminalArgs), RunOptions{OnLine: onLine})
|
||||
}
|
||||
|
||||
func shellyUpgradeArgv(includeAUR bool) []string {
|
||||
if !includeAUR {
|
||||
return []string{"shelly", "upgrade", "standard", "--no-confirm"}
|
||||
}
|
||||
return []string{"shelly", "upgrade", "all", "--no-flatpak", "--no-appimage", "--no-confirm"}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const shellyRepoFixture = `[{"Name":"linux","CurrentVersion":"2:6.18-1","NewVersion":"2:6.19-1","DownloadSize":1024,"Repository":"core"}]`
|
||||
const shellyAURFixture = `[{"Name":"example-git","Version":"r1-1","NewVersion":"r2-1","DownloadSize":-1,"PackageBase":"example"}]`
|
||||
|
||||
func TestParseShellyUpdates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
out string
|
||||
repo RepoKind
|
||||
want []Package
|
||||
}{
|
||||
{"repository", shellyRepoFixture, RepoSystem, []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem, FromVersion: "2:6.18-1", ToVersion: "2:6.19-1", SizeBytes: 1024}}},
|
||||
{"aur package base", shellyAURFixture, RepoAUR, []Package{{Name: "example-git", Backend: "shelly", Repo: RepoAUR, FromVersion: "r1-1", ToVersion: "r2-1", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
|
||||
{"aur name fallback", `[{"Name":"example","Version":"1","NewVersion":"2"}]`, RepoAUR, []Package{{Name: "example", Backend: "shelly", Repo: RepoAUR, FromVersion: "1", ToVersion: "2", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
|
||||
{"empty", "[]\n", RepoSystem, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseShellyUpdates(tt.out, tt.repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("packages = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, out := range []string{"", "null", "{}", "[", "[] trailing", "[null]", `[{"Name":"linux"}]`, `[{"Name":"linux","CurrentVersion":"1","NewVersion":"2","DownloadSize":"large"}]`} {
|
||||
t.Run(out, func(t *testing.T) {
|
||||
if _, err := parseShellyUpdates(out, RepoSystem); err == nil {
|
||||
t.Fatalf("expected error for %q", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeUpdateExecutable(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellySelection(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
bins []string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{"shelly only", []string{"shelly"}, `{"schemaVersion":1,"name":"shelly","version":"3.1.3"}`, "shelly"},
|
||||
{"shelly before pacman", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly"}`, "shelly"},
|
||||
{"paru preferred", []string{"paru", "yay", "shelly", "pacman"}, "", "paru"},
|
||||
{"yay preferred", []string{"yay", "shelly", "pacman"}, "", "yay"},
|
||||
{"legacy shelly", []string{"shelly", "pacman"}, "2.4.1", "pacman"},
|
||||
{"wrong binary", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly-ui"}`, "pacman"},
|
||||
{"unknown schema", []string{"shelly", "pacman"}, `{"schemaVersion":2,"name":"shelly"}`, "pacman"},
|
||||
{"pacman only", []string{"pacman"}, "", "pacman"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
for _, bin := range tt.bins {
|
||||
writeUpdateExecutable(t, dir, bin, "printf '%s\\n' '"+tt.version+"'")
|
||||
}
|
||||
selection := Select(t.Context())
|
||||
if selection.System == nil || selection.System.ID() != tt.want {
|
||||
t.Fatalf("selection = %#v, want %s", selection.System, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fakeShelly(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
log := filepath.Join(dir, "calls")
|
||||
t.Setenv("PATH", dir)
|
||||
t.Setenv("TERMINAL", "")
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "")
|
||||
t.Setenv("DMS_TEST_SHELLY_LOG", log)
|
||||
t.Setenv("DMS_TEST_SHELLY_EXIT", "0")
|
||||
t.Setenv("SHELLY_ELEVATOR", "")
|
||||
writeUpdateExecutable(t, dir, "shelly", `printf '%s|%s\n' "$*" "$SHELLY_ELEVATOR" >> "$DMS_TEST_SHELLY_LOG"
|
||||
case "$*" in
|
||||
'list-updates standard --json') printf '%s\n' '`+shellyRepoFixture+`' ;;
|
||||
'list-updates aur --json') printf '%s\n' '`+shellyAURFixture+`' ;;
|
||||
'upgrade standard --no-confirm'|'upgrade all --no-flatpak --no-appimage --no-confirm') exit "$DMS_TEST_SHELLY_EXIT" ;;
|
||||
*) echo "unexpected arguments: $*" >&2; exit 99 ;;
|
||||
esac`)
|
||||
return dir, log
|
||||
}
|
||||
|
||||
func readUpdateCalls(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestShellyCheckUpdates(t *testing.T) {
|
||||
_, log := fakeShelly(t)
|
||||
pkgs, err := (shellyBackend{}).CheckUpdates(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pkgs) != 2 || pkgs[0].Repo != RepoSystem || pkgs[1].Repo != RepoAUR {
|
||||
t.Fatalf("unexpected packages: %#v", pkgs)
|
||||
}
|
||||
if got, want := readUpdateCalls(t, log), "list-updates standard --json|\nlist-updates aur --json|\n"; got != want {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyCheckFailures(t *testing.T) {
|
||||
for _, code := range []string{"1", "2"} {
|
||||
t.Run(code, func(t *testing.T) {
|
||||
dir, _ := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", "echo 'database unavailable' >&2; exit "+code)
|
||||
if _, err := (shellyBackend{}).CheckUpdates(t.Context()); err == nil || !strings.Contains(err.Error(), "database unavailable") {
|
||||
t.Fatalf("expected query failure with stderr, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("cancellation", func(t *testing.T) {
|
||||
dir, _ := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", "exec /bin/sleep 30")
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := (shellyBackend{}).CheckUpdates(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected deadline error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShellyUpgrade(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
includeAUR bool
|
||||
attached bool
|
||||
terminal bool
|
||||
}{
|
||||
{"pkexec repository", false, false, false},
|
||||
{"pkexec aur", true, false, false},
|
||||
{"attached repository", false, true, false},
|
||||
{"attached aur", true, true, false},
|
||||
{"terminal repository", false, false, true},
|
||||
{"terminal aur", true, false, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir, log := fakeShelly(t)
|
||||
opts := UpgradeOptions{IncludeAUR: tt.includeAUR, AttachStdio: tt.attached, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
elevator := ""
|
||||
if tt.terminal {
|
||||
opts.Terminal = "fake-terminal"
|
||||
writeUpdateExecutable(t, dir, opts.Terminal, `while [ "$1" != sh ] && [ "$#" -gt 0 ]; do shift; done
|
||||
shift
|
||||
exec /bin/sh "$@"`)
|
||||
} else if !tt.attached {
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
elevator = "pkexec"
|
||||
}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := "upgrade standard --no-confirm"
|
||||
if tt.includeAUR {
|
||||
command = "upgrade all --no-flatpak --no-appimage --no-confirm"
|
||||
}
|
||||
if got, want := readUpdateCalls(t, log), command+"|"+elevator+"\n"; got != want {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyUpgradeHolds(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
includeAUR bool
|
||||
ignored []string
|
||||
wantError bool
|
||||
dryRun bool
|
||||
}{
|
||||
{"held aur", true, []string{"example-git"}, true, false},
|
||||
{"repository only", false, []string{"example-git"}, false, false},
|
||||
{"repository hold cannot exclude system upgrade", true, []string{"linux"}, false, false},
|
||||
{"unrelated hold", true, []string{"org.example.Flatpak"}, false, false},
|
||||
{"dry held aur", true, []string{"example-git"}, true, true},
|
||||
{"dry repository only", false, []string{"example-git"}, false, true},
|
||||
{"dry repository hold", true, []string{"linux"}, false, true},
|
||||
{"dry unrelated hold", true, []string{"org.example.Flatpak"}, false, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, log := fakeShelly(t)
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
opts := UpgradeOptions{DryRun: tt.dryRun, IncludeAUR: tt.includeAUR, Ignored: tt.ignored, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
|
||||
calls := readUpdateCalls(t, log)
|
||||
if strings.Count(calls, "list-updates aur --json") > 1 {
|
||||
t.Fatalf("duplicate AUR check: %q", calls)
|
||||
}
|
||||
if tt.wantError {
|
||||
if err == nil || err.Error() != `shelly cannot exclude held AUR package "example-git"; disable AUR updates or remove its DMS hold before updating` || strings.Contains(calls, "upgrade ") {
|
||||
t.Fatalf("held AUR package must block upgrade, got %v, calls %q", err, readUpdateCalls(t, log))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || strings.Contains(calls, "upgrade ") == tt.dryRun {
|
||||
t.Fatalf("unexpected result for dryRun=%v: %v, calls %q", tt.dryRun, err, calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !isPacmanFamily(shellyBackend{}) {
|
||||
t.Fatal("Shelly must enforce repository hold restrictions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyHoldCheckFailure(t *testing.T) {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
dir, log := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", `printf '%s\n' "$*" >> "$DMS_TEST_SHELLY_LOG"
|
||||
echo 'AUR unavailable' >&2
|
||||
exit 1`)
|
||||
opts := UpgradeOptions{DryRun: dryRun, IncludeAUR: true, Ignored: []string{"example-git"}, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "AUR unavailable") {
|
||||
t.Fatalf("dryRun=%v: expected AUR query error, got %v", dryRun, err)
|
||||
}
|
||||
if calls := readUpdateCalls(t, log); calls != "list-updates aur --json\n" {
|
||||
t.Fatalf("query failure must stop the operation: %q", calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyDryRun(t *testing.T) {
|
||||
for _, includeAUR := range []bool{false, true} {
|
||||
_, log := fakeShelly(t)
|
||||
var lines []string
|
||||
opts := UpgradeOptions{DryRun: true, IncludeAUR: includeAUR, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, func(line string) { lines = append(lines, line) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantCalls := "list-updates standard --json|\n"
|
||||
wantLines := []string{"linux 2:6.18-1 -> 2:6.19-1"}
|
||||
if includeAUR {
|
||||
wantCalls = "list-updates aur --json|\n" + wantCalls
|
||||
wantLines = append(wantLines, "example-git r1-1 -> r2-1")
|
||||
}
|
||||
if got := readUpdateCalls(t, log); got != wantCalls || !reflect.DeepEqual(lines, wantLines) {
|
||||
t.Fatalf("calls = %q, lines = %q", got, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellySkipsExcludedTargets(t *testing.T) {
|
||||
for _, targets := range [][]Package{
|
||||
nil,
|
||||
{{Name: "example-git", Backend: "shelly", Repo: RepoAUR}},
|
||||
{{Name: "org.example.App", Backend: "flatpak", Repo: RepoFlatpak}},
|
||||
{{Name: "linux", Backend: "pacman", Repo: RepoSystem}},
|
||||
} {
|
||||
_, log := fakeShelly(t)
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), UpgradeOptions{Targets: targets, IncludeFlatpak: true}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readUpdateCalls(t, log); got != "" {
|
||||
t.Fatalf("excluded targets executed commands: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyUpgradeFailure(t *testing.T) {
|
||||
_, _ = fakeShelly(t)
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
t.Setenv("DMS_TEST_SHELLY_EXIT", "126")
|
||||
opts := UpgradeOptions{Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err == nil {
|
||||
t.Fatal("authentication or upgrade failure must propagate")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ func Run(ctx context.Context, argv []string, opts RunOptions) error {
|
||||
}
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
@@ -125,7 +126,14 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
||||
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
||||
title, shellCmd,
|
||||
)
|
||||
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
|
||||
closer := `dms_update_status=$?
|
||||
if [ "$dms_update_status" -eq 0 ]; then
|
||||
printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'
|
||||
else
|
||||
printf '\n\033[1;31m=== Update failed (exit %s). Press Enter to close. ===\033[0m\n' "$dms_update_status"
|
||||
fi
|
||||
read -r dms_update_reply
|
||||
exit "$dms_update_status"`
|
||||
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||
full := export + banner + "; " + shellCmd + "; " + closer
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTerminalWrapperPreservesExitStatus(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
command string
|
||||
status int
|
||||
message string
|
||||
}{
|
||||
{"true", 0, "Done."},
|
||||
{"false", 1, "Update failed (exit 1)"},
|
||||
{"sh -c 'exit 42'", 42, "Update failed (exit 42)"},
|
||||
} {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
argv := wrapInTerminal("xterm", "Test", tt.command, nil)
|
||||
cmd := exec.Command("sh", "-c", argv[len(argv)-1])
|
||||
cmd.Stdin = strings.NewReader("\n")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if tt.status == 0 && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.status != 0 {
|
||||
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||
if !ok || exitErr.ExitCode() != tt.status {
|
||||
t.Fatalf("expected exit %d, got %v (%s)", tt.status, err, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(out), tt.message) {
|
||||
t.Fatalf("missing status message %q: %s", tt.message, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAttachesStdin(t *testing.T) {
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
if _, err := writer.WriteString("confirmation\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer.Close()
|
||||
previous := os.Stdin
|
||||
os.Stdin = reader
|
||||
defer func() { os.Stdin = previous }()
|
||||
if err := Run(t.Context(), []string{"sh", "-c", `read -r answer && [ "$answer" = confirmation ]`}, RunOptions{AttachStdio: true}); err != nil {
|
||||
t.Fatalf("attached command did not receive stdin: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -420,8 +420,8 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
m.failCustomUpgrade(ErrCodeCancelled, err)
|
||||
return
|
||||
}
|
||||
// exit status reflects the trailing `read`, not the update command
|
||||
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
|
||||
m.failCustomUpgrade(ErrCodeBackendFailed, err)
|
||||
return
|
||||
}
|
||||
|
||||
m.finishSuccessfulUpgrade(false)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type customUpgradeTestBackend struct {
|
||||
pacmanBackend
|
||||
checks int
|
||||
}
|
||||
|
||||
func (b *customUpgradeTestBackend) CheckUpdates(context.Context) ([]Package, error) {
|
||||
b.checks++
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestCustomUpgradeFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
writeUpdateExecutable(t, dir, "fake-terminal", `while [ "$#" -gt 0 ] && [ "$1" != sh ]; do shift; done
|
||||
[ "$#" -gt 0 ] || exit 99
|
||||
shift
|
||||
exec /bin/sh "$@"`)
|
||||
|
||||
backend := &customUpgradeTestBackend{}
|
||||
m := &Manager{
|
||||
selection: Selection{System: backend},
|
||||
notifyDirty: make(chan struct{}, 1),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
m.runCustomUpgrade(t.Context(), UpgradeOptions{
|
||||
Terminal: "fake-terminal",
|
||||
CustomCommand: "false",
|
||||
})
|
||||
|
||||
state := m.GetState()
|
||||
if state.Phase != PhaseError {
|
||||
t.Errorf("phase = %s, want %s", state.Phase, PhaseError)
|
||||
}
|
||||
if state.Error == nil || state.Error.Code != ErrCodeBackendFailed || !strings.Contains(state.Error.Message, "exit status 1") {
|
||||
t.Errorf("error = %#v, want backend-failed with exit status 1", state.Error)
|
||||
}
|
||||
if slices.Contains(state.RecentLog, "Upgrade complete.") {
|
||||
t.Error("failed custom upgrade logged success")
|
||||
}
|
||||
if backend.checks != 0 {
|
||||
t.Errorf("failed custom upgrade refreshed %d times", backend.checks)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type AppChecker interface {
|
||||
@@ -115,3 +123,64 @@ func userBinDirs() []string {
|
||||
dirs = append(dirs, "/usr/local/bin")
|
||||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -67,3 +70,58 @@ func TestEnvWithUserBinPathPrependsLocalBin(t *testing.T) {
|
||||
assert.Contains(t, parts, "/usr/local/bin")
|
||||
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 (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
@@ -10,22 +11,35 @@ func dconfPath(schema, key string) string {
|
||||
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) {
|
||||
if out, err := exec.Command("gsettings", "get", schema, key).Output(); err == nil {
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
if dconfSelected() {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if err := exec.Command("gsettings", "set", schema, key, value).Run(); err == nil {
|
||||
return nil
|
||||
if dconfSelected() {
|
||||
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"
|
||||
)
|
||||
|
||||
var app = paths.New("danklinux")
|
||||
|
||||
func App() paths.App { return app }
|
||||
|
||||
func RuntimeDir() string { return app.SocketDir() }
|
||||
|
||||
func XDGStateHome() string { return paths.XDGStateHome() }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
if (!table)
|
||||
return "";
|
||||
@@ -196,11 +216,7 @@ Singleton {
|
||||
return table[context][term];
|
||||
if (table[term] && table[term][term])
|
||||
return table[term][term];
|
||||
for (const c in table) {
|
||||
if (table[c] && table[c][term])
|
||||
return table[c][term];
|
||||
}
|
||||
return "";
|
||||
return _termIndex(table)[term] || "";
|
||||
}
|
||||
|
||||
// isRealContext is consumed by translations/extract_translations.py only:
|
||||
|
||||
@@ -3,6 +3,7 @@ import Quickshell
|
||||
|
||||
QtObject {
|
||||
required property Singleton service
|
||||
property var modules: null
|
||||
property bool active: true
|
||||
|
||||
property bool _held: false
|
||||
@@ -11,7 +12,15 @@ QtObject {
|
||||
if (wanted === _held)
|
||||
return;
|
||||
_held = wanted;
|
||||
service.refCount += wanted ? 1 : -1;
|
||||
if (modules === null) {
|
||||
service.refCount += wanted ? 1 : -1;
|
||||
return;
|
||||
}
|
||||
if (wanted) {
|
||||
service.addRef(modules);
|
||||
return;
|
||||
}
|
||||
service.removeRef(modules);
|
||||
}
|
||||
|
||||
onActiveChanged: sync(active)
|
||||
|
||||
+147
-115
@@ -8,6 +8,7 @@ import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Common.settings
|
||||
import qs.Services
|
||||
import "GSettings.js" as GSettings
|
||||
import "settings/SettingsSpec.js" as Spec
|
||||
import "settings/SettingsStore.js" as Store
|
||||
|
||||
@@ -15,7 +16,7 @@ Singleton {
|
||||
id: root
|
||||
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"
|
||||
|
||||
@@ -987,37 +988,78 @@ Singleton {
|
||||
property var notificationRules: []
|
||||
property bool notificationDndAllowCritical: true
|
||||
property bool notificationFocusedMonitor: false
|
||||
// Names the bar config rendered as Dank Island instead of a DankBar. Empty = no island.
|
||||
property string dankIslandBarId: ""
|
||||
readonly property var islandBarConfig: dankIslandBarId ? (barConfigs.find(cfg => cfg.id === dankIslandBarId) || null) : null
|
||||
readonly property bool dankIslandEnabled: !!islandBarConfig && (islandBarConfig.enabled ?? false)
|
||||
readonly property int dankIslandPosition: islandBarConfig?.position ?? SettingsData.Position.Top
|
||||
readonly property string dankIslandEdge: dankIslandPosition === SettingsData.Position.Bottom ? "bottom" : "top"
|
||||
readonly property int dankIslandReservedStripHeight: {
|
||||
const reserve = Math.max(24, Math.min(128, dankIslandReserveHeight));
|
||||
const compact = Math.max(24, Math.min(72, dankIslandCompactHeight));
|
||||
const gap = Math.max(0, Math.min(48, dankIslandOuterGap));
|
||||
// Island is a per-instance render mode: any bar config with island:true draws as an island
|
||||
// instead of a DankBar, and carries its own island* look settings.
|
||||
readonly property var islandBarConfigs: {
|
||||
barConfigs;
|
||||
return (barConfigs || []).filter(cfg => cfg && cfg.island === true);
|
||||
}
|
||||
readonly property bool dankIslandEnabled: islandBarConfigs.some(cfg => cfg.enabled ?? false)
|
||||
readonly property var islandDefaults: ({
|
||||
"islandFloating": false,
|
||||
"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);
|
||||
}
|
||||
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 _islandHomeLayoutDefault: [
|
||||
{
|
||||
@@ -1049,10 +1091,8 @@ Singleton {
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
property var dankIslandHomeLayout: _islandHomeLayoutDefault
|
||||
|
||||
function getIslandHomeLayout() {
|
||||
const stored = Array.isArray(dankIslandHomeLayout) ? dankIslandHomeLayout : [];
|
||||
function getIslandHomeLayout(bc) {
|
||||
const stored = Array.isArray(bc?.islandHomeLayout) ? bc.islandHomeLayout : [];
|
||||
const result = [];
|
||||
const seen = {};
|
||||
for (const entry of stored) {
|
||||
@@ -1075,41 +1115,33 @@ Singleton {
|
||||
return result;
|
||||
}
|
||||
|
||||
function islandHomeGroupEnabled(id) {
|
||||
const entry = getIslandHomeLayout().find(g => g.id === id);
|
||||
function islandHomeGroupEnabled(bc, id) {
|
||||
const entry = getIslandHomeLayout(bc).find(g => g.id === id);
|
||||
return entry ? entry.enabled : false;
|
||||
}
|
||||
|
||||
function setIslandHomeLayoutOrder(ids) {
|
||||
const current = getIslandHomeLayout();
|
||||
function setIslandHomeLayoutOrder(barId, ids) {
|
||||
const current = getIslandHomeLayout(getBarConfig(barId));
|
||||
const ordered = ids.map(id => current.find(g => g.id === id)).filter(g => g);
|
||||
for (const entry of current) {
|
||||
if (ids.indexOf(entry.id) < 0)
|
||||
ordered.push(entry);
|
||||
}
|
||||
set("dankIslandHomeLayout", ordered);
|
||||
updateBarConfig(barId, {
|
||||
islandHomeLayout: ordered
|
||||
});
|
||||
}
|
||||
|
||||
function setIslandHomeGroupEnabled(id, on) {
|
||||
function setIslandHomeGroupEnabled(barId, id, on) {
|
||||
if (id === "clock")
|
||||
return;
|
||||
set("dankIslandHomeLayout", getIslandHomeLayout().map(g => g.id === id ? {
|
||||
"id": g.id,
|
||||
"enabled": on
|
||||
} : g));
|
||||
updateBarConfig(barId, {
|
||||
islandHomeLayout: getIslandHomeLayout(getBarConfig(barId)).map(g => g.id === id ? {
|
||||
"id": g.id,
|
||||
"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 int osdPosition: SettingsData.Position.BottomCenter
|
||||
@@ -1523,11 +1555,7 @@ Singleton {
|
||||
return;
|
||||
if (!SessionData.lastAppliedIconTheme)
|
||||
return;
|
||||
const script = `if command -v gsettings >/dev/null 2>&1; then
|
||||
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`;
|
||||
const script = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme");
|
||||
|
||||
Proc.runCommand("iconThemeDriftCheck", ["sh", "-c", script], (output, exitCode) => {
|
||||
const platform = (output || "").trim();
|
||||
@@ -1565,11 +1593,7 @@ Singleton {
|
||||
const resolved = resolveIconTheme();
|
||||
let cosmicThemeName = (resolved === "System Default") ? systemDefaultIconTheme : resolved;
|
||||
if (!cosmicThemeName || cosmicThemeName === "System Default") {
|
||||
const detectScript = `if command -v gsettings >/dev/null 2>&1; then
|
||||
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`;
|
||||
const detectScript = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme");
|
||||
|
||||
Proc.runCommand("detectCosmicIconTheme", ["sh", "-c", detectScript], (output, exitCode) => {
|
||||
if (exitCode !== 0)
|
||||
@@ -1631,11 +1655,7 @@ Singleton {
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v gsettings >/dev/null 2>&1; then
|
||||
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
|
||||
${GSettings.setCmd("org.gnome.desktop.interface", "icon-theme", gtkThemeName)} || true
|
||||
|
||||
pkill -HUP -f 'gtk' 2>/dev/null || true`;
|
||||
|
||||
@@ -1738,13 +1758,21 @@ Singleton {
|
||||
})
|
||||
|
||||
function set(key, value) {
|
||||
if (key === "dankIslandInteractionMode" && value !== "click" && value !== "hybrid")
|
||||
value = "hybrid";
|
||||
Spec.set(root, key, value, saveSettings, _hooks);
|
||||
if (key === "dankIslandBarId" && value && frameEnabled)
|
||||
set("frameEnabled", false);
|
||||
if (key === "frameEnabled" && value && dankIslandBarId)
|
||||
set("dankIslandBarId", "");
|
||||
if (key === "frameEnabled" && value)
|
||||
clearIslandBars();
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -1799,8 +1827,8 @@ Singleton {
|
||||
Store.parse(root, obj);
|
||||
|
||||
// set() enforces this pair, but a hand-edited settings.json bypasses set() entirely.
|
||||
if (frameEnabled && dankIslandBarId)
|
||||
dankIslandBarId = "";
|
||||
if (frameEnabled && islandBarConfigs.length > 0)
|
||||
clearIslandBars();
|
||||
|
||||
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
|
||||
frameMode = "connected";
|
||||
@@ -2131,7 +2159,7 @@ Singleton {
|
||||
const pathsArg = iconPaths.join(" ");
|
||||
|
||||
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
|
||||
[ -d "$dir" ] || continue
|
||||
for theme in "$dir"/*/; do
|
||||
@@ -2170,7 +2198,7 @@ Singleton {
|
||||
const pathsArg = cursorPaths.join(" ");
|
||||
|
||||
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
|
||||
[ -d "$dir" ] || continue
|
||||
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.
|
||||
if (!isIslandBarConfig(barConfig)) {
|
||||
const islandTop = dankIslandEdgeOffset(screen, "top");
|
||||
const islandBottom = dankIslandEdgeOffset(screen, "bottom");
|
||||
if (islandTop > 0)
|
||||
topBar = Math.max(topBar, islandTop);
|
||||
if (islandBottom > 0)
|
||||
bottomBar = Math.max(bottomBar, islandBottom);
|
||||
topBar = Math.max(topBar, dankIslandEdgeOffset(screen, "top"));
|
||||
bottomBar = Math.max(bottomBar, dankIslandEdgeOffset(screen, "bottom"));
|
||||
leftBar = Math.max(leftBar, dankIslandEdgeOffset(screen, "left"));
|
||||
rightBar = Math.max(rightBar, dankIslandEdgeOffset(screen, "right"));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -2470,21 +2496,20 @@ Singleton {
|
||||
return barConfigs.find(cfg => cfg.id === barId) || null;
|
||||
}
|
||||
|
||||
function setIslandBarId(barId) {
|
||||
function setBarIsland(barId, on) {
|
||||
const config = getBarConfig(barId);
|
||||
if (!config)
|
||||
if (!config || (config.island === true) === (on === true))
|
||||
return;
|
||||
set("dankIslandBarId", barId);
|
||||
const updates = {};
|
||||
if (!config.enabled)
|
||||
updates.enabled = true;
|
||||
if ((config.screenPreferences ?? []).length === 0)
|
||||
updates.screenPreferences = ["all"];
|
||||
const position = config.position ?? SettingsData.Position.Top;
|
||||
if (position !== SettingsData.Position.Top && position !== SettingsData.Position.Bottom)
|
||||
updates.position = SettingsData.Position.Top;
|
||||
if (Object.keys(updates).length > 0)
|
||||
updateBarConfig(barId, updates);
|
||||
const updates = {
|
||||
island: on === true
|
||||
};
|
||||
if (on === true) {
|
||||
if (!config.enabled)
|
||||
updates.enabled = true;
|
||||
if ((config.screenPreferences ?? []).length === 0)
|
||||
updates.screenPreferences = ["all"];
|
||||
}
|
||||
updateBarConfig(barId, updates);
|
||||
}
|
||||
|
||||
function isBarIpcRevealed(barId) {
|
||||
@@ -2494,7 +2519,7 @@ Singleton {
|
||||
}
|
||||
|
||||
function setBarIpcReveal(barId, revealed) {
|
||||
if (!barId || barId === dankIslandBarId)
|
||||
if (!barId || isIslandBarConfig(getBarConfig(barId)))
|
||||
return;
|
||||
const nextRevealed = !!revealed;
|
||||
if (!!barIpcRevealStates[barId] === nextRevealed)
|
||||
@@ -2528,8 +2553,6 @@ Singleton {
|
||||
const index = configs.findIndex(cfg => cfg.id === barId);
|
||||
if (index === -1)
|
||||
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;
|
||||
if (updates.autoHide === false || updates.visible === false)
|
||||
setBarIpcReveal(barId, false);
|
||||
@@ -2548,8 +2571,6 @@ Singleton {
|
||||
return;
|
||||
const configs = barConfigs.filter(cfg => cfg.id !== barId);
|
||||
barConfigs = configs;
|
||||
if (dankIslandBarId === barId)
|
||||
dankIslandBarId = "";
|
||||
if (connectedFrameBarStyleBackups?.[barId] !== undefined) {
|
||||
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
|
||||
delete nextBackups[barId];
|
||||
@@ -2559,7 +2580,7 @@ Singleton {
|
||||
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() {
|
||||
return barConfigs.filter(cfg => cfg.enabled && !isIslandBarConfig(cfg));
|
||||
}
|
||||
@@ -2728,34 +2749,45 @@ Singleton {
|
||||
}
|
||||
|
||||
function isIslandBarConfig(bc) {
|
||||
return !!bc && !!dankIslandBarId && bc.id === dankIslandBarId;
|
||||
return !!bc && bc.island === true;
|
||||
}
|
||||
|
||||
function getIslandScreens() {
|
||||
const cfg = islandBarConfig;
|
||||
if (!cfg || !(cfg.enabled ?? false))
|
||||
function activeIslandConfigsForScreen(screen) {
|
||||
if (!screen)
|
||||
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) {
|
||||
const cfg = islandBarConfig;
|
||||
return !!screen && !!cfg && (cfg.enabled ?? false) && barConfigCoversScreen(cfg, screen);
|
||||
return activeIslandConfigsForScreen(screen).length > 0;
|
||||
}
|
||||
|
||||
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.
|
||||
function dankIslandEdgeOffset(screen, edge) {
|
||||
return dankIslandOwnsEdge(screen, edge) ? dankIslandReservedStripHeight : 0;
|
||||
const cfg = islandConfigForEdge(screen, edge);
|
||||
return cfg ? islandStripThickness(cfg) : 0;
|
||||
}
|
||||
|
||||
function dankIslandIsSoleBarForScreen(screen) {
|
||||
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) {
|
||||
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.Modules.Greetd
|
||||
import "StockThemes.js" as StockThemes
|
||||
import "GSettings.js" as GSettings
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
@@ -144,10 +145,6 @@ Singleton {
|
||||
|
||||
Component.onCompleted: {
|
||||
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) => {
|
||||
matugenAvailable = (code === 0) && !envDisableMatugen;
|
||||
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
|
||||
@@ -297,32 +294,7 @@ Singleton {
|
||||
if (currentTheme === "custom") {
|
||||
return customThemeData || StockThemes.getThemeByName("purple", isLightMode);
|
||||
} else if (currentTheme === dynamic) {
|
||||
return {
|
||||
"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"
|
||||
};
|
||||
return buildExtractedDynamicMode(isLightMode ? "light" : "dark", "Dynamic");
|
||||
} else {
|
||||
return StockThemes.getThemeByName(currentTheme, isLightMode);
|
||||
}
|
||||
@@ -1525,30 +1497,10 @@ Singleton {
|
||||
const dankBarScale = fontScale !== undefined ? fontScale : 1.0;
|
||||
const maxScale = (maximizeText ?? false) ? 1.5 : 1.0;
|
||||
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)
|
||||
return evenLineBoxSize(Math.round(fontSizeMedium * dankBarScale * maxScale));
|
||||
return evenLineBoxSize(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;
|
||||
return Math.round(fontSizeMedium * dankBarScale * maxScale);
|
||||
return Math.round(fontSizeSmall * dankBarScale * maxScale);
|
||||
}
|
||||
|
||||
function getBatteryIcon(level, isCharging, batteryAvailable) {
|
||||
@@ -1918,30 +1870,19 @@ Singleton {
|
||||
const theme = isLight ? "adw-gtk3" : "adw-gtk3-dark";
|
||||
const schema = "org.gnome.desktop.interface";
|
||||
const key = "gtk-theme";
|
||||
const reset = GSettings.setCmd(schema, key, "");
|
||||
const apply = GSettings.setCmd(schema, key, theme);
|
||||
|
||||
const makeCmd = (tool, schema, val) => {
|
||||
if (tool === "gsettings") {
|
||||
return `gsettings set ${schema} ${key} '' && gsettings set ${schema} ${key} ${val}`;
|
||||
} else {
|
||||
const dconfPath = `/${schema.replace(/\./g, "/")}`;
|
||||
return `dconf write ${dconfPath}/${key} "''" && dconf write ${dconfPath}/${key} "'${val}'"`;
|
||||
}
|
||||
};
|
||||
|
||||
Proc.runCommand("gtkRefresher", ["sh", "-c", makeCmd("gsettings", schema, theme)], (output, exitCode) => {
|
||||
Proc.runCommand("gtkRefresher", ["sh", "-c", `${reset}; ${apply}`], (output, exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
Proc.runCommand("gtkRefreshFallback", ["sh", "-c", makeCmd("dconf", schema, theme)], (output, exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
log.warn("Failed to refresh gtk-theme");
|
||||
}
|
||||
});
|
||||
log.warn("Failed to refresh gtk-theme");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function patchGtk3colors() {
|
||||
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
|
||||
Proc.runCommand("gtk3Patcher", ["bash", shellDir + "/scripts/gtk.sh", configDir, "patch", isLight, shellDir], (output, exitCode) => {
|
||||
Proc.runCommand("gtk3Patcher", ["bash", shellDir + "/scripts/gtk.sh", configDir, "patch", isLight], (output, exitCode) => {
|
||||
switch (exitCode) {
|
||||
case 0:
|
||||
refreshGtkTheme();
|
||||
@@ -1963,7 +1904,7 @@ Singleton {
|
||||
}
|
||||
|
||||
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "true" : "false";
|
||||
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, "apply", isLight, shellDir], (output, exitCode) => {
|
||||
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, "apply", isLight], (output, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
if (typeof ToastService !== "undefined" && !root.matugenToastSuppressed) {
|
||||
ToastService.showInfo(I18n.tr("GTK colors applied successfully"));
|
||||
|
||||
@@ -534,39 +534,6 @@ var SPEC = {
|
||||
notificationDndAllowCritical: { def: true },
|
||||
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 },
|
||||
osdPosition: { def: 5 },
|
||||
|
||||
@@ -14,6 +14,43 @@ var STALE_WIDGET_KEYS = ["desktopClockEnabled", "desktopClockStyle", "desktopClo
|
||||
|
||||
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) {
|
||||
if (!target || typeof target !== "object" || target.batteryPillStyle === undefined)
|
||||
return;
|
||||
@@ -433,5 +470,28 @@ function migrateToVersion(obj, targetVersion) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ Item {
|
||||
|
||||
property bool osdSurfacesLoaded: false
|
||||
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: {
|
||||
const screens = SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications");
|
||||
return root.withoutDankIslandScreens(screens);
|
||||
|
||||
@@ -964,9 +964,6 @@ Item {
|
||||
const posValue = positionMap[position.toLowerCase()];
|
||||
if (posValue === undefined)
|
||||
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, {
|
||||
position: posValue
|
||||
});
|
||||
@@ -1866,6 +1863,8 @@ Item {
|
||||
function open(): string {
|
||||
root.workspaceRenameModalLoader.active = true;
|
||||
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];
|
||||
root.workspaceRenameModalLoader.item.show(ws?.name || "");
|
||||
return "WORKSPACE_RENAME_MODAL_OPENED";
|
||||
@@ -1888,6 +1887,8 @@ Item {
|
||||
root.workspaceRenameModalLoader.item.hide();
|
||||
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];
|
||||
root.workspaceRenameModalLoader.item.show(ws?.name || "");
|
||||
return "WORKSPACE_RENAME_MODAL_OPENED";
|
||||
|
||||
@@ -37,7 +37,7 @@ Item {
|
||||
|
||||
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
||||
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && effectiveModalLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
|
||||
|
||||
function _dockOccupiesSide(side) {
|
||||
if (!SettingsData.showDock)
|
||||
@@ -83,6 +83,12 @@ Item {
|
||||
property bool keepPopoutsOpen: false
|
||||
property var customKeyboardFocus: null
|
||||
property bool useOverlayLayer: false
|
||||
readonly property var effectiveModalLayer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
|
||||
"allow": ["top", "overlay"],
|
||||
"invalidLayer": WlrLayer.Top,
|
||||
"label": "modals",
|
||||
"error": true
|
||||
})
|
||||
property real frozenMotionOffsetX: 0
|
||||
property real frozenMotionOffsetY: 0
|
||||
readonly property alias contentWindow: contentWindow
|
||||
@@ -140,7 +146,7 @@ Item {
|
||||
"phase": phase,
|
||||
"visible": presented,
|
||||
"presented": presented,
|
||||
"layer": root.useOverlayLayer ? "overlay" : "top",
|
||||
"layer": root.effectiveModalLayer === WlrLayer.Overlay ? "overlay" : "top",
|
||||
"barSide": resolvedConnectedBarSide,
|
||||
"bodyRect": bodyRect,
|
||||
"animationOffset": animationOffset,
|
||||
@@ -397,12 +403,7 @@ Item {
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: root.layerNamespace
|
||||
WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
|
||||
"allow": ["top", "overlay"],
|
||||
"invalidLayer": WlrLayer.Top,
|
||||
"label": "modals",
|
||||
"error": true
|
||||
})
|
||||
WlrLayershell.layer: root.effectiveModalLayer
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(shouldHaveFocus, customKeyboardFocus)
|
||||
|
||||
|
||||
@@ -821,24 +821,15 @@ Item {
|
||||
}
|
||||
|
||||
if (isCategoryFiltered) {
|
||||
var categoryPluginId = AppSearchService.getPluginIdForCategory(appCategory);
|
||||
if (categoryPluginId) {
|
||||
var pluginCategoryItems = getPluginItems(categoryPluginId, "");
|
||||
for (var i = 0; i < pluginCategoryItems.length; i++) {
|
||||
allItems.push(pluginCategoryItems[i]);
|
||||
}
|
||||
} else {
|
||||
var rawApps = AppSearchService.getAppsInCategory(appCategory);
|
||||
for (var i = 0; i < rawApps.length; i++) {
|
||||
allItems.push(getOrTransformApp(rawApps[i]));
|
||||
}
|
||||
// Also include core apps (DMS Settings etc.) that match this category
|
||||
var allCoreApps = AppSearchService.getCoreApps("");
|
||||
for (var i = 0; i < allCoreApps.length; i++) {
|
||||
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
|
||||
if (coreAppCats.indexOf(appCategory) !== -1)
|
||||
allItems.push(transformCoreApp(allCoreApps[i]));
|
||||
}
|
||||
var rawApps = AppSearchService.getAppsInCategory(appCategory);
|
||||
for (var i = 0; i < rawApps.length; i++) {
|
||||
allItems.push(getOrTransformApp(rawApps[i]));
|
||||
}
|
||||
var allCoreApps = AppSearchService.getCoreApps("");
|
||||
for (var i = 0; i < allCoreApps.length; i++) {
|
||||
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
|
||||
if (coreAppCats.indexOf(appCategory) !== -1)
|
||||
allItems.push(transformCoreApp(allCoreApps[i]));
|
||||
}
|
||||
} else {
|
||||
var apps = searchApps(searchQuery);
|
||||
@@ -1688,18 +1679,60 @@ Item {
|
||||
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() {
|
||||
var cached = CacheData.loadLauncherCache();
|
||||
if (!cached || !Array.isArray(cached) || cached.length === 0)
|
||||
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 browseSectionSeen = false;
|
||||
for (var i = 0; i < cached.length; 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 srcItems = s.items || [];
|
||||
for (var j = 0; j < srcItems.length; 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({
|
||||
id: it.id || "",
|
||||
type: it.type || "app",
|
||||
@@ -1709,15 +1742,12 @@ Item {
|
||||
iconType: it.iconType || "image",
|
||||
iconFull: it.iconFull || "",
|
||||
section: it.section || "",
|
||||
isCore: it.isCore || false,
|
||||
isBuiltInLauncher: it.isBuiltInLauncher || false,
|
||||
pluginId: it.pluginId || "",
|
||||
source: it.source || "",
|
||||
data: {
|
||||
id: it.id
|
||||
},
|
||||
actions: _actionsFromDesktopEntry(it.id),
|
||||
primaryAction: it.type === "app" && !it.isCore ? {
|
||||
primaryAction: it.type === "app" ? {
|
||||
name: I18n.tr("Launch"),
|
||||
icon: "open_in_new",
|
||||
action: "launch"
|
||||
@@ -1729,16 +1759,22 @@ Item {
|
||||
_preScored: undefined
|
||||
});
|
||||
}
|
||||
sectionsData.push({
|
||||
id: s.id || "",
|
||||
title: s.title || "",
|
||||
icon: s.icon || "",
|
||||
priority: s.priority || 0,
|
||||
items: items,
|
||||
collapsed: false,
|
||||
flatStartIndex: 0
|
||||
});
|
||||
if (items.length > 0)
|
||||
sectionsData.push(_diskCacheSection(s, items));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ Item {
|
||||
|
||||
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
||||
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && effectiveLauncherLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
|
||||
readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
|
||||
|
||||
function _dockOccupiesSide(side) {
|
||||
@@ -246,7 +246,7 @@ Item {
|
||||
"phase": phase,
|
||||
"visible": presented,
|
||||
"presented": presented,
|
||||
"layer": root.usesOverlayLayer ? "overlay" : "top",
|
||||
"layer": root.effectiveLauncherLayer === WlrLayer.Overlay ? "overlay" : "top",
|
||||
"barSide": resolvedConnectedBarSide,
|
||||
"bodyRect": bodyRect,
|
||||
"animationOffset": animationOffset,
|
||||
|
||||
@@ -47,23 +47,20 @@ Rectangle {
|
||||
Row {
|
||||
id: leftContent
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingXS
|
||||
anchors.leftMargin: hasAppCategories ? 0 : Theme.spacingXS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
// Whether the apps category picker should replace the plain title
|
||||
readonly property bool hasAppCategories: root.section?.id === "apps" && (root.controller?.appCategories?.length ?? 0) > 0
|
||||
|
||||
DankIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Hide section icon when the category chip already shows one
|
||||
visible: !leftContent.hasAppCategories
|
||||
name: root.section?.icon ?? "folder"
|
||||
size: 16
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
// Plain title — hidden when the category chip is shown
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !leftContent.hasAppCategories
|
||||
@@ -73,12 +70,10 @@ Rectangle {
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
// Compact inline category chip — only visible on the apps section
|
||||
Item {
|
||||
id: categoryChip
|
||||
visible: leftContent.hasAppCategories
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Size to content with a fixed-min width so it doesn't jump around
|
||||
width: chipRow.implicitWidth + Theme.spacingM * 2
|
||||
height: 24
|
||||
|
||||
@@ -237,7 +232,6 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Size to list content, cap at 10 visible items
|
||||
height: Math.min((root.controller?.appCategories?.length ?? 0) * 34, 10 * 34) + Theme.spacingS * 2 + 4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ Item {
|
||||
if (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 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 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));
|
||||
|
||||
return Qt.point(targetX, targetY);
|
||||
|
||||
@@ -92,8 +92,8 @@ Rectangle {
|
||||
root.leaveHiddenTab(33);
|
||||
}
|
||||
|
||||
function onDankIslandBarIdChanged() {
|
||||
if (!SettingsData.dankIslandBarId)
|
||||
function onIslandBarConfigsChanged() {
|
||||
if (SettingsData.islandBarConfigs.length === 0)
|
||||
root.leaveHiddenTab(46);
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ Rectangle {
|
||||
return false;
|
||||
if (item.frameOnly && !SettingsData.frameEnabled)
|
||||
return false;
|
||||
if (item.islandOnly && !SettingsData.dankIslandBarId)
|
||||
if (item.islandOnly && SettingsData.islandBarConfigs.length === 0)
|
||||
return false;
|
||||
if (item.cellularOnly && (NetworkService.cellularDevices?.length ?? 0) === 0)
|
||||
return false;
|
||||
|
||||
@@ -9,6 +9,8 @@ DankFloatingWindow {
|
||||
readonly property var log: Log.scoped("WorkspaceRenameModal")
|
||||
|
||||
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
|
||||
property var aqueousWorkspace: null
|
||||
property bool renaming: false
|
||||
|
||||
objectName: "workspaceRenameModal"
|
||||
title: I18n.tr("Rename Workspace")
|
||||
@@ -19,9 +21,24 @@ DankFloatingWindow {
|
||||
onClosed: hide()
|
||||
|
||||
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;
|
||||
visible = true;
|
||||
Qt.callLater(() => nameInput.forceActiveFocus());
|
||||
return true;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
@@ -29,6 +46,24 @@ DankFloatingWindow {
|
||||
}
|
||||
|
||||
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);
|
||||
hide();
|
||||
}
|
||||
@@ -49,6 +84,8 @@ DankFloatingWindow {
|
||||
return;
|
||||
}
|
||||
nameInput.text = "";
|
||||
aqueousWorkspace = null;
|
||||
renaming = false;
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
@@ -133,7 +170,7 @@ DankFloatingWindow {
|
||||
textColor: Theme.surfaceText
|
||||
placeholderText: I18n.tr("Workspace name")
|
||||
backgroundColor: "transparent"
|
||||
enabled: root.visible
|
||||
enabled: root.visible && !root.renaming
|
||||
onAccepted: submitAndClose()
|
||||
}
|
||||
}
|
||||
@@ -190,6 +227,7 @@ DankFloatingWindow {
|
||||
|
||||
MouseArea {
|
||||
id: renameArea
|
||||
enabled: !root.renaming
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
@@ -19,8 +19,11 @@ Rectangle {
|
||||
signal settingsButtonClicked
|
||||
signal headerTapped
|
||||
|
||||
Component.onCompleted: DgopService.addRef("system")
|
||||
Component.onDestruction: DgopService.removeRef("system")
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: "system"
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
|
||||
implicitHeight: 70
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
@@ -19,7 +19,10 @@ Rectangle {
|
||||
|
||||
signal showPortSelector(var node)
|
||||
|
||||
Component.onCompleted: AudioService.refreshSinkPorts()
|
||||
Component.onCompleted: {
|
||||
AudioService.refreshSinkPorts();
|
||||
AudioService.refreshCards();
|
||||
}
|
||||
|
||||
implicitHeight: headerRow.height + (!hasVolumeSliderInCC ? volumeSlider.height : 0) + audioContent.height + Theme.spacingM
|
||||
radius: Theme.cornerRadius
|
||||
@@ -365,6 +368,78 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: AudioService.switchableOutputPorts
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: portDelegate
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
height: 50
|
||||
radius: Theme.cornerRadius
|
||||
color: portMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||
border.color: Theme.outlineLight
|
||||
border.width: 1
|
||||
|
||||
DankRipple {
|
||||
id: portRipple
|
||||
cornerRadius: portDelegate.radius
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: portDelegate.modelData.icon
|
||||
size: Theme.iconSize - 4
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - Theme.iconSize - parent.spacing
|
||||
|
||||
StyledText {
|
||||
text: portDelegate.modelData.title
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
wrapMode: Text.NoWrap
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: portDelegate.modelData.cardDescription
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
wrapMode: Text.NoWrap
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: portMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPressed: mouse => portRipple.trigger(mouse.x, mouse.y)
|
||||
onClicked: AudioService.activateOutputPort(portDelegate.modelData, (ok, message) => ToastService.showToast(message, ok ? ToastService.levelInfo : ToastService.levelError))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
id: playbackHeaderRow
|
||||
anchors.left: parent.left
|
||||
|
||||
@@ -20,12 +20,10 @@ Rectangle {
|
||||
border.color: Theme.outlineMedium
|
||||
border.width: Theme.layerOutlineWidth
|
||||
|
||||
Component.onCompleted: {
|
||||
DgopService.addRef(["diskmounts"]);
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
DgopService.removeRef(["diskmounts"]);
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: ["diskmounts"]
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
|
||||
@@ -67,11 +67,10 @@ CompoundPill {
|
||||
return Theme.surfaceText;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
DgopService.addRef(["diskmounts"]);
|
||||
}
|
||||
Component.onDestruction: {
|
||||
DgopService.removeRef(["diskmounts"]);
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: ["diskmounts"]
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
|
||||
onToggled: {
|
||||
|
||||
@@ -117,10 +117,9 @@ Rectangle {
|
||||
onClicked: root.clicked()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
DgopService.addRef(["diskmounts"]);
|
||||
}
|
||||
Component.onDestruction: {
|
||||
DgopService.removeRef(["diskmounts"]);
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: ["diskmounts"]
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ Item {
|
||||
// M3 elevation shadow — Level 2 baseline (navigation bar), with per-bar override support
|
||||
readonly property bool hasPerBarOverride: (barConfig?.shadowIntensity ?? 0) > 0
|
||||
readonly property var elevLevel: Theme.elevationLevel2
|
||||
readonly property bool shadowEnabled: (Theme.elevationEnabled && (typeof SettingsData !== "undefined" ? (SettingsData.barElevationEnabled ?? true) : false)) || hasPerBarOverride
|
||||
readonly property bool shadowEnabled: Theme.elevationEnabled && (typeof SettingsData !== "undefined" ? (SettingsData.barElevationEnabled ?? true) : false)
|
||||
readonly property string autoBarShadowDirection: isTop ? "top" : (isBottom ? "bottom" : (isLeft ? "left" : (isRight ? "right" : "top")))
|
||||
readonly property string globalShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection
|
||||
readonly property string perBarShadowDirectionMode: barConfig?.shadowDirectionMode ?? "inherit"
|
||||
|
||||
@@ -39,6 +39,7 @@ Item {
|
||||
applyIndexLayout();
|
||||
}
|
||||
updateContentExtent();
|
||||
blurBarWindow?.refreshBlurRegion?.();
|
||||
}
|
||||
|
||||
function updateContentExtent() {
|
||||
|
||||
@@ -164,6 +164,12 @@ Item {
|
||||
_blurRebuildTimer.restart();
|
||||
}
|
||||
|
||||
function refreshBlurRegion() {
|
||||
if (!blurRegion)
|
||||
return;
|
||||
blurRegion.changed();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: _blurRebuildTimer
|
||||
interval: 1
|
||||
@@ -386,12 +392,26 @@ Item {
|
||||
Connections {
|
||||
target: topBarSlide
|
||||
function onXChanged() {
|
||||
if (barWindow.blurRegion)
|
||||
barWindow.blurRegion.changed();
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onYChanged() {
|
||||
if (barWindow.blurRegion)
|
||||
barWindow.blurRegion.changed();
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: barUnitInset
|
||||
function onXChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onYChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onWidthChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onHeightChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,8 +421,7 @@ Item {
|
||||
_blurRebuildTimer.restart();
|
||||
}
|
||||
function onWingChanged() {
|
||||
if (barWindow.blurRegion)
|
||||
barWindow.blurRegion.changed();
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +473,7 @@ Item {
|
||||
readonly property real _wingR: Math.max(0, wingtipsRadius)
|
||||
|
||||
// Shadow buffer: extra window space for shadow to render beyond bar bounds
|
||||
readonly property bool _shadowActive: (Theme.elevationEnabled && (typeof SettingsData !== "undefined" ? (SettingsData.barElevationEnabled ?? true) : false)) || (renderBarConfig?.shadowIntensity ?? 0) > 0
|
||||
readonly property bool _shadowActive: Theme.elevationEnabled && (typeof SettingsData !== "undefined" ? (SettingsData.barElevationEnabled ?? true) : false)
|
||||
readonly property real _shadowBuffer: {
|
||||
if (!_shadowActive)
|
||||
return 0;
|
||||
@@ -496,7 +515,7 @@ Item {
|
||||
hasMaximizedToplevel = false;
|
||||
return;
|
||||
}
|
||||
if (!CompositorService.isHyprland && !CompositorService.isNiri) {
|
||||
if (!CompositorService.isHyprland && !CompositorService.isNiri && !CompositorService.isAqueous) {
|
||||
hasMaximizedToplevel = false;
|
||||
return;
|
||||
}
|
||||
@@ -520,7 +539,7 @@ Item {
|
||||
shouldHideForWindows = false;
|
||||
return;
|
||||
}
|
||||
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango) {
|
||||
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango && !CompositorService.isAqueous) {
|
||||
shouldHideForWindows = false;
|
||||
return;
|
||||
}
|
||||
@@ -795,7 +814,7 @@ Item {
|
||||
readonly property int barThickness: Theme.px(barWindow.effectiveBarThickness + barWindow.effectiveSpacing, barWindow._dpr)
|
||||
readonly property int lengthPaddingPx: Theme.px(barWindow.effectiveBarLengthPadding, barWindow._dpr)
|
||||
|
||||
readonly property bool inOverviewWithShow: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
|
||||
readonly property bool inOverviewWithShow: CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview
|
||||
readonly property bool effectiveVisible: (barConfig?.visible ?? true) || inOverviewWithShow
|
||||
readonly property bool showing: effectiveVisible && (topBarCore.reveal || inOverviewWithShow)
|
||||
|
||||
@@ -986,7 +1005,7 @@ Item {
|
||||
}
|
||||
|
||||
property bool reveal: {
|
||||
const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview;
|
||||
const inOverviewWithShow = CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview;
|
||||
if (inOverviewWithShow)
|
||||
return true;
|
||||
|
||||
@@ -997,7 +1016,7 @@ Item {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (CompositorService.isNiri && NiriService.inOverview)
|
||||
if (CompositorService.overviewActiveOnScreen(barWindow.screenName))
|
||||
return hoverReveal || popoutPinsReveal || revealSticky || ipcReveal;
|
||||
|
||||
return (barConfig?.visible ?? true) && (!autoHide || hoverReveal || popoutPinsReveal || revealSticky || ipcReveal);
|
||||
@@ -1062,7 +1081,7 @@ Item {
|
||||
top: barWindow.isVertical ? parent.top : undefined
|
||||
bottom: barWindow.isVertical ? parent.bottom : undefined
|
||||
}
|
||||
readonly property bool inOverview: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
|
||||
readonly property bool inOverview: CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview
|
||||
hoverEnabled: (barConfig?.autoHide ?? false) && !inOverview && !topBarCore.popoutPinsReveal
|
||||
acceptedButtons: barWindow.clickThroughEnabled ? Qt.NoButton : Qt.RightButton
|
||||
enabled: !inOverview && ((barConfig?.autoHide ?? false) || !barWindow.clickThroughEnabled)
|
||||
|
||||
@@ -156,6 +156,8 @@ Item {
|
||||
|
||||
function getRealWorkspaces() {
|
||||
const screenName = _barScreenName;
|
||||
if (CompositorService.isAqueous && AqueousService.available)
|
||||
return AqueousService.workspacesForOutput(SettingsData.workspaceFollowFocus ? AqueousService.focusedOutput : screenName);
|
||||
if (CompositorService.isNiri) {
|
||||
const fallbackWorkspaces = [
|
||||
{
|
||||
@@ -238,6 +240,8 @@ Item {
|
||||
|
||||
function getCurrentWorkspace() {
|
||||
const screenName = _barScreenName;
|
||||
if (CompositorService.isAqueous && AqueousService.available)
|
||||
return getRealWorkspaces().find(ws => ws.active)?.id || "";
|
||||
if (CompositorService.isNiri) {
|
||||
if (!screenName || SettingsData.workspaceFollowFocus) {
|
||||
return NiriService.getCurrentWorkspaceNumber();
|
||||
@@ -279,7 +283,14 @@ Item {
|
||||
return;
|
||||
}
|
||||
|
||||
if (CompositorService.isNiri) {
|
||||
if (CompositorService.isAqueous && AqueousService.available) {
|
||||
const index = realWorkspaces.findIndex(ws => ws.id === getCurrentWorkspace());
|
||||
if (index < 0)
|
||||
return;
|
||||
const next = Math.max(0, Math.min(realWorkspaces.length - 1, index + (direction > 0 ? 1 : -1)));
|
||||
if (next !== index)
|
||||
AqueousService.activateWorkspace(realWorkspaces[next]);
|
||||
} else if (CompositorService.isNiri) {
|
||||
const currentWs = getCurrentWorkspace();
|
||||
const currentIndex = realWorkspaces.findIndex(ws => ws && ws.idx === currentWs);
|
||||
const validIndex = currentIndex === -1 ? 0 : currentIndex;
|
||||
@@ -323,7 +334,7 @@ Item {
|
||||
}
|
||||
|
||||
function switchApp(deltaY) {
|
||||
const windows = sortedToplevels;
|
||||
const windows = sortedToplevels.filter(w => !w.skipSwitcher);
|
||||
if (windows.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ PanelWindow {
|
||||
implicitWidth: body.surfaceImplicitWidth
|
||||
exclusiveZone: body.surfaceExclusiveZone
|
||||
|
||||
BackgroundEffect.blurRegion: body.blurRegion
|
||||
BackgroundEffect.blurRegion: BlurService.enabled ? body.blurRegion : null
|
||||
|
||||
Component.onCompleted: {
|
||||
KeyboardFocus.registerBarWindow(barWindow);
|
||||
|
||||
@@ -25,6 +25,13 @@ Item {
|
||||
readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false)
|
||||
property alias widgetLayoutLoader: layoutLoader
|
||||
|
||||
function refreshBlur() {
|
||||
blurBarWindow?.refreshBlurRegion?.();
|
||||
}
|
||||
|
||||
onXChanged: refreshBlur()
|
||||
onYChanged: refreshBlur()
|
||||
|
||||
implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0
|
||||
implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0
|
||||
|
||||
@@ -52,6 +59,7 @@ Item {
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
width: widgetLoader.item ? widgetLoader.item.width : 0
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onXChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -100,6 +108,7 @@ Item {
|
||||
property var itemData: modelData
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onYChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
@@ -25,6 +25,13 @@ Item {
|
||||
readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false)
|
||||
property alias widgetLayoutLoader: layoutLoader
|
||||
|
||||
function refreshBlur() {
|
||||
blurBarWindow?.refreshBlurRegion?.();
|
||||
}
|
||||
|
||||
onXChanged: refreshBlur()
|
||||
onYChanged: refreshBlur()
|
||||
|
||||
implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0
|
||||
implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0
|
||||
|
||||
@@ -54,6 +61,7 @@ Item {
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
width: widgetLoader.item ? widgetLoader.item.width : 0
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onXChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -102,6 +110,7 @@ Item {
|
||||
property var itemData: modelData
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onYChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
@@ -160,7 +160,7 @@ PanelWindow {
|
||||
return [];
|
||||
|
||||
const toplevels = [];
|
||||
const allToplevels = ToplevelManager.toplevels.values;
|
||||
const allToplevels = CompositorService.isAqueous && AqueousService.available ? CompositorService.sortedToplevels : ToplevelManager.toplevels.values;
|
||||
for (let i = 0; i < allToplevels.length; i++) {
|
||||
const toplevel = allToplevels[i];
|
||||
if (toplevel.appId === root.appData.appId) {
|
||||
|
||||
@@ -204,7 +204,7 @@ BasePill {
|
||||
spacing: Theme.spacingS
|
||||
|
||||
property real fontSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
||||
property real digitWidth: fontSize * 0.6
|
||||
property real digitWidth: Math.round(fontSize * 0.6)
|
||||
|
||||
property string hoursStr: {
|
||||
const hours = systemClock?.date?.getHours() ?? 0;
|
||||
@@ -234,6 +234,8 @@ BasePill {
|
||||
text: "•"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.outlineButton
|
||||
width: Math.ceil(implicitWidth)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !compact
|
||||
}
|
||||
@@ -272,6 +274,8 @@ BasePill {
|
||||
text: ":"
|
||||
font.pixelSize: clockRow.fontSize
|
||||
color: Theme.widgetTextColor
|
||||
width: Math.ceil(implicitWidth)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
@@ -295,6 +299,8 @@ BasePill {
|
||||
text: ":"
|
||||
font.pixelSize: clockRow.fontSize
|
||||
color: Theme.widgetTextColor
|
||||
width: Math.ceil(implicitWidth)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
@@ -320,6 +326,8 @@ BasePill {
|
||||
text: clockRow.ampmStr
|
||||
font.pixelSize: clockRow.fontSize
|
||||
color: Theme.widgetTextColor
|
||||
width: Math.ceil(implicitWidth)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,6 +345,8 @@ BasePill {
|
||||
}
|
||||
font.pixelSize: clockRow.fontSize
|
||||
color: Theme.widgetTextColor
|
||||
width: Math.ceil(implicitWidth)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !compact
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ BasePill {
|
||||
property int availableWidth: maxWidth
|
||||
readonly property real effectiveHorizontalWidth: Math.max(0, Math.min(maxWidth, availableWidth))
|
||||
readonly property real effectiveHorizontalInnerWidth: Math.max(0, effectiveHorizontalWidth - horizontalPadding * 2)
|
||||
property Toplevel activeWindow: null
|
||||
property var activeWindow: null
|
||||
property var activeDesktopEntry: null
|
||||
property bool isHovered: mouseArea.containsMouse
|
||||
property bool isAutoHideBar: false
|
||||
@@ -99,6 +99,11 @@ BasePill {
|
||||
}
|
||||
|
||||
function updateActiveWindow() {
|
||||
if (CompositorService.isAqueous && AqueousService.available) {
|
||||
const focused = AqueousService.focusedWindow;
|
||||
activeWindow = focused && (!parentScreen || focused.screens.some(s => s.name === parentScreen.name)) ? focused : null;
|
||||
return;
|
||||
}
|
||||
let active = ToplevelManager.activeToplevel;
|
||||
|
||||
if (!active && CompositorService.isNiri) {
|
||||
|
||||
@@ -16,6 +16,8 @@ BasePill {
|
||||
property bool showIcon: widgetData?.keyboardLayoutNameShowIcon !== undefined ? widgetData.keyboardLayoutNameShowIcon : SettingsData.keyboardLayoutNameShowIcon
|
||||
readonly property var validVariants: ["US", "UK", "GB", "AZERTY", "QWERTY", "Dvorak", "Colemak", "Mac", "Intl", "International"]
|
||||
property string currentLayout: {
|
||||
if (CompositorService.isAqueous)
|
||||
return AqueousService.keyboardLayout;
|
||||
if (CompositorService.isNiri) {
|
||||
return NiriService.getCurrentKeyboardLayoutName();
|
||||
} else if (CompositorService.isMango) {
|
||||
@@ -25,7 +27,7 @@ BasePill {
|
||||
}
|
||||
property string hyprlandKeyboard: ""
|
||||
property var hyprlandLayoutLabels: []
|
||||
readonly property var _allLayoutLabels: CompositorService.isNiri ? (NiriService.keyboardLayoutNames || []).map(n => displayLabel(n)) : hyprlandLayoutLabels
|
||||
readonly property var _allLayoutLabels: CompositorService.isAqueous ? AqueousService.keyboardLayouts.map(n => displayLabel(n)) : CompositorService.isNiri ? (NiriService.keyboardLayoutNames || []).map(n => displayLabel(n)) : hyprlandLayoutLabels
|
||||
readonly property string reserveLabel: widestLabel(_allLayoutLabels)
|
||||
readonly property string verticalReserveLabel: widestLabel(_allLayoutLabels.map(n => LayoutCodes.layoutCode(n)))
|
||||
|
||||
@@ -131,6 +133,8 @@ BasePill {
|
||||
onClicked: {
|
||||
if (CompositorService.isNiri) {
|
||||
NiriService.cycleKeyboardLayout();
|
||||
} else if (CompositorService.isAqueous) {
|
||||
AqueousService.cycleKeyboardLayout();
|
||||
} else if (CompositorService.isHyprland) {
|
||||
Quickshell.execDetached(["hyprctl", "switchxkblayout", root.hyprlandKeyboard, "next"]);
|
||||
} else if (CompositorService.isMango) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user