mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-22 03:04:01 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a87e8227d | ||
|
|
c0b26caf2b | ||
|
|
c6c40dbced | ||
|
|
02ddd010a0 | ||
|
|
6eb9df87cf | ||
|
|
0adbdfdde1 | ||
|
|
fb002d1977 | ||
|
|
8b7c9085ca | ||
|
|
8b34bbf71a | ||
|
|
fd468a6b78 | ||
|
|
7c04b61bcc | ||
|
|
bc4916a132 | ||
|
|
58a356bb05 | ||
|
|
3061e1301d | ||
|
|
e333da2013 | ||
|
|
307d05d76c | ||
|
|
10dbe73335 | ||
|
|
b6fee3a98f | ||
|
|
6166d51c7c | ||
|
|
d06fcccff9 | ||
|
|
d9cfeded47 | ||
|
|
c4ced3ac9d | ||
|
|
ce2ec80d80 | ||
|
|
a340eba493 | ||
|
|
2db7646fe3 | ||
|
|
097de493bb | ||
|
|
a7db0354ae | ||
|
|
2d2502cc67 | ||
|
|
402a8515ee | ||
|
|
4cf0f0c898 | ||
|
|
fd0564a3ec | ||
|
|
07f8675a7b | ||
|
|
cab2a1a3bc | ||
|
|
41dd91fe75 | ||
|
|
c074b220ee | ||
|
|
243d716703 | ||
|
|
fde2181c3b | ||
|
|
a5ffd18596 | ||
|
|
e0aa1f0177 | ||
|
|
e73b9c3ce1 | ||
|
|
69955286a7 | ||
|
|
d3a02f6342 | ||
|
|
86234e5b09 | ||
|
|
938fabe27d | ||
|
|
1b34aa0ce1 | ||
|
|
76ed5d68fa | ||
|
|
b69496dbcd | ||
|
|
ef179f9c92 | ||
|
|
b451d4d8e0 | ||
|
|
12f7675950 | ||
|
|
06cd88e990 | ||
|
|
aa4b99def4 | ||
|
|
4015f634c4 | ||
|
|
b2d1ec6690 | ||
|
|
1efc10dc7e | ||
|
|
94710cf643 | ||
|
|
3711cb11fa | ||
|
|
40a0eb6841 | ||
|
|
0a643028b3 | ||
|
|
0189eb0dbb | ||
|
|
ed26bdebdc | ||
|
|
2e2c74e355 | ||
|
|
c1a3be60c3 | ||
|
|
f8260513b1 | ||
|
|
ed1855946f | ||
|
|
c0bc1d8885 | ||
|
|
3f09d7b1c8 | ||
|
|
710f20d085 | ||
|
|
6261e580aa | ||
|
|
42912ebe74 | ||
|
|
e8a755b85d | ||
|
|
6713f05e02 | ||
|
|
5d39702136 | ||
|
|
166b82df1b | ||
|
|
bca1466162 | ||
|
|
75b8932b20 | ||
|
|
a1b51b191f | ||
|
|
5defc26929 | ||
|
|
71e8a85cd1 | ||
|
|
95b2750e5f | ||
|
|
7f1a423174 | ||
|
|
33e599100c | ||
|
|
cb85620777 | ||
|
|
faf25e98a5 | ||
|
|
d7b264701e | ||
|
|
c929a8952b | ||
|
|
82089c588a | ||
|
|
19a56d1835 | ||
|
|
8371fa8ef5 | ||
|
|
acca7fae68 | ||
|
|
a478c27fd0 | ||
|
|
094dcdd035 | ||
|
|
344c1e610c | ||
|
|
fdf8722818 | ||
|
|
f2114c1a01 | ||
|
|
6ed79d06df | ||
|
|
8ff677d2eb | ||
|
|
700f43a194 | ||
|
|
53a5582858 |
@@ -520,37 +520,37 @@ jobs:
|
||||
# Pin the satellite repos to this release's version. Each dispatch target
|
||||
# decides for itself whether to tag: X.Y.0 always tags, X.Y.Z skips when the
|
||||
# repo has no commits of its own since its last tag.
|
||||
fanout:
|
||||
name: Pin ${{ matrix.repo }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release]
|
||||
if: ${{ !contains(inputs.tag, '-') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
repo:
|
||||
- dgop
|
||||
- dankgo
|
||||
- dank-qml-common
|
||||
- dankcalendar
|
||||
- danksearch
|
||||
- dank-greeter
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: ${{ matrix.repo }}
|
||||
|
||||
- name: Dispatch pin-release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
REPO: ${{ github.repository_owner }}/${{ matrix.repo }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
|
||||
echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
|
||||
# fanout:
|
||||
# name: Pin ${{ matrix.repo }}
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [release]
|
||||
# if: ${{ !contains(inputs.tag, '-') }}
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# repo:
|
||||
# - dgop
|
||||
# - dankgo
|
||||
# - dank-qml-common
|
||||
# - dankcalendar
|
||||
# - danksearch
|
||||
# - dank-greeter
|
||||
# steps:
|
||||
# - name: Create GitHub App token
|
||||
# id: app_token
|
||||
# uses: actions/create-github-app-token@v3
|
||||
# with:
|
||||
# app-id: ${{ secrets.APP_ID }}
|
||||
# private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
# owner: ${{ github.repository_owner }}
|
||||
# repositories: ${{ matrix.repo }}
|
||||
#
|
||||
# - name: Dispatch pin-release
|
||||
# env:
|
||||
# GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
# REPO: ${{ github.repository_owner }}/${{ matrix.repo }}
|
||||
# TAG: ${{ inputs.tag }}
|
||||
# run: |
|
||||
# set -euo pipefail
|
||||
# gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
|
||||
# echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
[submodule "dank-qml-common"]
|
||||
path = dank-qml-common
|
||||
url = https://github.com/AvengeMedia/dank-qml-common.git
|
||||
branch = master
|
||||
branch = stable-1.6
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -79,11 +79,11 @@ func runDank16(cmd *cobra.Command, args []string) {
|
||||
primaryLight = primaryColor
|
||||
}
|
||||
variantOpts := dank16.VariantOptions{
|
||||
PrimaryDark: primaryDark,
|
||||
PrimaryLight: primaryLight,
|
||||
Background: background,
|
||||
UseDPS: contrastAlgo == "dps",
|
||||
IsLightMode: isLight,
|
||||
PrimaryDark: primaryDark,
|
||||
PrimaryLight: primaryLight,
|
||||
BackgroundDark: background,
|
||||
UseDPS: contrastAlgo == "dps",
|
||||
IsLightMode: isLight,
|
||||
}
|
||||
variantColors := dank16.GenerateVariantPalette(variantOpts)
|
||||
fmt.Print(dank16.GenerateVariantJSON(variantColors))
|
||||
|
||||
@@ -95,6 +95,8 @@ var (
|
||||
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
||||
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
||||
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
|
||||
scrollVersionRegex = regexp.MustCompile(`scroll version (\d+\.\d+)`)
|
||||
aqueousVersionRegex = regexp.MustCompile(`(?i)aqueous v?(\d+\.\d+(?:\.\d+)?)`)
|
||||
)
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
@@ -739,6 +741,8 @@ func checkWindowManagers() []checkResult {
|
||||
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
||||
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
||||
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
|
||||
{"Scroll", "scroll", "--version", scrollVersionRegex, []string{"scroll"}},
|
||||
{"Aqueous", "aqueous", "-version", aqueousVersionRegex, []string{"aqueous"}},
|
||||
}
|
||||
|
||||
var results []checkResult
|
||||
@@ -771,7 +775,7 @@ func checkWindowManagers() []checkResult {
|
||||
results = append(results, checkResult{
|
||||
catCompositor, "Compositor", statusError,
|
||||
"No supported Wayland compositor found",
|
||||
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
|
||||
"Install Hyprland, niri, Sway, River, Wayfire, labwc, mangowc, miracle-wm, Scroll, or Aqueous",
|
||||
doctorDocsURL + "#compositor-checks",
|
||||
})
|
||||
}
|
||||
@@ -780,11 +784,28 @@ func checkWindowManagers() []checkResult {
|
||||
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
||||
}
|
||||
|
||||
if os.Getenv("AQUEOUS_SOCKET") != "" {
|
||||
results = append(results, checkAqueousConfigHelper())
|
||||
}
|
||||
|
||||
results = append(results, checkCompositorBlurSupport())
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func checkAqueousConfigHelper() checkResult {
|
||||
url := doctorDocsURL + "#compositor-checks"
|
||||
path, err := exec.LookPath("aqueous-config")
|
||||
if err != nil {
|
||||
return checkResult{catCompositor, "aqueous-config", statusWarn, "Not found", "Keybind and display settings edits from the shell need the aqueous-config helper", url}
|
||||
}
|
||||
details := "Keybind and display settings edits from the shell"
|
||||
if doctorVerbose {
|
||||
details = path
|
||||
}
|
||||
return checkResult{catCompositor, "aqueous-config", statusOK, "Installed", details, url}
|
||||
}
|
||||
|
||||
func checkCompositorBlurSupport() checkResult {
|
||||
supported, err := blur.ProbeSupport()
|
||||
if err != nil {
|
||||
@@ -817,6 +838,8 @@ func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
|
||||
|
||||
func detectRunningWM() string {
|
||||
switch {
|
||||
case os.Getenv("AQUEOUS_SOCKET") != "":
|
||||
return "Aqueous"
|
||||
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
|
||||
return "Hyprland"
|
||||
case os.Getenv("NIRI_SOCKET") != "":
|
||||
@@ -911,7 +934,7 @@ ShellRoot {
|
||||
{"Polkit", "Authentication prompts"},
|
||||
{"IdleMonitor", "Idle detection"},
|
||||
{"IdleInhibitor", "Prevent idle/sleep"},
|
||||
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
|
||||
{"ShortcutInhibitor", "Allow shortcut management (niri, Aqueous)"},
|
||||
{"BackgroundBlur", "Background blur API support in Quickshell"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 @@ var (
|
||||
ssStdout bool
|
||||
ssJSON bool
|
||||
ssGeometry bool
|
||||
ssAllowMulti bool
|
||||
ssHUD string
|
||||
)
|
||||
|
||||
type screenshotMetadata struct {
|
||||
@@ -57,7 +60,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 +74,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 +83,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 +124,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 +167,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 +181,8 @@ 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")
|
||||
screenshotCmd.PersistentFlags().StringVar(&ssHUD, "hud", "auto", "HUD overlay scale in region selector (auto, off/0, or integer scale 1-4)")
|
||||
|
||||
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
|
||||
|
||||
@@ -195,6 +202,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 +213,8 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
config.Reset = ssReset
|
||||
config.Stdout = ssStdout
|
||||
config.Geometry = ssGeometry
|
||||
config.AllowMultiple = ssAllowMulti
|
||||
config.HUD = ssHUD
|
||||
|
||||
if ssGeometry {
|
||||
config.Clipboard = false
|
||||
|
||||
@@ -58,7 +58,7 @@ func init() {
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates")
|
||||
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
|
||||
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-9
@@ -3,7 +3,7 @@ module github.com/AvengeMedia/DankMaterialShell/core
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577
|
||||
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287
|
||||
github.com/Nadim147c/material/v3 v3.1.1
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
@@ -18,7 +18,7 @@ require (
|
||||
github.com/pilebones/go-udev v0.9.1
|
||||
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
||||
github.com/yuin/goldmark v1.8.4
|
||||
@@ -71,23 +71,23 @@ require (
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
|
||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e
|
||||
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.4
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
@@ -98,13 +98,12 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/text v0.40.0
|
||||
golang.org/x/text v0.41.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
+22
-24
@@ -1,9 +1,9 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e h1:AUCBM5EF+hxCG55uJl30waRQHhR5+Wij9u8AfoTkQvs=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
|
||||
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577 h1:z7daEy56wLH6R7PGS3MKIVoDHNdVoDptY/f2i61EDCQ=
|
||||
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577/go.mod h1:2kt1u9YgHwi2npqB5ago8kwZdFrHlPGXv3PM/vwRHfY=
|
||||
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea h1:A2IuXIzhTQwvBZiS8pxU5RmWSszMd16Oh11gUlJ9OeU=
|
||||
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
|
||||
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287 h1:wzQqP16zxfUcfFltJelKg6pTdRyiLY2l2NJLT0Par+Q=
|
||||
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287/go.mod h1:lwzO5Y4BO5z0I7cLMp5A7sw/THliwGz1TNv6xoUehY4=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Nadim147c/material/v3 v3.1.1 h1:3ZuTPWBJWUpzzWW1rtqe28RMZXhA4f0/mg/Jk3JG94Y=
|
||||
@@ -38,8 +38,8 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
@@ -61,8 +61,6 @@ github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daT
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ=
|
||||
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c=
|
||||
github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
|
||||
@@ -85,8 +83,8 @@ github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ=
|
||||
@@ -166,8 +164,6 @@ github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@@ -192,8 +188,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU=
|
||||
@@ -204,8 +200,8 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
|
||||
@@ -219,18 +215,20 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
|
||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
|
||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
||||
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
@@ -243,10 +241,10 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -557,7 +557,7 @@ func TestKittyConfigStructure(t *testing.T) {
|
||||
func TestKittyThemeConfigStructure(t *testing.T) {
|
||||
assert.Contains(t, KittyThemeConfig, "foreground #e0e2e8")
|
||||
assert.Contains(t, KittyThemeConfig, "background #101418")
|
||||
assert.Contains(t, KittyThemeConfig, "cursor #e0e2e8")
|
||||
assert.Contains(t, KittyThemeConfig, "cursor #9dcbfb")
|
||||
assert.Contains(t, KittyThemeConfig, "color0 #101418")
|
||||
assert.Contains(t, KittyThemeConfig, "color15 #ffffff")
|
||||
}
|
||||
@@ -890,3 +890,72 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackupLegacyHyprlandConfFilesKeepsExistingBackups(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
configDir := filepath.Join(td, ".config", "hypr")
|
||||
dmsDir := filepath.Join(configDir, "dms")
|
||||
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
||||
|
||||
userConfig := "# the config the user actually wrote\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
|
||||
backupDir := filepath.Join(configDir, hyprlandBackupDirName, "2026-01-02_03-04-05")
|
||||
require.NoError(t, os.MkdirAll(backupDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(backupDir, "hyprland.conf"), []byte(userConfig), 0o644))
|
||||
|
||||
autogenerated := "autogenerated = 1\n$mainMod = SUPER\n"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "hyprland.conf"), []byte(autogenerated), 0o644))
|
||||
|
||||
moved, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, moved)
|
||||
assert.Equal(t, filepath.Join(backupDir, "hyprland.conf.1"), mainConfBackup)
|
||||
|
||||
kept, err := os.ReadFile(filepath.Join(backupDir, "hyprland.conf"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userConfig, string(kept))
|
||||
|
||||
stray, err := os.ReadFile(mainConfBackup)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, autogenerated, string(stray))
|
||||
}
|
||||
|
||||
func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) {
|
||||
backupDir := t.TempDir()
|
||||
dst := filepath.Join(backupDir, "hyprland.lua")
|
||||
require.NoError(t, os.WriteFile(dst, []byte("-- first\n"), 0o644))
|
||||
|
||||
written, err := backupHyprlandConfigFile(dst, []byte("-- second\n"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dst+".1", written)
|
||||
|
||||
first, err := os.ReadFile(dst)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "-- first\n", string(first))
|
||||
}
|
||||
|
||||
func TestDeployHyprlandConfigWritesLuaBeforeRemovingLegacyConf(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
t.Setenv("HOME", td)
|
||||
configDir := filepath.Join(td, ".config", "hypr")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
||||
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||
userConfig := "# user config\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
|
||||
require.NoError(t, os.WriteFile(confPath, []byte(userConfig), 0o644))
|
||||
|
||||
logChan := make(chan string, 100)
|
||||
cd := NewConfigDeployer(logChan)
|
||||
go func() {
|
||||
for range logChan {
|
||||
}
|
||||
}()
|
||||
defer close(logChan)
|
||||
|
||||
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NoFileExists(t, confPath)
|
||||
backup, err := os.ReadFile(result.BackupPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userConfig, string(backup))
|
||||
assert.Equal(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"), result.BackupPath)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
cursor #e0e2e8
|
||||
cursor_text_color #c2c7cf
|
||||
cursor #9dcbfb
|
||||
cursor_text_color #003354
|
||||
|
||||
foreground #e0e2e8
|
||||
background #101418
|
||||
|
||||
@@ -179,8 +179,8 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
|
||||
if err != nil {
|
||||
rel = filepath.Base(src)
|
||||
}
|
||||
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
|
||||
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
||||
dst, err := moveHyprlandConfigFile(src, filepath.Join(configDir, hyprlandBackupDirName, ts, rel))
|
||||
if err != nil {
|
||||
if logFn != nil {
|
||||
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
|
||||
}
|
||||
|
||||
@@ -3,21 +3,32 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Mirrors Hyprland's shipped target. Before=graphical-session.target would cycle with the add-wants implicit After=dms.service.
|
||||
const hyprlandSessionTargetUnit = `[Unit]
|
||||
Description=Hyprland Session Target
|
||||
BindsTo=graphical-session.target
|
||||
Wants=graphical-session-pre.target
|
||||
After=graphical-session-pre.target
|
||||
PropagatesStopTo=graphical-session.target
|
||||
`
|
||||
|
||||
var staleHyprlandSessionTargetUnits = []string{
|
||||
`[Unit]
|
||||
Description=Hyprland Session Target
|
||||
Requires=graphical-session.target
|
||||
After=graphical-session.target
|
||||
`,
|
||||
`[Unit]
|
||||
Description=Hyprland Session Target
|
||||
BindsTo=graphical-session.target
|
||||
Before=graphical-session.target
|
||||
Wants=graphical-session-pre.target
|
||||
After=graphical-session-pre.target
|
||||
`
|
||||
|
||||
const legacyHyprlandSessionTargetUnit = `[Unit]
|
||||
Description=Hyprland Session Target
|
||||
Requires=graphical-session.target
|
||||
After=graphical-session.target
|
||||
`
|
||||
`,
|
||||
}
|
||||
|
||||
// Only a missing or dankinstall-written unit is replaced; hand-written units are left alone.
|
||||
func EnsureHyprlandSessionTarget() (string, error) {
|
||||
@@ -30,10 +41,7 @@ func EnsureHyprlandSessionTarget() (string, error) {
|
||||
existing, err := os.ReadFile(targetPath)
|
||||
if err == nil {
|
||||
content := string(existing)
|
||||
if content == hyprlandSessionTargetUnit {
|
||||
return targetPath, nil
|
||||
}
|
||||
if content != legacyHyprlandSessionTargetUnit {
|
||||
if content == hyprlandSessionTargetUnit || !slices.Contains(staleHyprlandSessionTargetUnits, content) {
|
||||
return targetPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,23 +31,25 @@ func TestEnsureHyprlandSessionTargetWritesMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureHyprlandSessionTargetUpgradesLegacy(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
path := targetPathFor(t, home)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(legacyHyprlandSessionTargetUnit), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func TestEnsureHyprlandSessionTargetUpgradesStale(t *testing.T) {
|
||||
for _, stale := range staleHyprlandSessionTargetUnits {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
path := targetPathFor(t, home)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(stale), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := EnsureHyprlandSessionTarget(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if string(data) != hyprlandSessionTargetUnit {
|
||||
t.Fatalf("legacy unit not upgraded:\n%s", data)
|
||||
if _, err := EnsureHyprlandSessionTarget(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if string(data) != hyprlandSessionTargetUnit {
|
||||
t.Fatalf("stale unit not upgraded:\n%s", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -455,6 +455,7 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
|
||||
type PaletteOptions struct {
|
||||
IsLight bool
|
||||
Background string
|
||||
Container string
|
||||
UseDPS bool
|
||||
}
|
||||
|
||||
@@ -520,8 +521,18 @@ func DeriveContainer(primary string, isLight bool) string {
|
||||
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
|
||||
}
|
||||
|
||||
// DeriveContainer only approximates the Material tone pairing, so a caller that
|
||||
// has the real primary_container should pass it: color5 and color6 are the
|
||||
// primary/container pair and their contrast with each other is the invariant.
|
||||
func resolveContainer(primaryColor string, opts PaletteOptions) string {
|
||||
if opts.Container != "" {
|
||||
return opts.Container
|
||||
}
|
||||
return DeriveContainer(primaryColor, opts.IsLight)
|
||||
}
|
||||
|
||||
func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
baseColor := DeriveContainer(primaryColor, opts.IsLight)
|
||||
baseColor := resolveContainer(primaryColor, opts)
|
||||
|
||||
rgb := HexToRGB(baseColor)
|
||||
hsv := RGBToHSV(rgb)
|
||||
@@ -531,13 +542,20 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
|
||||
var palette Palette
|
||||
|
||||
var normalTextTarget, secondaryTarget float64
|
||||
if opts.UseDPS {
|
||||
// Delta Phi Star is not symmetric across polarity: on a light background the
|
||||
// same Lc buys far less real contrast, so the light targets are the numbers
|
||||
// that land on the same measured WCAG ratios the dark ones already deliver.
|
||||
var normalTextTarget, accentTarget float64
|
||||
switch {
|
||||
case opts.UseDPS && opts.IsLight:
|
||||
normalTextTarget = 70.0
|
||||
accentTarget = 58.0
|
||||
case opts.UseDPS:
|
||||
normalTextTarget = 40.0
|
||||
secondaryTarget = 35.0
|
||||
} else {
|
||||
accentTarget = 24.5
|
||||
default:
|
||||
normalTextTarget = 4.5
|
||||
secondaryTarget = 3.0
|
||||
accentTarget = 2.1
|
||||
}
|
||||
|
||||
var bgColor string
|
||||
@@ -557,8 +575,6 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
greenH := blendHue(0.33, ph.H, 0.10)
|
||||
yellowH := blendHue(0.14, ph.H, 0.04)
|
||||
|
||||
accentTarget := secondaryTarget * 0.7
|
||||
|
||||
if opts.IsLight {
|
||||
redS := math.Min(baseSat*1.2, 1.0)
|
||||
redV := baseVal * 0.95
|
||||
@@ -570,15 +586,13 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
|
||||
yellowS := math.Min(baseSat*1.5, 1.0)
|
||||
yellowV := math.Min(baseVal*1.2, 1.0)
|
||||
palette.Color3 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, accentTarget, opts))
|
||||
palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, opts))
|
||||
|
||||
blueS := math.Min(ph.S*1.05, 1.0)
|
||||
blueV := math.Min(ph.V*1.05, 1.0)
|
||||
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
|
||||
|
||||
// Color5 matches primary_container exactly (light container in light mode)
|
||||
container5 := DeriveContainer(primaryColor, true)
|
||||
palette.Color5 = NewColorInfo(container5)
|
||||
palette.Color5 = NewColorInfo(baseColor)
|
||||
|
||||
palette.Color6 = NewColorInfo(primaryColor)
|
||||
|
||||
@@ -604,8 +618,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
brightBlueV := math.Min(ph.V*1.15, 1.0)
|
||||
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
|
||||
|
||||
lightContainer := DeriveContainer(primaryColor, true)
|
||||
palette.Color13 = NewColorInfo(lightContainer)
|
||||
color13S := ph.S * 0.7
|
||||
color13V := math.Min(ph.V*1.3, 1.0)
|
||||
palette.Color13 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color13S, V: color13V})), bgColor, accentTarget, opts))
|
||||
|
||||
brightCyanS := ph.S * 0.5
|
||||
brightCyanV := math.Min(ph.V*1.3, 1.0)
|
||||
@@ -632,9 +647,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
blueV := ph.V * 0.95
|
||||
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
|
||||
|
||||
// Color5 matches primary_container exactly (dark container in dark mode)
|
||||
darkContainer := DeriveContainer(primaryColor, false)
|
||||
palette.Color5 = NewColorInfo(darkContainer)
|
||||
palette.Color5 = NewColorInfo(baseColor)
|
||||
|
||||
palette.Color6 = NewColorInfo(primaryColor)
|
||||
|
||||
@@ -681,11 +694,14 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
}
|
||||
|
||||
type VariantOptions struct {
|
||||
PrimaryDark string
|
||||
PrimaryLight string
|
||||
Background string
|
||||
UseDPS bool
|
||||
IsLightMode bool
|
||||
PrimaryDark string
|
||||
PrimaryLight string
|
||||
BackgroundDark string
|
||||
BackgroundLight string
|
||||
ContainerDark string
|
||||
ContainerLight string
|
||||
UseDPS bool
|
||||
IsLightMode bool
|
||||
}
|
||||
|
||||
func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
|
||||
@@ -702,8 +718,8 @@ func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
|
||||
}
|
||||
|
||||
func GenerateVariantPalette(opts VariantOptions) VariantPalette {
|
||||
darkOpts := PaletteOptions{IsLight: false, Background: opts.Background, UseDPS: opts.UseDPS}
|
||||
lightOpts := PaletteOptions{IsLight: true, Background: opts.Background, UseDPS: opts.UseDPS}
|
||||
darkOpts := PaletteOptions{IsLight: false, Background: opts.BackgroundDark, Container: opts.ContainerDark, UseDPS: opts.UseDPS}
|
||||
lightOpts := PaletteOptions{IsLight: true, Background: opts.BackgroundLight, Container: opts.ContainerLight, UseDPS: opts.UseDPS}
|
||||
|
||||
dark := GeneratePalette(opts.PrimaryDark, darkOpts)
|
||||
light := GeneratePalette(opts.PrimaryLight, lightOpts)
|
||||
|
||||
@@ -701,7 +701,8 @@ func TestGeneratePaletteWithDPS(t *testing.T) {
|
||||
|
||||
bgColor := result.Color0.Hex
|
||||
for i := 1; i < 8; i++ {
|
||||
// Skip Color5 (container) and Color6 (exact primary) - intentionally not contrast-adjusted
|
||||
// Color5 is the container and Color6 the exact primary: they pair
|
||||
// with each other, not with the background
|
||||
if i == 5 || i == 6 {
|
||||
continue
|
||||
}
|
||||
@@ -862,3 +863,112 @@ func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
|
||||
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePaletteLightYellowMeetsNormalTextContrast(t *testing.T) {
|
||||
primaries := []string{"#63568f", "#7d5800", "#116d2b", "#1c5070", "#8b3f68"}
|
||||
|
||||
for _, primary := range primaries {
|
||||
t.Run(primary, func(t *testing.T) {
|
||||
palette := GeneratePalette(primary, PaletteOptions{IsLight: true, Background: "#f5fafc", UseDPS: true})
|
||||
|
||||
ratio := ContrastRatio(palette.Color3.Hex, palette.Color0.Hex)
|
||||
if ratio < 4.5 {
|
||||
t.Errorf("light Color3 %s vs bg %s ratio %.2f, expected >= 4.5 (yellow must clear the normal-text target)",
|
||||
palette.Color3.Hex, palette.Color0.Hex, ratio)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateVariantPaletteUsesPerVariantBackground(t *testing.T) {
|
||||
const darkBg = "#0f1417"
|
||||
const lightBg = "#f5fafc"
|
||||
|
||||
variants := GenerateVariantPalette(VariantOptions{
|
||||
PrimaryDark: "#8ecff2",
|
||||
PrimaryLight: "#1c5070",
|
||||
BackgroundDark: darkBg,
|
||||
BackgroundLight: lightBg,
|
||||
UseDPS: true,
|
||||
})
|
||||
|
||||
if variants.Color0.Dark.Hex != darkBg {
|
||||
t.Errorf("dark Color0 = %s, expected the dark background %s", variants.Color0.Dark.Hex, darkBg)
|
||||
}
|
||||
if variants.Color0.Light.Hex != lightBg {
|
||||
t.Errorf("light Color0 = %s, expected the light background %s", variants.Color0.Light.Hex, lightBg)
|
||||
}
|
||||
|
||||
lightDim := variants.Color8.Light.Hex
|
||||
if lstar := getLstar(lightDim); lstar < 60.0 {
|
||||
t.Errorf("light Color8 %s has L* %.2f, expected >= 60 (derived from the dark surface it collapses to near-black)",
|
||||
lightDim, lstar)
|
||||
}
|
||||
if ratio := ContrastRatio(lightDim, lightBg); ratio > 3.0 {
|
||||
t.Errorf("light Color8 %s vs bg %s ratio %.2f, expected <= 3.0 (bright black stays dim)", lightDim, lightBg, ratio)
|
||||
}
|
||||
|
||||
darkDim := variants.Color8.Dark.Hex
|
||||
if ratio := ContrastRatio(darkDim, darkBg); ratio > 3.0 {
|
||||
t.Errorf("dark Color8 %s vs bg %s ratio %.2f, expected <= 3.0", darkDim, darkBg, ratio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePaletteColor5PairsWithColor6(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
primary string
|
||||
container string
|
||||
bg string
|
||||
isLight bool
|
||||
}{
|
||||
{"violet dark", "#cdbdff", "#4b3e76", "#141318", false},
|
||||
{"violet light", "#63568f", "#e8deff", "#fdf7ff", true},
|
||||
{"green dark", "#9ed49d", "#1f5027", "#101510", false},
|
||||
{"green light", "#38693c", "#b9f0b8", "#f7fbf2", true},
|
||||
{"amber light", "#805610", "#ffddb3", "#fff8f4", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
palette := GeneratePalette(tt.primary, PaletteOptions{
|
||||
IsLight: tt.isLight,
|
||||
Background: tt.bg,
|
||||
Container: tt.container,
|
||||
UseDPS: true,
|
||||
})
|
||||
|
||||
if palette.Color5.Hex != tt.container {
|
||||
t.Errorf("Color5 = %s, expected the supplied container %s", palette.Color5.Hex, tt.container)
|
||||
}
|
||||
if palette.Color6.Hex != tt.primary {
|
||||
t.Errorf("Color6 = %s, expected the primary %s", palette.Color6.Hex, tt.primary)
|
||||
}
|
||||
|
||||
pair := ContrastRatio(palette.Color5.Hex, palette.Color6.Hex)
|
||||
if pair < 4.5 {
|
||||
t.Errorf("Color5 %s vs Color6 %s ratio %.2f, expected >= 4.5 (the container/primary pair carries the prompt themes that use ANSI 5 on 6)",
|
||||
palette.Color5.Hex, palette.Color6.Hex, pair)
|
||||
}
|
||||
|
||||
if palette.Color5.Hex == palette.Color13.Hex {
|
||||
t.Errorf("Color5 and Color13 are both %s, expected a distinct bright variant", palette.Color5.Hex)
|
||||
}
|
||||
if sep := ContrastRatio(palette.Color5.Hex, palette.Color13.Hex); sep < 2.0 {
|
||||
t.Errorf("Color5 %s vs Color13 %s ratio %.2f, expected >= 2.0 (normal and bright must be tellable apart)",
|
||||
palette.Color5.Hex, palette.Color13.Hex, sep)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePaletteFallsBackToDerivedContainer(t *testing.T) {
|
||||
const primary = "#cdbdff"
|
||||
|
||||
palette := GeneratePalette(primary, PaletteOptions{Background: "#141318", UseDPS: true})
|
||||
|
||||
want := DeriveContainer(primary, false)
|
||||
if palette.Color5.Hex != want {
|
||||
t.Errorf("Color5 = %s with no container supplied, expected the derived %s", palette.Color5.Hex, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -343,7 +343,7 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
|
||||
oldColors, _ := os.ReadFile(opts.ColorsOutput())
|
||||
|
||||
var primaryDark, primaryLight, surface string
|
||||
var primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string
|
||||
var dank16JSON string
|
||||
var importArgs []string
|
||||
var sourceImage string
|
||||
@@ -373,7 +373,10 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
log.Info("Using stock/custom theme colors with matugen base")
|
||||
primaryDark = extractNestedColor(opts.StockColors, "primary", "dark")
|
||||
primaryLight = extractNestedColor(opts.StockColors, "primary", "light")
|
||||
surface = extractNestedColor(opts.StockColors, "surface", "dark")
|
||||
surfaceDark = extractNestedColor(opts.StockColors, "surface", "dark")
|
||||
surfaceLight = extractNestedColor(opts.StockColors, "surface", "light")
|
||||
containerDark = extractNestedColor(opts.StockColors, "primary_container", "dark")
|
||||
containerLight = extractNestedColor(opts.StockColors, "primary_container", "light")
|
||||
|
||||
if primaryDark == "" {
|
||||
return false, fmt.Errorf("failed to extract primary dark from stock colors")
|
||||
@@ -382,7 +385,7 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
primaryLight = primaryDark
|
||||
}
|
||||
|
||||
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
|
||||
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
|
||||
importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON)
|
||||
importArgs = []string{"--import-json-string", importData}
|
||||
|
||||
@@ -403,7 +406,10 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
|
||||
primaryDark = extractMatugenColor(matJSON, "primary", "dark")
|
||||
primaryLight = extractMatugenColor(matJSON, "primary", "light")
|
||||
surface = extractMatugenColor(matJSON, "surface", "dark")
|
||||
surfaceDark = extractMatugenColor(matJSON, "surface", "dark")
|
||||
surfaceLight = extractMatugenColor(matJSON, "surface", "light")
|
||||
containerDark = extractMatugenColor(matJSON, "primary_container", "dark")
|
||||
containerLight = extractMatugenColor(matJSON, "primary_container", "light")
|
||||
|
||||
if primaryDark == "" {
|
||||
return false, fmt.Errorf("failed to extract primary color")
|
||||
@@ -412,7 +418,7 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
primaryLight = primaryDark
|
||||
}
|
||||
|
||||
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
|
||||
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
|
||||
importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage)}
|
||||
|
||||
log.Infof("Running matugen %s with dank16 injection", opts.Kind)
|
||||
@@ -500,23 +506,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 +991,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 +1024,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)...)
|
||||
@@ -1098,13 +1131,16 @@ func extractNestedColor(jsonStr, colorName, variant string) string {
|
||||
return color
|
||||
}
|
||||
|
||||
func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string {
|
||||
func generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string, mode ColorMode) string {
|
||||
variantOpts := dank16.VariantOptions{
|
||||
PrimaryDark: primaryDark,
|
||||
PrimaryLight: primaryLight,
|
||||
Background: surface,
|
||||
UseDPS: true,
|
||||
IsLightMode: mode == ColorModeLight,
|
||||
PrimaryDark: primaryDark,
|
||||
PrimaryLight: primaryLight,
|
||||
BackgroundDark: surfaceDark,
|
||||
BackgroundLight: surfaceLight,
|
||||
ContainerDark: containerDark,
|
||||
ContainerLight: containerLight,
|
||||
UseDPS: true,
|
||||
IsLightMode: mode == ColorModeLight,
|
||||
}
|
||||
variantColors := dank16.GenerateVariantPalette(variantOpts)
|
||||
return dank16.GenerateVariantJSON(variantColors)
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package qmlchecks
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -55,3 +56,51 @@ func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
|
||||
t.Fatalf("DMS-managed policy must generate the sanitized user PAM stack on Nix-store installs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockScreenFprintProfileMatchesPamQml(t *testing.T) {
|
||||
profile, err := os.ReadFile("../../../quickshell/assets/pam/fprint")
|
||||
if err != nil {
|
||||
t.Fatalf("read fprint PAM profile: %v", err)
|
||||
}
|
||||
|
||||
qml, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml")
|
||||
if err != nil {
|
||||
t.Fatalf("read lock screen PAM QML: %v", err)
|
||||
}
|
||||
|
||||
// Pam.qml tells a pam_fprintd timeout expiry apart from a device fault by
|
||||
// how long the session ran, so a profile change that is not mirrored in
|
||||
// sessionTimeoutMs makes real faults look like planned renewals.
|
||||
profileTimeout := regexp.MustCompile(`pam_fprintd\.so[^\n]*\btimeout=(\d+)`).FindSubmatch(profile)
|
||||
if profileTimeout == nil {
|
||||
t.Fatalf("fprint profile must set an explicit pam_fprintd timeout")
|
||||
}
|
||||
qmlTimeout := regexp.MustCompile(`sessionTimeoutMs:\s*(\d+)`).FindSubmatch(qml)
|
||||
if qmlTimeout == nil {
|
||||
t.Fatalf("Pam.qml must declare sessionTimeoutMs")
|
||||
}
|
||||
|
||||
seconds, err := strconv.Atoi(string(profileTimeout[1]))
|
||||
if err != nil {
|
||||
t.Fatalf("parse profile timeout: %v", err)
|
||||
}
|
||||
millis, err := strconv.Atoi(string(qmlTimeout[1]))
|
||||
if err != nil {
|
||||
t.Fatalf("parse sessionTimeoutMs: %v", err)
|
||||
}
|
||||
if seconds*1000 != millis {
|
||||
t.Fatalf("sessionTimeoutMs is %dms but the fprint profile sets timeout=%d", millis, seconds)
|
||||
}
|
||||
|
||||
// fprintd 1.94.0 and older ignore this option when it is longer than two
|
||||
// digits and silently fall back to 30 seconds.
|
||||
if len(profileTimeout[1]) > 2 {
|
||||
t.Fatalf("pam_fprintd timeout=%s is too long for fprintd <= 1.94.0, which would ignore it", profileTimeout[1])
|
||||
}
|
||||
|
||||
// One finished session must equal one wrong finger, otherwise
|
||||
// SettingsData.maxFprintTries silently means a multiple of the scans it names.
|
||||
if !regexp.MustCompile(`pam_fprintd\.so[^\n]*\bmax-tries=1\b`).Match(profile) {
|
||||
t.Fatalf("fprint profile must use max-tries=1 so the shell counts wrong scans one for one")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
||||
case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase, handles, shift):
|
||||
slot.shm.CopyFrom(srcBuf)
|
||||
r.dimBackground(slot.shm)
|
||||
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat)
|
||||
effectiveScale := 1.0
|
||||
if os.output != nil {
|
||||
effectiveScale = os.output.effectiveScale()
|
||||
}
|
||||
hudScale := r.hudScale(effectiveScale)
|
||||
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat, hudScale)
|
||||
slot.backgroundInitialized = true
|
||||
slot.backgroundSource = srcBuf
|
||||
slot.backgroundDragging = r.selection.dragging
|
||||
@@ -979,7 +990,10 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
||||
case r.compositorVersion >= 4:
|
||||
fullDamage = false
|
||||
damage = overlayDamage(os.shown, cur)
|
||||
if len(damage) == 0 {
|
||||
if os.committedCursor != r.showCapturedCursor {
|
||||
fullDamage = true
|
||||
damage = nil
|
||||
} else if len(damage) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1023,6 +1037,7 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
||||
})
|
||||
}
|
||||
_ = os.wlSurface.Commit()
|
||||
os.committedCursor = r.showCapturedCursor
|
||||
|
||||
// Mark this slot as busy until compositor releases it
|
||||
slot.busy = true
|
||||
@@ -1097,6 +1112,9 @@ func (r *RegionSelector) cleanup() {
|
||||
if r.pointer != nil {
|
||||
r.pointer.Release()
|
||||
}
|
||||
if r.touch != nil {
|
||||
_ = r.touch.Release()
|
||||
}
|
||||
if r.keyboard != nil {
|
||||
r.keyboard.Release()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,25 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var fontGlyphs = map[rune][12]uint8{
|
||||
const (
|
||||
fontCharW = 8
|
||||
fontCharH = 12
|
||||
fontStep = fontCharW + 1
|
||||
)
|
||||
|
||||
func scaleFactor(scale float64) int {
|
||||
s := int(math.Round(scale))
|
||||
if s < 1 {
|
||||
return 1
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var fontGlyphs = map[rune][fontCharH]uint8{
|
||||
'0': {0x3C, 0x66, 0x66, 0x6E, 0x76, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00},
|
||||
'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 +609,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 +632,41 @@ 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
|
||||
const (
|
||||
minHUDScale = 1
|
||||
maxHUDScale = 4
|
||||
)
|
||||
|
||||
func (r *RegionSelector) hudScale(effectiveScale float64) int {
|
||||
hudOpt := "auto"
|
||||
if r.screenshoter != nil && r.screenshoter.config.HUD != "" {
|
||||
hudOpt = strings.ToLower(strings.TrimSpace(r.screenshoter.config.HUD))
|
||||
}
|
||||
switch hudOpt {
|
||||
case "off", "none", "false", "0":
|
||||
return 0
|
||||
case "", "auto":
|
||||
return min(scaleFactor(effectiveScale), maxHUDScale)
|
||||
default:
|
||||
if val, err := strconv.ParseFloat(hudOpt, 64); err == nil {
|
||||
if val <= 0 {
|
||||
return 0
|
||||
}
|
||||
return min(max(scaleFactor(val), minHUDScale), maxHUDScale)
|
||||
}
|
||||
return min(scaleFactor(effectiveScale), maxHUDScale)
|
||||
}
|
||||
}
|
||||
|
||||
type hudItem struct {
|
||||
key, desc string
|
||||
}
|
||||
|
||||
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 +676,28 @@ 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) {
|
||||
if scale <= 0 {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
scale = scaleFactor(float64(scale))
|
||||
charH := fontCharH * scale
|
||||
padding, itemSpacing := 12*scale, 24*scale
|
||||
margin := 20 * scale
|
||||
|
||||
items := r.hudItems()
|
||||
totalW := 0
|
||||
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 +706,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) {
|
||||
if r.selection.dragging {
|
||||
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32, scale int) {
|
||||
if r.selection.dragging || scale <= 0 {
|
||||
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 +786,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 +845,55 @@ func (r *RegionSelector) fillRect(data []byte, stride, bufW, bufH, x, y, w, h in
|
||||
}
|
||||
|
||||
func (r *RegionSelector) drawText(data []byte, stride, bufW, bufH, x, y int, text string, cr, cg, cb uint8, format uint32) {
|
||||
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,26 @@ 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))
|
||||
// When scale <= 0, hudDimensions should return zero
|
||||
hx, hy, hw, hh := r.hudDimensions(bufW, bufH, 0)
|
||||
if hx != 0 || hy != 0 || hw != 0 || hh != 0 {
|
||||
t.Fatalf("expected 0 dimensions for scale 0, got %d, %d, %d, %d", hx, hy, hw, hh)
|
||||
}
|
||||
|
||||
// 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)
|
||||
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("scale=%d noConfirm=%v showCursor=%v: pixel (%d, %d) is non-zero outside HUD pill [%d..%d, %d..%d]",
|
||||
scale, noConfirm, showCursor, x, y, hudX, hudX+hudW, hudY, hudY+hudH)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,6 +266,47 @@ func TestHUDDimensionsContainText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHUDScale(t *testing.T) {
|
||||
tests := []struct {
|
||||
hudOpt string
|
||||
effectiveScale float64
|
||||
expectedScale int
|
||||
}{
|
||||
{"auto", 1.0, 1},
|
||||
{"auto", 1.8, 2},
|
||||
{"auto", 2.4, 2},
|
||||
{"", 1.0, 1},
|
||||
{"", 2.0, 2},
|
||||
{"off", 1.0, 0},
|
||||
{"OFF", 2.0, 0},
|
||||
{"none", 1.5, 0},
|
||||
{"false", 1.0, 0},
|
||||
{"0", 2.0, 0},
|
||||
{"0.0", 1.0, 0},
|
||||
{"-1", 1.0, 0},
|
||||
{"1", 1.0, 1},
|
||||
{"1.5", 1.0, 2},
|
||||
{"2", 1.0, 2},
|
||||
{"3", 1.0, 3},
|
||||
{"4", 1.0, 4},
|
||||
{"1000", 1.0, 4},
|
||||
{"0.2", 1.0, 1},
|
||||
{"invalid", 1.7, 2},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
r := &RegionSelector{
|
||||
screenshoter: &Screenshoter{
|
||||
config: Config{HUD: tc.hudOpt},
|
||||
},
|
||||
}
|
||||
got := r.hudScale(tc.effectiveScale)
|
||||
if got != tc.expectedScale {
|
||||
t.Errorf("hudScale(%q, %v) = %d, expected %d", tc.hudOpt, tc.effectiveScale, got, tc.expectedScale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayDeltaHandlesClampedMonitorEdge(t *testing.T) {
|
||||
prev := &overlay{
|
||||
interior: dirtyRect{100, 100, 1920, 1080},
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
)
|
||||
|
||||
func newTestSurface(id uint32) *client.Surface {
|
||||
s := &client.Surface{}
|
||||
s.SetID(id)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestTouchInputSequence(t *testing.T) {
|
||||
surface := newTestSurface(100)
|
||||
|
||||
os := &OutputSurface{
|
||||
output: &WaylandOutput{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
logicalW: 1920,
|
||||
logicalH: 1080,
|
||||
wlSurface: surface,
|
||||
}
|
||||
|
||||
r := &RegionSelector{
|
||||
running: true,
|
||||
surfaces: []*OutputSurface{os},
|
||||
}
|
||||
|
||||
// 1. Touch Down
|
||||
r.handleTouchDown(surface, 1, 100, 200)
|
||||
|
||||
if !r.hasTouchPoint || r.touchPointId != 1 {
|
||||
t.Fatalf("expected hasTouchPoint=true, touchPointId=1, got hasTouchPoint=%v, id=%d", r.hasTouchPoint, r.touchPointId)
|
||||
}
|
||||
if !r.selection.dragging || !r.selection.hasSelection {
|
||||
t.Fatalf("expected dragging=true, hasSelection=true")
|
||||
}
|
||||
if r.selection.anchorX != 100 || r.selection.anchorY != 200 {
|
||||
t.Fatalf("expected anchor (100, 200), got (%.1f, %.1f)", r.selection.anchorX, r.selection.anchorY)
|
||||
}
|
||||
|
||||
// 2. Ignore secondary touch point while primary is active
|
||||
surface2 := newTestSurface(200)
|
||||
r.handleTouchDown(surface2, 2, 500, 600)
|
||||
if r.touchPointId != 1 {
|
||||
t.Fatalf("expected touchPointId to stay 1, got %d", r.touchPointId)
|
||||
}
|
||||
|
||||
// 3. Touch Motion with matching id
|
||||
r.handleTouchMotion(1, 400, 500)
|
||||
if r.selection.currentX != 400 || r.selection.currentY != 500 {
|
||||
t.Fatalf("expected current (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
|
||||
}
|
||||
|
||||
// 4. Touch Motion with mismatched id should be ignored
|
||||
r.handleTouchMotion(2, 900, 900)
|
||||
if r.selection.currentX != 400 || r.selection.currentY != 500 {
|
||||
t.Fatalf("expected current to remain (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
|
||||
}
|
||||
|
||||
// 5. Touch Up with mismatched id should not release
|
||||
r.handleTouchUp(2)
|
||||
if !r.hasTouchPoint || !r.selection.dragging {
|
||||
t.Fatalf("expected touch point to remain active on mismatched touch up")
|
||||
}
|
||||
|
||||
// 6. Touch Up with matching id
|
||||
r.handleTouchUp(1)
|
||||
if r.hasTouchPoint || r.selection.dragging {
|
||||
t.Fatalf("expected hasTouchPoint=false, dragging=false after touch up")
|
||||
}
|
||||
if !r.selection.hasSelection {
|
||||
t.Fatalf("expected selection to be preserved after touch up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchCancel(t *testing.T) {
|
||||
surface := newTestSurface(100)
|
||||
|
||||
os := &OutputSurface{
|
||||
output: &WaylandOutput{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
logicalW: 1920,
|
||||
logicalH: 1080,
|
||||
wlSurface: surface,
|
||||
}
|
||||
|
||||
r := &RegionSelector{
|
||||
running: true,
|
||||
surfaces: []*OutputSurface{os},
|
||||
}
|
||||
|
||||
r.handleTouchDown(surface, 5, 50, 60)
|
||||
if !r.hasTouchPoint || !r.selection.dragging {
|
||||
t.Fatalf("expected active touch point")
|
||||
}
|
||||
|
||||
r.handleTouchCancel()
|
||||
if r.hasTouchPoint || r.selection.dragging {
|
||||
t.Fatalf("expected touch point and dragging to be cleared on cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchUpScrollPhase(t *testing.T) {
|
||||
surface := newTestSurface(100)
|
||||
|
||||
os := &OutputSurface{
|
||||
output: &WaylandOutput{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
logicalW: 1920,
|
||||
logicalH: 1080,
|
||||
wlSurface: surface,
|
||||
}
|
||||
|
||||
sc := &Screenshoter{
|
||||
config: Config{NoConfirm: true},
|
||||
}
|
||||
|
||||
r := &RegionSelector{
|
||||
screenshoter: sc,
|
||||
phase: phaseScroll,
|
||||
running: true,
|
||||
surfaces: []*OutputSurface{os},
|
||||
selection: SelectionState{
|
||||
hasSelection: true,
|
||||
surface: os,
|
||||
},
|
||||
}
|
||||
|
||||
r.hasTouchPoint = true
|
||||
r.touchPointId = 3
|
||||
r.selection.dragging = true
|
||||
|
||||
r.handleTouchUp(3)
|
||||
|
||||
if r.hasTouchPoint || r.selection.dragging {
|
||||
t.Fatalf("expected touch point and dragging cleared")
|
||||
}
|
||||
|
||||
// In scroll phase with NoConfirm, handleTouchUp must not call finishSelection / change running state
|
||||
if !r.running {
|
||||
t.Fatalf("expected running to stay true, did not expect finishSelection side-effects")
|
||||
}
|
||||
}
|
||||
@@ -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,24 @@ 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
|
||||
HUD string
|
||||
// SelectorHook runs as the interactive selector starts (true) and ends (false).
|
||||
SelectorHook func(begin bool)
|
||||
}
|
||||
@@ -88,5 +91,6 @@ func DefaultConfig() Config {
|
||||
Clipboard: true,
|
||||
SaveFile: true,
|
||||
Notify: true,
|
||||
HUD: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ const (
|
||||
propertiesIface = "org.freedesktop.DBus.Properties"
|
||||
)
|
||||
|
||||
// Sender-scoped: the system bus is shared process-wide, so unscoped rules wake
|
||||
// every other subsystem's signal channel too.
|
||||
var bluezMatchRules = [][]dbus.MatchOption{
|
||||
{dbus.WithMatchInterface(propertiesIface), dbus.WithMatchMember("PropertiesChanged")},
|
||||
{dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")},
|
||||
{dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesRemoved")},
|
||||
{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(propertiesIface), dbus.WithMatchMember("PropertiesChanged")},
|
||||
{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")},
|
||||
{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesRemoved")},
|
||||
}
|
||||
|
||||
func NewManager() (*Manager, error) {
|
||||
|
||||
@@ -19,7 +19,7 @@ func WaitForAdapter() error {
|
||||
conn.Signal(signals)
|
||||
defer conn.RemoveSignal(signals)
|
||||
|
||||
adapterAdded := []dbus.MatchOption{dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")}
|
||||
adapterAdded := []dbus.MatchOption{dbus.WithMatchSender(bluezService), dbus.WithMatchInterface(objectMgrIface), dbus.WithMatchMember("InterfacesAdded")}
|
||||
if err := conn.AddMatchSignal(adapterAdded...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package brightness
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
@@ -20,13 +21,40 @@ const (
|
||||
DDCCI_VCP_SET = 0x03
|
||||
VCP_BRIGHTNESS = 0x10
|
||||
DDC_SOURCE_ADDR = 0x51
|
||||
EDID_ADDR = 0x50
|
||||
)
|
||||
|
||||
// ddcutil's DEFAULT_FLOCK_POLL_MILLISEC and DEFAULT_FLOCK_MAX_WAIT_MILLISEC (src/base/parms.h).
|
||||
const (
|
||||
ddcBusLockPoll = 100 * time.Millisecond
|
||||
ddcBusLockWait = 3 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
errDDCDisabled = errors.New("disabled via DMS_NO_DDC")
|
||||
errInvalidEDID = errors.New("invalid edid")
|
||||
errDisplayAsleep = errors.New("display asleep")
|
||||
errNoMonitor = errors.New("no monitor on bus")
|
||||
errBusReused = errors.New("bus belongs to another adapter")
|
||||
)
|
||||
|
||||
var ddcRereadDelays = []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second}
|
||||
|
||||
func NewDDCBackend() (*DDCBackend, error) {
|
||||
return newDDCBackend(nil)
|
||||
}
|
||||
|
||||
func newDDCBackend(onReread func()) (*DDCBackend, error) {
|
||||
if os.Getenv("DMS_NO_DDC") != "" {
|
||||
return nil, errDDCDisabled
|
||||
}
|
||||
|
||||
b := &DDCBackend{
|
||||
scanInterval: 30 * time.Second,
|
||||
debounceTimers: make(map[string]*time.Timer),
|
||||
debouncePending: make(map[string]ddcPendingSet),
|
||||
onReread: onReread,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
|
||||
if err := b.scanI2CDevices(); err != nil {
|
||||
@@ -53,6 +81,7 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
|
||||
}
|
||||
|
||||
activeBuses := make(map[int]bool)
|
||||
connectors := drmConnectorsByBus()
|
||||
|
||||
for i := range 32 {
|
||||
busPath := fmt.Sprintf("/dev/i2c-%d", i)
|
||||
@@ -70,18 +99,36 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
|
||||
|
||||
// Don't re-probe identified monitors: DDC traffic during a wake
|
||||
// sequence can disturb some monitors' own brightness handling.
|
||||
if _, ok := b.devices.Load(id); ok {
|
||||
if known, ok := b.devices.Load(id); ok {
|
||||
if known.adapter == getI2CDeviceSysfsName(i) {
|
||||
continue
|
||||
}
|
||||
b.devices.Delete(id)
|
||||
log.Debugf("removed DDC device %s (bus number reused by another adapter)", id)
|
||||
}
|
||||
|
||||
verdict := ddcBusVerdictFor(i, connectors)
|
||||
if verdict == ddcBusSkip {
|
||||
log.Debugf("i2c-%d: no monitor per DRM sysfs, not probing", i)
|
||||
continue
|
||||
}
|
||||
if ddcDisplayAsleep(i, connectors) {
|
||||
log.Debugf("i2c-%d: display asleep, not probing", i)
|
||||
continue
|
||||
}
|
||||
|
||||
dev, err := b.probeDDCDevice(i)
|
||||
dev, err := b.probeDDCDevice(i, verdict == ddcBusNeedsEDIDRead)
|
||||
if err != nil || dev == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dev.id = id
|
||||
unread := dev.max == 0
|
||||
b.devices.Store(id, dev)
|
||||
log.Debugf("found DDC device on i2c-%d", i)
|
||||
if unread && b.onReread != nil {
|
||||
go b.rereadUntilKnown(dev)
|
||||
}
|
||||
}
|
||||
|
||||
b.devices.Range(func(id string, dev *ddcDevice) bool {
|
||||
@@ -97,53 +144,119 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DDCBackend) probeDDCDevice(bus int) (*ddcDevice, error) {
|
||||
busPath := fmt.Sprintf("/dev/i2c-%d", bus)
|
||||
|
||||
fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
|
||||
func (b *DDCBackend) probeDDCDevice(bus int, readEDID bool) (*ddcDevice, error) {
|
||||
fd, err := openDDCBus(bus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 {
|
||||
return nil, errno
|
||||
}
|
||||
|
||||
dummy := make([]byte, 32)
|
||||
syscall.Read(fd, dummy) //nolint:errcheck
|
||||
|
||||
writebuf := []byte{DDC_SOURCE_ADDR, 0x80}
|
||||
writebuf = append(writebuf, ddcciChecksum(writebuf))
|
||||
n, err := syscall.Write(fd, writebuf)
|
||||
if err == nil && n == len(writebuf) {
|
||||
name := b.getDDCName(bus)
|
||||
dev := &ddcDevice{
|
||||
bus: bus,
|
||||
addr: DDCCI_ADDR,
|
||||
name: name,
|
||||
if readEDID {
|
||||
edid, err := readBusEDID(fd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isLaptopEDID(edid) {
|
||||
return nil, errors.New("laptop panel")
|
||||
}
|
||||
b.readInitialBrightness(fd, dev)
|
||||
return dev, nil
|
||||
}
|
||||
|
||||
readbuf := make([]byte, 4)
|
||||
n, err = syscall.Read(fd, readbuf)
|
||||
if err != nil || n == 0 {
|
||||
if err := setI2CAddr(fd, DDCCI_ADDR); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !detectX37(fd) {
|
||||
return nil, fmt.Errorf("x37 unresponsive")
|
||||
}
|
||||
|
||||
name := b.getDDCName(bus)
|
||||
|
||||
dev := &ddcDevice{
|
||||
bus: bus,
|
||||
addr: DDCCI_ADDR,
|
||||
name: name,
|
||||
bus: bus,
|
||||
addr: DDCCI_ADDR,
|
||||
name: b.getDDCName(bus),
|
||||
adapter: getI2CDeviceSysfsName(bus),
|
||||
}
|
||||
b.readInitialBrightness(fd, dev)
|
||||
return dev, nil
|
||||
}
|
||||
|
||||
// Based on ddcutil's i2c_open_bus() cross-instance lock (src/base/flock.c): ddcutil, other dms
|
||||
// processes and this one take turns on a bus.
|
||||
func openDDCBus(bus int) (int, error) {
|
||||
// Without O_CLOEXEC a process spawned mid-probe inherits the fd and the flock with it.
|
||||
fd, err := syscall.Open(fmt.Sprintf("/dev/i2c-%d", bus), syscall.O_RDWR|syscall.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
deadline := time.Now().Add(ddcBusLockWait)
|
||||
for {
|
||||
err = unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB)
|
||||
if err == nil {
|
||||
return fd, nil
|
||||
}
|
||||
if !errors.Is(err, unix.EWOULDBLOCK) || time.Now().After(deadline) {
|
||||
syscall.Close(fd)
|
||||
return -1, fmt.Errorf("lock i2c-%d: %w", bus, err)
|
||||
}
|
||||
time.Sleep(ddcBusLockPoll)
|
||||
}
|
||||
}
|
||||
|
||||
func setI2CAddr(fd, addr int) error {
|
||||
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(addr)); errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Based on ddcutil's i2c_detect_x37() (src/i2c/i2c_x37.c).
|
||||
func detectX37(fd int) bool {
|
||||
if n, err := syscall.Read(fd, make([]byte, 1)); err == nil && n == 1 {
|
||||
return true
|
||||
}
|
||||
_, err := syscall.Write(fd, nil)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Based on ddcutil's i2c_get_raw_edid_by_fd() with the fileio reader (src/i2c/i2c_edid.c).
|
||||
func readBusEDID(fd int) ([]byte, error) {
|
||||
if err := setI2CAddr(fd, EDID_ADDR); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var err error
|
||||
consecutiveEIO := 0
|
||||
for _, size := range []int{128, 128, 256, 256} {
|
||||
edid := make([]byte, size)
|
||||
if err = readEDIDOnce(fd, edid); err == nil {
|
||||
return edid[:128], nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, syscall.ENXIO), errors.Is(err, syscall.EOPNOTSUPP), errors.Is(err, syscall.ETIMEDOUT):
|
||||
return nil, err
|
||||
case errors.Is(err, syscall.EIO):
|
||||
consecutiveEIO++
|
||||
if consecutiveEIO >= 2 {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
consecutiveEIO = 0
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func readEDIDOnce(fd int, edid []byte) error {
|
||||
if _, err := syscall.Write(fd, []byte{0x00}); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := syscall.Read(fd, edid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n < 128 || !isValidEDID(edid[:128]) {
|
||||
return errInvalidEDID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DDCBackend) getDDCName(bus int) string {
|
||||
sysfsPath := fmt.Sprintf("/sys/class/i2c-adapter/i2c-%d/name", bus)
|
||||
data, err := os.ReadFile(sysfsPath)
|
||||
@@ -160,15 +273,77 @@ func (b *DDCBackend) getDDCName(bus int) string {
|
||||
}
|
||||
|
||||
func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded and spaced: extra DDC traffic can upset some monitors (#2049).
|
||||
func (b *DDCBackend) rereadUntilKnown(dev *ddcDevice) {
|
||||
for _, delay := range ddcRereadDelays {
|
||||
select {
|
||||
case <-b.stop:
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
read, retry := b.rereadBrightness(dev)
|
||||
if read {
|
||||
b.onReread()
|
||||
return
|
||||
}
|
||||
if !retry {
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Debugf("giving up on reading brightness for %s", dev.id)
|
||||
}
|
||||
|
||||
func (b *DDCBackend) rereadBrightness(dev *ddcDevice) (read, retry bool) {
|
||||
b.ioMutex.Lock()
|
||||
defer b.ioMutex.Unlock()
|
||||
|
||||
if current, ok := b.devices.Load(dev.id); !ok || current != dev || dev.max > 0 {
|
||||
return false, false
|
||||
}
|
||||
switch err := b.checkBusAlive(dev); {
|
||||
case errors.Is(err, errBusReused):
|
||||
return false, false
|
||||
case err != nil:
|
||||
return false, true
|
||||
}
|
||||
|
||||
fd, err := openDDCBus(dev.bus)
|
||||
if err != nil {
|
||||
log.Debugf("brightness reread skipped for %s: %v", dev.id, err)
|
||||
return false, true
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
if err := setI2CAddr(fd, dev.addr); err != nil {
|
||||
return false, false
|
||||
}
|
||||
|
||||
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
|
||||
if err != nil {
|
||||
log.Debugf("failed to read initial brightness for %s: %v", dev.name, err)
|
||||
return
|
||||
log.Debugf("brightness reread failed for %s: %v", dev.id, err)
|
||||
return false, true
|
||||
}
|
||||
|
||||
dev.max = cap.max
|
||||
dev.lastBrightness = cap.current
|
||||
log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max)
|
||||
log.Debugf("read %s brightness %d/%d", dev.id, cap.current, cap.max)
|
||||
return true, false
|
||||
}
|
||||
|
||||
func (b *DDCBackend) GetDevices() ([]Device, error) {
|
||||
@@ -178,6 +353,8 @@ func (b *DDCBackend) GetDevices() ([]Device, error) {
|
||||
|
||||
devices := make([]Device, 0)
|
||||
|
||||
b.ioMutex.Lock()
|
||||
defer b.ioMutex.Unlock()
|
||||
b.devices.Range(func(id string, dev *ddcDevice) bool {
|
||||
devices = append(devices, Device{
|
||||
Class: ClassDDC,
|
||||
@@ -185,7 +362,7 @@ func (b *DDCBackend) GetDevices() ([]Device, error) {
|
||||
Name: dev.name,
|
||||
Current: dev.lastBrightness,
|
||||
Max: dev.max,
|
||||
CurrentPercent: dev.lastBrightness,
|
||||
CurrentPercent: ddcPercentFromRaw(dev.lastBrightness, dev.max),
|
||||
Backend: "ddc",
|
||||
})
|
||||
return true
|
||||
@@ -269,6 +446,9 @@ func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) er
|
||||
return fmt.Errorf("device not found: %s", id)
|
||||
}
|
||||
|
||||
b.ioMutex.Lock()
|
||||
defer b.ioMutex.Unlock()
|
||||
|
||||
busPath := fmt.Sprintf("/dev/i2c-%d", dev.bus)
|
||||
|
||||
if _, err := os.Stat(busPath); os.IsNotExist(err) {
|
||||
@@ -277,52 +457,85 @@ func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) er
|
||||
return fmt.Errorf("device disconnected: %s", id)
|
||||
}
|
||||
|
||||
fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
|
||||
if err != nil {
|
||||
switch err := b.checkBusAlive(dev); {
|
||||
case errors.Is(err, errBusReused):
|
||||
b.devices.Delete(id)
|
||||
log.Debugf("removed DDC device %s (bus number reused by another adapter)", id)
|
||||
return err
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
|
||||
fd, err := openDDCBus(dev.bus)
|
||||
switch {
|
||||
case errors.Is(err, unix.EWOULDBLOCK):
|
||||
return err
|
||||
case err != nil:
|
||||
b.devices.Delete(id)
|
||||
log.Debugf("removed DDC device %s (open failed: %v)", id, err)
|
||||
return fmt.Errorf("open i2c device: %w", err)
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(dev.addr)); errno != 0 {
|
||||
return fmt.Errorf("set i2c slave addr: %w", errno)
|
||||
if err := setI2CAddr(fd, dev.addr); err != nil {
|
||||
return fmt.Errorf("set i2c slave addr: %w", err)
|
||||
}
|
||||
|
||||
max := dev.max
|
||||
if max == 0 {
|
||||
maxValue := dev.max
|
||||
if maxValue == 0 {
|
||||
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current capability: %w", err)
|
||||
}
|
||||
max = cap.max
|
||||
dev.max = max
|
||||
b.devices.Store(id, dev)
|
||||
maxValue = cap.max
|
||||
}
|
||||
if maxValue <= 0 {
|
||||
return fmt.Errorf("%s reported max brightness %d", id, maxValue)
|
||||
}
|
||||
|
||||
if err := b.setVCPFeature(fd, VCP_BRIGHTNESS, value); err != nil {
|
||||
raw := ddcRawFromPercent(value, maxValue)
|
||||
if err := b.setVCPFeature(fd, VCP_BRIGHTNESS, raw); err != nil {
|
||||
return fmt.Errorf("set vcp feature: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("set %s to %d/%d", id, value, max)
|
||||
log.Debugf("set %s to %d/%d", id, raw, maxValue)
|
||||
|
||||
dev.max = max
|
||||
dev.lastBrightness = value
|
||||
dev.max = maxValue
|
||||
dev.lastBrightness = raw
|
||||
b.devices.Store(id, dev)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
|
||||
for range 3 {
|
||||
dummy := make([]byte, 32)
|
||||
n, _ := syscall.Read(fd, dummy)
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
// Based on ddcutil's i2c_check_open_bus_alive() (src/i2c/i2c_bus_core.c), sysfs side only. Known
|
||||
// devices are keyed by bus number, which the kernel hands to the next adapter once one goes away.
|
||||
func (b *DDCBackend) checkBusAlive(dev *ddcDevice) error {
|
||||
if isIgnorableI2CBus(dev.bus) || getI2CDeviceSysfsName(dev.bus) != dev.adapter {
|
||||
return errBusReused
|
||||
}
|
||||
connectors := drmConnectorsByBus()
|
||||
if ddcBusVerdictFor(dev.bus, connectors) == ddcBusSkip {
|
||||
return errNoMonitor
|
||||
}
|
||||
if ddcDisplayAsleep(dev.bus, connectors) {
|
||||
return errDisplayAsleep
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ddcRawFromPercent(percent, maxValue int) int {
|
||||
percent = min(max(percent, 0), 100)
|
||||
return int(math.Round(float64(percent*maxValue) / 100))
|
||||
}
|
||||
|
||||
func ddcPercentFromRaw(raw, maxValue int) int {
|
||||
if maxValue <= 0 {
|
||||
return 0
|
||||
}
|
||||
return min(int(math.Round(float64(raw*100)/float64(maxValue))), 100)
|
||||
}
|
||||
|
||||
func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
|
||||
data := []byte{
|
||||
DDCCI_VCP_GET,
|
||||
vcp,
|
||||
@@ -342,25 +555,6 @@ func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
pollFds := []unix.PollFd{
|
||||
{
|
||||
Fd: int32(fd),
|
||||
Events: unix.POLLIN,
|
||||
},
|
||||
}
|
||||
|
||||
pollTimeout := 200
|
||||
pollResult, err := unix.Poll(pollFds, pollTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll i2c: %w", err)
|
||||
}
|
||||
if pollResult == 0 {
|
||||
return nil, fmt.Errorf("poll timeout after %dms", pollTimeout)
|
||||
}
|
||||
if pollFds[0].Revents&unix.POLLIN == 0 {
|
||||
return nil, fmt.Errorf("poll returned but POLLIN not set")
|
||||
}
|
||||
|
||||
response := make([]byte, 12)
|
||||
n, err = syscall.Read(fd, response)
|
||||
if err != nil || n < 8 {
|
||||
@@ -515,4 +709,8 @@ func (b *DDCBackend) WaitPending() {
|
||||
}
|
||||
|
||||
func (b *DDCBackend) Close() {
|
||||
if b.stop == nil {
|
||||
return
|
||||
}
|
||||
b.closeOnce.Do(func() { close(b.stop) })
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -11,9 +14,12 @@ import (
|
||||
)
|
||||
|
||||
// isIgnorableI2CBus checks if an I2C bus should be skipped during DDC probing.
|
||||
// Based on ddcutil's sysfs_is_ignorable_i2c_device() (sysfs_base.c:1441)
|
||||
// Based on ddcutil's sysfs_is_ignorable_i2c_device() (src/sysfs/sysfs_simple.c)
|
||||
func isIgnorableI2CBus(busno int) bool {
|
||||
name := getI2CDeviceSysfsName(busno)
|
||||
if name == "DPMST" {
|
||||
return false
|
||||
}
|
||||
driver := getI2CSysfsDriver(busno)
|
||||
|
||||
if name != "" && isIgnorableI2CDeviceName(name, driver) {
|
||||
@@ -23,19 +29,20 @@ func isIgnorableI2CBus(busno int) bool {
|
||||
|
||||
// Only probe display adapters (0x03xxxx) and docking stations (0x0axxxx)
|
||||
class := getI2CDeviceSysfsClass(busno)
|
||||
if class != 0 {
|
||||
classHigh := class & 0xFFFF0000
|
||||
ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
|
||||
if ignorable {
|
||||
log.Debugf("i2c-%d: ignoring class 0x%08x", busno, class)
|
||||
}
|
||||
return ignorable
|
||||
if class == 0 {
|
||||
// No PCI class says nothing about a platform adapter, but a real adapter always has a name.
|
||||
return name == ""
|
||||
}
|
||||
|
||||
return false
|
||||
classHigh := class & 0xFFFF0000
|
||||
ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
|
||||
if ignorable {
|
||||
log.Debugf("i2c-%d: ignoring class 0x%08x", busno, class)
|
||||
}
|
||||
return ignorable
|
||||
}
|
||||
|
||||
// Based on ddcutil's ignorable_i2c_device_sysfs_name() (sysfs_base.c:1408)
|
||||
// Based on ddcutil's ignorable_i2c_device_sysfs_name() (src/sysfs/sysfs_simple.c)
|
||||
func isIgnorableI2CDeviceName(name, driver string) bool {
|
||||
ignorablePrefixes := []string{
|
||||
"SMBus",
|
||||
@@ -44,7 +51,8 @@ func isIgnorableI2CDeviceName(name, driver string) bool {
|
||||
"smu",
|
||||
"mac-io",
|
||||
"u4",
|
||||
"AMDGPU SMU", // AMD Navi2+ - probing hangs GPU
|
||||
"AMDGPU SMU", // AMD Navi2+ - probing hangs GPU
|
||||
"AMDGPU DM i2c OEM bus", // RGB controllers, not displays
|
||||
}
|
||||
|
||||
for _, prefix := range ignorablePrefixes {
|
||||
@@ -71,65 +79,194 @@ func getI2CDeviceSysfsName(busno int) string {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// Based on ddcutil's get_i2c_device_sysfs_class() (sysfs_base.c:1380)
|
||||
// Based on ddcutil's get_i2c_device_sysfs_class() (src/sysfs/sysfs_simple.c)
|
||||
func getI2CDeviceSysfsClass(busno int) uint32 {
|
||||
classPath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/class", busno)
|
||||
data, err := os.ReadFile(classPath)
|
||||
if err != nil {
|
||||
classPath = fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/device/device/class", busno)
|
||||
data, err = os.ReadFile(classPath)
|
||||
paths := []string{
|
||||
fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno),
|
||||
fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device", busno),
|
||||
fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/i2c-dev/i2c-%d/device", busno, busno),
|
||||
}
|
||||
for _, path := range paths {
|
||||
adapter := findI2CAdapter(path)
|
||||
if adapter == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(adapter, "class"))
|
||||
if err != nil {
|
||||
return 0
|
||||
continue
|
||||
}
|
||||
class, err := strconv.ParseUint(strings.TrimPrefix(strings.TrimSpace(string(data)), "0x"), 16, 32)
|
||||
if err == nil && class != 0 {
|
||||
return uint32(class)
|
||||
}
|
||||
}
|
||||
|
||||
classStr := strings.TrimSpace(string(data))
|
||||
classStr = strings.TrimPrefix(classStr, "0x")
|
||||
|
||||
class, err := strconv.ParseUint(classStr, 16, 32)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return uint32(class)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Based on ddcutil's get_i2c_sysfs_driver_by_busno() (sysfs_base.c:1284)
|
||||
// Based on ddcutil's get_driver_for_busno() (src/sysfs/sysfs_simple.c)
|
||||
func getI2CSysfsDriver(busno int) string {
|
||||
devicePath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno)
|
||||
adapterPath, err := findI2CAdapter(devicePath)
|
||||
adapter := findI2CAdapter(fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno))
|
||||
if adapter == "" {
|
||||
return ""
|
||||
}
|
||||
module, err := filepath.EvalSymlinks(filepath.Join(adapter, "driver", "module"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(module)
|
||||
}
|
||||
|
||||
driverLink := filepath.Join(adapterPath, "driver")
|
||||
target, err := os.Readlink(driverLink)
|
||||
// Based on ddcutil's sysfs_find_adapter() (src/sysfs/sysfs_simple.c): the nearest
|
||||
// ancestor under /sys/devices carrying a class attribute.
|
||||
func findI2CAdapter(path string) string {
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return filepath.Base(target)
|
||||
}
|
||||
|
||||
func findI2CAdapter(devicePath string) (string, error) {
|
||||
currentPath := devicePath
|
||||
|
||||
for range 10 {
|
||||
if _, err := os.Stat(filepath.Join(currentPath, "name")); err == nil {
|
||||
return currentPath, nil
|
||||
for strings.HasPrefix(resolved, "/sys/devices") {
|
||||
if info, err := os.Stat(filepath.Join(resolved, "class")); err == nil && info.Mode().IsRegular() {
|
||||
return resolved
|
||||
}
|
||||
|
||||
deviceLink := filepath.Join(currentPath, "device")
|
||||
target, err := os.Readlink(deviceLink)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(currentPath), target)
|
||||
}
|
||||
currentPath = filepath.Clean(target)
|
||||
resolved = filepath.Dir(resolved)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find adapter for %s", devicePath)
|
||||
// ddcutil's known_reliable_drivers (src/sysfs/sysfs_base.c): drivers that keep the DRM connector
|
||||
// edid, status and dpms attributes current.
|
||||
var sysfsReliableDrivers = []string{"i915", "xe", "amdgpu", "radeon", "nouveau"}
|
||||
|
||||
var (
|
||||
drmConnectorPattern = regexp.MustCompile(`^card[0-9]+-`)
|
||||
i2cBusPattern = regexp.MustCompile(`^i2c-([0-9]+)$`)
|
||||
)
|
||||
|
||||
type ddcBusVerdict int
|
||||
|
||||
const (
|
||||
ddcBusSkip ddcBusVerdict = iota
|
||||
ddcBusHasEDID
|
||||
ddcBusNeedsEDIDRead
|
||||
)
|
||||
|
||||
// Based on ddcutil's i2c_edid_exists() and its laptop panel exclusion (src/i2c/i2c_bus_core.c).
|
||||
func ddcBusVerdictFor(busno int, connectors map[int]string) ddcBusVerdict {
|
||||
name := getI2CDeviceSysfsName(busno)
|
||||
displayLink := name == "DisplayLink I2C Adapter"
|
||||
reliable := slices.Contains(sysfsReliableDrivers, getI2CSysfsDriver(busno))
|
||||
|
||||
connector, mapped := connectors[busno]
|
||||
if !mapped {
|
||||
if reliable && !displayLink && name != "DPMST" && len(connectors) > 0 {
|
||||
return ddcBusSkip
|
||||
}
|
||||
return ddcBusNeedsEDIDRead
|
||||
}
|
||||
if strings.Contains(connector, "-eDP-") || strings.Contains(connector, "-LVDS-") {
|
||||
return ddcBusSkip
|
||||
}
|
||||
if reliable || displayLink {
|
||||
if len(drmConnectorAttr(connector, "edid")) >= 128 {
|
||||
return ddcBusHasEDID
|
||||
}
|
||||
return ddcBusSkip
|
||||
}
|
||||
if strings.TrimSpace(drmConnectorAttr(connector, "status")) == "disconnected" {
|
||||
return ddcBusSkip
|
||||
}
|
||||
return ddcBusNeedsEDIDRead
|
||||
}
|
||||
|
||||
// Based on ddcutil's dpms_check_drm_asleep_by_businfo() (src/sysfs/sysfs_dpms.c).
|
||||
func ddcDisplayAsleep(busno int, connectors map[int]string) bool {
|
||||
connector, mapped := connectors[busno]
|
||||
if !mapped || !slices.Contains(sysfsReliableDrivers, getI2CSysfsDriver(busno)) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(drmConnectorAttr(connector, "dpms")) != "On"
|
||||
}
|
||||
|
||||
// Based on ddcutil's get_connector_bus_numbers() (src/sysfs/sysfs_simple.c): a DP connector's own
|
||||
// i2c-N directory, any other connector's ddc/i2c-dev/i2c-N.
|
||||
func drmConnectorsByBus() map[int]string {
|
||||
connectors := map[int]string{}
|
||||
entries, err := os.ReadDir("/sys/class/drm")
|
||||
if err != nil {
|
||||
return connectors
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !drmConnectorPattern.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join("/sys/class/drm", name)
|
||||
if !strings.Contains(name, "-DP-") {
|
||||
dir = filepath.Join(dir, "ddc", "i2c-dev")
|
||||
}
|
||||
if busno, ok := i2cSubdirBus(dir); ok {
|
||||
connectors[busno] = name
|
||||
}
|
||||
}
|
||||
return connectors
|
||||
}
|
||||
|
||||
func i2cSubdirBus(dir string) (int, bool) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
match := i2cBusPattern.FindStringSubmatch(entry.Name())
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
if busno, err := strconv.Atoi(match[1]); err == nil {
|
||||
return busno, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func drmConnectorAttr(connector, attr string) string {
|
||||
data, err := os.ReadFile(filepath.Join("/sys/class/drm", connector, attr))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
var edidHeader = []byte{0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00}
|
||||
|
||||
// Based on ddcutil's is_valid_raw_edid() (src/util/edid.c).
|
||||
func isValidEDID(edid []byte) bool {
|
||||
if len(edid) < 128 || !bytes.Equal(edid[:8], edidHeader) {
|
||||
return false
|
||||
}
|
||||
var sum byte
|
||||
for _, b := range edid[:128] {
|
||||
sum += b
|
||||
}
|
||||
return sum == 0
|
||||
}
|
||||
|
||||
// Based on ddcutil's is_laptop_parsed_edid() (src/util/edid.c).
|
||||
func isLaptopEDID(edid []byte) bool {
|
||||
return edidDescriptorText(edid, 0xfc) == "" && edidDescriptorText(edid, 0xff) == ""
|
||||
}
|
||||
|
||||
// Based on ddcutil's get_edid_descriptor_strings() (src/util/edid.c).
|
||||
func edidDescriptorText(edid []byte, tag byte) string {
|
||||
text := ""
|
||||
for i := range 4 {
|
||||
descriptor := edid[54+i*18 : 72+i*18]
|
||||
if descriptor[0] != 0 || descriptor[1] != 0 || descriptor[2] != 0 || descriptor[4] != 0 || descriptor[3] != tag {
|
||||
continue
|
||||
}
|
||||
raw := descriptor[5:18]
|
||||
if end := bytes.IndexByte(raw, 0x0a); end >= 0 {
|
||||
raw = raw[:end]
|
||||
}
|
||||
text = strings.TrimRight(string(raw), " \t\n\v\f\r")
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package brightness
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -42,12 +42,12 @@ func (m *Manager) initLogind() {
|
||||
}
|
||||
|
||||
func (m *Manager) initDDC() {
|
||||
if os.Getenv("DMS_NO_DDC") != "" {
|
||||
ddc, err := newDDCBackend(m.updateState)
|
||||
switch {
|
||||
case errors.Is(err, errDDCDisabled):
|
||||
log.Info("DDC backend disabled via DMS_NO_DDC")
|
||||
return
|
||||
}
|
||||
ddc, err := NewDDCBackend()
|
||||
if err != nil {
|
||||
case err != nil:
|
||||
log.Debugf("Failed to initialize DDC backend: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ type DDCBackend struct {
|
||||
debounceTimers map[string]*time.Timer
|
||||
debouncePending map[string]ddcPendingSet
|
||||
debounceWg sync.WaitGroup
|
||||
|
||||
ioMutex sync.Mutex
|
||||
onReread func()
|
||||
stop chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type ddcPendingSet struct {
|
||||
@@ -108,6 +113,7 @@ type ddcDevice struct {
|
||||
addr int
|
||||
id string
|
||||
name string
|
||||
adapter string
|
||||
max int
|
||||
lastBrightness int
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ func HandleRequest(conn *models.Conn, req models.Request, m *Manager) {
|
||||
handlePinEntry(conn, req, m)
|
||||
case "clipboard.unpinEntry":
|
||||
handleUnpinEntry(conn, req, m)
|
||||
case "clipboard.editEntry":
|
||||
handleEditEntry(conn, req, m)
|
||||
case "clipboard.getPinnedEntries":
|
||||
handleGetPinnedEntries(conn, req, m)
|
||||
case "clipboard.getPinnedCount":
|
||||
@@ -422,3 +424,24 @@ func handleCopyFile(conn *models.Conn, req models.Request, m *Manager) {
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"})
|
||||
}
|
||||
|
||||
func handleEditEntry(conn *models.Conn, req models.Request, m *Manager) {
|
||||
id, err := params.Int(req.Params, "id")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
text, err := params.String(req.Params, "text")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.EditEntry(uint64(id), text); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry updated"})
|
||||
}
|
||||
|
||||
@@ -795,6 +795,13 @@ func selectAltTextMimeType(mimes []string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func isTextMimeType(mime string) bool {
|
||||
if mime == "" || strings.HasPrefix(mime, "text/plain") {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(altTextMimeTypes, mime)
|
||||
}
|
||||
|
||||
func (m *Manager) isImageMimeType(mime string) bool {
|
||||
return strings.HasPrefix(mime, "image/")
|
||||
}
|
||||
@@ -1963,6 +1970,113 @@ func (m *Manager) UnpinEntry(id uint64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) EditEntry(id uint64, text string) error {
|
||||
if m.db == nil {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
data := []byte(text)
|
||||
mimeType := "text/plain;charset=utf-8"
|
||||
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return fmt.Errorf("cannot save empty entry")
|
||||
}
|
||||
|
||||
cfg := m.getConfig()
|
||||
if int64(len(data)) > cfg.MaxEntrySize {
|
||||
return fmt.Errorf("data too large")
|
||||
}
|
||||
|
||||
newHash := computeHash(data)
|
||||
preview := m.textPreview(data)
|
||||
|
||||
err := m.dbUpdate(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("clipboard"))
|
||||
if b == nil {
|
||||
return fmt.Errorf("clipboard bucket missing")
|
||||
}
|
||||
|
||||
oldKey := itob(id)
|
||||
v := b.Get(oldKey)
|
||||
if v == nil {
|
||||
return errEntryNotFound
|
||||
}
|
||||
|
||||
existing, err := decodeEntry(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.IsImage {
|
||||
return errors.New("cannot edit image entry")
|
||||
}
|
||||
|
||||
if !isTextMimeType(existing.MimeType) {
|
||||
return errors.New("cannot edit non-text entry")
|
||||
}
|
||||
|
||||
wasPinned := existing.Pinned
|
||||
|
||||
if err := b.Delete(oldKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.deduplicateInTx(b, newHash); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if wasPinned {
|
||||
c := b.Cursor()
|
||||
for k, val := c.First(); k != nil; k, val = c.Next() {
|
||||
if extractHash(val) == newHash {
|
||||
meta, err := decodeEntryMeta(val)
|
||||
if err == nil && meta.Pinned {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newID, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newEntry := Entry{
|
||||
ID: newID,
|
||||
Data: data,
|
||||
MimeType: mimeType,
|
||||
Preview: preview,
|
||||
Size: len(data),
|
||||
Timestamp: time.Now(),
|
||||
IsImage: false,
|
||||
Hash: newHash,
|
||||
Pinned: wasPinned,
|
||||
}
|
||||
|
||||
encoded, err := encodeEntry(newEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := b.Put(itob(newID), encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.trimLengthInTx(b)
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
if clipErr := m.SetClipboard(data, mimeType); clipErr != nil {
|
||||
log.Errorf("Failed to set clipboard selection: %v", clipErr)
|
||||
}
|
||||
m.updateState()
|
||||
m.notifySubscribers()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) GetPinnedEntries() []Entry {
|
||||
if m.db == nil {
|
||||
return nil
|
||||
|
||||
@@ -45,9 +45,15 @@ func newTestManagerWithDB(t *testing.T) *Manager {
|
||||
db.Close()
|
||||
})
|
||||
|
||||
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
||||
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
|
||||
fn()
|
||||
}).Maybe()
|
||||
|
||||
return &Manager{
|
||||
config: DefaultConfig(),
|
||||
db: db,
|
||||
wlCtx: mockCtx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,6 +584,175 @@ func TestCreateHistoryEntryFromPinned_KeepsLatestUnpinnedDuplicate(t *testing.T)
|
||||
assert.NotEqual(t, firstDuplicate.ID, latestDuplicate.ID)
|
||||
}
|
||||
|
||||
func TestEditEntry_UnpinnedEntry(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
|
||||
id := storeTestEntry(t, m, "original unpinned")
|
||||
require.NoError(t, m.EditEntry(id, "edited unpinned"))
|
||||
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
assert.Equal(t, "edited unpinned", history[0].Preview)
|
||||
assert.False(t, history[0].Pinned)
|
||||
assert.NotEqual(t, id, history[0].ID)
|
||||
|
||||
// Old entry should not exist
|
||||
oldEntry, err := m.GetEntry(id)
|
||||
assert.ErrorIs(t, err, errEntryNotFound)
|
||||
assert.Nil(t, oldEntry)
|
||||
}
|
||||
|
||||
func TestEditEntry_PinnedEntryRemainsPinned(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
|
||||
id := storeTestEntry(t, m, "original pinned")
|
||||
require.NoError(t, m.PinEntry(id))
|
||||
assert.Equal(t, 1, m.GetPinnedCount())
|
||||
|
||||
require.NoError(t, m.EditEntry(id, "edited pinned"))
|
||||
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
assert.Equal(t, "edited pinned", history[0].Preview)
|
||||
assert.True(t, history[0].Pinned)
|
||||
|
||||
pinnedEntries := m.GetPinnedEntries()
|
||||
require.Len(t, pinnedEntries, 1)
|
||||
assert.Equal(t, "edited pinned", pinnedEntries[0].Preview)
|
||||
assert.Equal(t, 1, m.GetPinnedCount())
|
||||
|
||||
// Old pinned entry should be deleted
|
||||
oldEntry, err := m.GetEntry(id)
|
||||
assert.ErrorIs(t, err, errEntryNotFound)
|
||||
assert.Nil(t, oldEntry)
|
||||
}
|
||||
|
||||
func TestEditEntry_NotFound(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
|
||||
err := m.EditEntry(99999, "new text")
|
||||
assert.ErrorIs(t, err, errEntryNotFound)
|
||||
}
|
||||
|
||||
func TestEditEntry_ImageReturnsError(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
|
||||
imgEntry := Entry{
|
||||
Data: []byte{0x89, 0x50, 0x4E, 0x47},
|
||||
MimeType: "image/png",
|
||||
Preview: "[[ image ]]",
|
||||
Size: 4,
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
IsImage: true,
|
||||
}
|
||||
require.NoError(t, m.storeEntry(imgEntry))
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
id := history[0].ID
|
||||
|
||||
err := m.EditEntry(id, "replacement text")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot edit image entry")
|
||||
}
|
||||
|
||||
func TestEditEntry_NonTextReturnsError(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
|
||||
uriEntry := Entry{
|
||||
Data: []byte("file:///path/to/file\n"),
|
||||
MimeType: "text/uri-list",
|
||||
Preview: "file:///path/to/file",
|
||||
Size: 21,
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
IsImage: false,
|
||||
}
|
||||
require.NoError(t, m.storeEntry(uriEntry))
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
id := history[0].ID
|
||||
|
||||
err := m.EditEntry(id, "replacement text")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot edit non-text entry")
|
||||
}
|
||||
|
||||
func TestEditEntry_AltTextMimeTypesAllowed(t *testing.T) {
|
||||
for _, mime := range []string{"UTF8_STRING", "STRING", "TEXT", "text/plain;charset=utf-8", "text/plain"} {
|
||||
t.Run(mime, func(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
entry := Entry{
|
||||
Data: []byte("old text"),
|
||||
MimeType: mime,
|
||||
Preview: "old text",
|
||||
Size: 8,
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
IsImage: false,
|
||||
}
|
||||
require.NoError(t, m.storeEntry(entry))
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
id := history[0].ID
|
||||
|
||||
err := m.EditEntry(id, "replacement text")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditEntry_EmptyOrWhitespaceReturnsError(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
id := storeTestEntry(t, m, "keep me")
|
||||
|
||||
for _, badText := range []string{"", " ", "\t\n\r"} {
|
||||
err := m.EditEntry(id, badText)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot save empty entry")
|
||||
}
|
||||
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
assert.Equal(t, "keep me", history[0].Preview)
|
||||
}
|
||||
|
||||
func TestEditEntry_DataTooLargeReturnsError(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
m.config.MaxEntrySize = 10
|
||||
id := storeTestEntry(t, m, "small")
|
||||
|
||||
err := m.EditEntry(id, "this text exceeds the 10-byte limit")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "data too large")
|
||||
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
assert.Equal(t, "small", history[0].Preview)
|
||||
}
|
||||
|
||||
func TestHandleEditEntry_SuccessAndValidation(t *testing.T) {
|
||||
m := newTestManagerWithDB(t)
|
||||
id := storeTestEntry(t, m, "before edit")
|
||||
|
||||
mc := newClipboardTestConn()
|
||||
conn := models.NewConn(mc)
|
||||
handleEditEntry(conn, models.Request{
|
||||
ID: 1,
|
||||
Params: map[string]any{
|
||||
"id": float64(id),
|
||||
"text": "after edit",
|
||||
},
|
||||
}, m)
|
||||
|
||||
var resp models.Response[models.SuccessResult]
|
||||
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
|
||||
assert.Empty(t, resp.Error)
|
||||
require.NotNil(t, resp.Result)
|
||||
assert.True(t, resp.Result.Success)
|
||||
|
||||
history := m.GetHistory()
|
||||
require.Len(t, history, 1)
|
||||
assert.Equal(t, "after edit", history[0].Preview)
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
subscribers: make(map[string]chan State),
|
||||
|
||||
@@ -126,7 +126,7 @@ func findKeyboards() ([]EvdevDevice, error) {
|
||||
|
||||
var keyboards []EvdevDevice
|
||||
for _, path := range matches {
|
||||
device, err := evdev.Open(path)
|
||||
device, err := evdev.OpenWithFlags(path, os.O_RDONLY)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func (m *Manager) watchForNewKeyboards() {
|
||||
continue
|
||||
}
|
||||
|
||||
device, err := evdev.Open(event.Name)
|
||||
device, err := evdev.OpenWithFlags(event.Name, os.O_RDONLY)
|
||||
if err != nil {
|
||||
m.devicesMutex.Unlock()
|
||||
continue
|
||||
|
||||
@@ -154,23 +154,7 @@ func (b *IWDBackend) applyManagedObjects(objects map[dbus.ObjectPath]map[string]
|
||||
}
|
||||
if _, hasDevice := interfaces[iwdDeviceInterface]; hasDevice {
|
||||
b.devicePath = path
|
||||
|
||||
if devProps, ok := interfaces[iwdDeviceInterface]; ok {
|
||||
if nameVar, ok := devProps["Name"]; ok {
|
||||
if name, ok := nameVar.Value().(string); ok {
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiDevice = name
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
}
|
||||
if poweredVar, ok := devProps["Powered"]; ok {
|
||||
if powered, ok := poweredVar.Value().(bool); ok {
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiEnabled = powered
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
b.applyDeviceProps(interfaces[iwdDeviceInterface])
|
||||
}
|
||||
if _, hasAdapter := interfaces[iwdAdapterInterface]; hasAdapter {
|
||||
b.adapterPath = path
|
||||
@@ -193,6 +177,47 @@ func (b *IWDBackend) applyManagedObjects(objects map[dbus.ObjectPath]map[string]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IWDBackend) applyDeviceProps(props map[string]dbus.Variant) {
|
||||
if nameVar, ok := props["Name"]; ok {
|
||||
if name, ok := nameVar.Value().(string); ok {
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiDevice = name
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
}
|
||||
if poweredVar, ok := props["Powered"]; ok {
|
||||
if powered, ok := poweredVar.Value().(bool); ok {
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiEnabled = powered
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setDevice records a device that appeared at a new path, which is what
|
||||
// happens when iwd restarts and recreates wlan0. It returns the path it
|
||||
// replaced so the caller can move the signal match over.
|
||||
func (b *IWDBackend) setDevice(path dbus.ObjectPath, props map[string]dbus.Variant) (prev dbus.ObjectPath, changed bool) {
|
||||
if path == "" || path == b.devicePath {
|
||||
return "", false
|
||||
}
|
||||
prev = b.devicePath
|
||||
b.devicePath = path
|
||||
b.applyDeviceProps(props)
|
||||
return prev, true
|
||||
}
|
||||
|
||||
func (b *IWDBackend) clearDevice(path dbus.ObjectPath) bool {
|
||||
if path == "" || path != b.devicePath {
|
||||
return false
|
||||
}
|
||||
b.devicePath = ""
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiEnabled = false
|
||||
b.stateMutex.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *IWDBackend) GetCurrentState() (*BackendState, error) {
|
||||
b.stateMutex.RLock()
|
||||
defer b.stateMutex.RUnlock()
|
||||
|
||||
@@ -26,12 +26,7 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
|
||||
b.conn.Signal(sigChan)
|
||||
|
||||
if b.devicePath != "" {
|
||||
err := b.conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(b.devicePath),
|
||||
dbus.WithMatchInterface(dbusPropertiesInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
if err != nil {
|
||||
if err := b.addDeviceSignalMatch(b.devicePath); err != nil {
|
||||
return fmt.Errorf("failed to add device signal match: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +81,48 @@ func (b *IWDBackend) addStationSignalMatch(path dbus.ObjectPath) error {
|
||||
)
|
||||
}
|
||||
|
||||
func (b *IWDBackend) addDeviceSignalMatch(path dbus.ObjectPath) error {
|
||||
return b.conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(path),
|
||||
dbus.WithMatchInterface(dbusPropertiesInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
}
|
||||
|
||||
func (b *IWDBackend) removeDeviceSignalMatch(path dbus.ObjectPath) {
|
||||
err := b.conn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(path),
|
||||
dbus.WithMatchInterface(dbusPropertiesInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to remove iwd device signal match for %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *IWDBackend) handleDeviceAdded(path dbus.ObjectPath, props map[string]dbus.Variant) bool {
|
||||
prev, changed := b.setDevice(path, props)
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
|
||||
if prev != "" {
|
||||
b.removeDeviceSignalMatch(prev)
|
||||
}
|
||||
if err := b.addDeviceSignalMatch(path); err != nil {
|
||||
log.Warnf("Failed to add iwd device signal match for %s: %v", path, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *IWDBackend) handleDeviceRemoved(path dbus.ObjectPath) bool {
|
||||
if !b.clearDevice(path) {
|
||||
return false
|
||||
}
|
||||
b.removeDeviceSignalMatch(path)
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *IWDBackend) handleStationAdded(path dbus.ObjectPath) bool {
|
||||
if path == "" || path == b.stationPath {
|
||||
return false
|
||||
@@ -139,6 +176,12 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
if len(sig.Body) >= 2 {
|
||||
path, _ := sig.Body[0].(dbus.ObjectPath)
|
||||
if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok {
|
||||
// Device before Station: handleStationAdded reads Powered from devicePath.
|
||||
if props, ok := interfaces[iwdDeviceInterface]; ok {
|
||||
if b.handleDeviceAdded(path, props) && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
}
|
||||
if _, ok := interfaces[iwdStationInterface]; ok {
|
||||
if b.handleStationAdded(path) && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
@@ -159,17 +202,19 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
path, _ := sig.Body[0].(dbus.ObjectPath)
|
||||
if interfaces, ok := sig.Body[1].([]string); ok {
|
||||
for _, iface := range interfaces {
|
||||
if iface == iwdStationInterface {
|
||||
switch iface {
|
||||
case iwdDeviceInterface:
|
||||
if b.handleDeviceRemoved(path) && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
case iwdStationInterface:
|
||||
if b.handleStationRemoved(path) && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
break
|
||||
}
|
||||
if iface == iwdKnownNetworkInterface {
|
||||
case iwdKnownNetworkInterface:
|
||||
if b.refreshWiFiNetworkState() && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,3 +460,71 @@ func TestConnectAttempt_DoubleFinalization(t *testing.T) {
|
||||
assert.Equal(t, "bad-credentials", backend.state.LastError)
|
||||
backend.stateMutex.RUnlock()
|
||||
}
|
||||
|
||||
func TestIWDBackend_SetDevice_FollowsRecreatedDevice(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
err := backend.applyManagedObjects(map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
|
||||
"/net/connman/iwd/0/3": {
|
||||
iwdDeviceInterface: {"Name": dbus.MakeVariant("wlan0"), "Powered": dbus.MakeVariant(true)},
|
||||
iwdStationInterface: {},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// iwd restarts: the old objects go away and wlan0 comes back with a new ifindex.
|
||||
assert.True(t, backend.handleStationRemoved("/net/connman/iwd/0/3"))
|
||||
assert.True(t, backend.clearDevice("/net/connman/iwd/0/3"))
|
||||
|
||||
prev, changed := backend.setDevice("/net/connman/iwd/0/7", map[string]dbus.Variant{
|
||||
"Name": dbus.MakeVariant("wlan0"),
|
||||
"Powered": dbus.MakeVariant(true),
|
||||
})
|
||||
|
||||
assert.True(t, changed)
|
||||
assert.Empty(t, prev)
|
||||
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/7"), backend.devicePath)
|
||||
|
||||
enabled, _ := backend.GetWiFiEnabled()
|
||||
assert.True(t, enabled, "Wi-Fi must be enabled again once the recreated device reports Powered")
|
||||
}
|
||||
|
||||
func TestIWDBackend_SetDevice_ReplacesPathWithoutRemovalSignal(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
backend.devicePath = "/net/connman/iwd/0/3"
|
||||
|
||||
prev, changed := backend.setDevice("/net/connman/iwd/0/7", map[string]dbus.Variant{
|
||||
"Powered": dbus.MakeVariant(true),
|
||||
})
|
||||
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/3"), prev)
|
||||
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/7"), backend.devicePath)
|
||||
}
|
||||
|
||||
func TestIWDBackend_SetDevice_SamePathIsNoop(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
backend.devicePath = "/net/connman/iwd/0/3"
|
||||
|
||||
_, changed := backend.setDevice("/net/connman/iwd/0/3", map[string]dbus.Variant{
|
||||
"Powered": dbus.MakeVariant(false),
|
||||
})
|
||||
assert.False(t, changed)
|
||||
|
||||
_, changed = backend.setDevice("", nil)
|
||||
assert.False(t, changed)
|
||||
}
|
||||
|
||||
func TestIWDBackend_ClearDevice(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
backend.devicePath = "/net/connman/iwd/0/3"
|
||||
|
||||
assert.False(t, backend.clearDevice("/net/connman/iwd/0/9"), "unrelated path must be ignored")
|
||||
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/3"), backend.devicePath)
|
||||
|
||||
assert.True(t, backend.clearDevice("/net/connman/iwd/0/3"))
|
||||
assert.Empty(t, backend.devicePath)
|
||||
|
||||
enabled, _ := backend.GetWiFiEnabled()
|
||||
assert.False(t, enabled)
|
||||
assert.ErrorContains(t, backend.SetWiFiEnabled(true), "no WiFi device available")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -30,12 +31,54 @@ func (l *linkInfo) isWired() bool {
|
||||
if looksVirtual(l.name) {
|
||||
return false
|
||||
}
|
||||
if l.linkType == "bridge" {
|
||||
return hasPhysicalMember(l.name)
|
||||
}
|
||||
if l.linkType != "" {
|
||||
return l.linkType == "ether"
|
||||
return isWiredLinkType(l.linkType)
|
||||
}
|
||||
return !strings.HasPrefix(l.name, "wlan") && !strings.HasPrefix(l.name, "wlp")
|
||||
}
|
||||
|
||||
// isWiredLinkType reports whether a networkd link Type can carry the wired
|
||||
// uplink. Link aggregates and bridges hold the IP configuration while the
|
||||
// enslaved member NIC stays in the "enslaved" state, same as #1581 for
|
||||
// NetworkManager. Bridges are further checked in isWired.
|
||||
func isWiredLinkType(t string) bool {
|
||||
switch t {
|
||||
case "ether", "bridge", "bond", "team", "vlan":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bridgeMembers lists the interfaces enslaved to a bridge. It is a variable so
|
||||
// tests can stub the sysfs lookup.
|
||||
var bridgeMembers = func(bridge string) []string {
|
||||
entries, err := os.ReadDir("/sys/class/net/" + bridge + "/brif")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// hasPhysicalMember reports whether a bridge has at least one member that is
|
||||
// not virtual (veth, tap, ...). This tells a host bridge carrying the uplink
|
||||
// apart from container/VM bridges (lxc, waydroid, ...) that only hold virtual
|
||||
// members.
|
||||
func hasPhysicalMember(bridge string) bool {
|
||||
for _, m := range bridgeMembers(bridge) {
|
||||
if !looksVirtual(m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (l *linkInfo) isWireless() bool {
|
||||
if looksVirtual(l.name) {
|
||||
return false
|
||||
|
||||
@@ -166,6 +166,15 @@ func TestLinkInfo_Classify(t *testing.T) {
|
||||
{"veth ether excluded", "veth1234", "ether", false, false},
|
||||
{"podman bridge ether excluded", "podman3", "ether", false, false},
|
||||
{"docker bridge ether excluded", "docker0", "ether", false, false},
|
||||
// The IP lives on the aggregate while the member NIC is "enslaved" (#3463).
|
||||
{"bridge type", "br0", "bridge", true, false},
|
||||
{"bond type", "bond0", "bond", true, false},
|
||||
{"team type", "team0", "team", true, false},
|
||||
{"vlan type", "vlan10", "vlan", true, false},
|
||||
{"docker user bridge excluded", "br-1a2b3c", "bridge", false, false},
|
||||
{"libvirt bridge excluded", "virbr0", "bridge", false, false},
|
||||
{"bridge with only virtual members excluded", "lxcbr0", "bridge", false, false},
|
||||
{"bridge without members excluded", "waydroid0", "bridge", false, false},
|
||||
// Fallback path: linkType unavailable, name-prefix heuristic applies.
|
||||
{"fallback enp wired", "enp141s0", "", true, false},
|
||||
{"fallback wlan wireless", "wlan0", "", false, true},
|
||||
@@ -174,6 +183,15 @@ func TestLinkInfo_Classify(t *testing.T) {
|
||||
{"fallback docker skipped", "docker0", "", false, false},
|
||||
{"fallback tun skipped", "tun0", "", false, false},
|
||||
}
|
||||
members := map[string][]string{
|
||||
"br0": {"enp42s0", "vnet0"},
|
||||
"lxcbr0": {"vethAbCd12"},
|
||||
"waydroid0": nil,
|
||||
}
|
||||
orig := bridgeMembers
|
||||
bridgeMembers = func(b string) []string { return members[b] }
|
||||
t.Cleanup(func() { bridgeMembers = orig })
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
l := &linkInfo{name: tc.ifname, linkType: tc.linkType}
|
||||
@@ -254,7 +272,7 @@ func TestLooksVirtual(t *testing.T) {
|
||||
for _, n := range virtual {
|
||||
assert.True(t, looksVirtual(n), "%s should look virtual", n)
|
||||
}
|
||||
real := []string{"enp141s0", "eno1", "wlan0", "wlp3s0", "wifi", "dock", "nebula.homelab", "wg0"}
|
||||
real := []string{"enp141s0", "eno1", "wlan0", "wlp3s0", "wifi", "dock", "nebula.homelab", "wg0", "br0", "bond0", "team0", "vlan10"}
|
||||
for _, n := range real {
|
||||
assert.False(t, looksVirtual(n), "%s should not look virtual", n)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ const (
|
||||
|
||||
metricPreferred = int64(100)
|
||||
metricNonPreferred = int64(300)
|
||||
metricDefault = int64(100)
|
||||
// -1 lets NetworkManager pick the metric by device type (nm-settings ipv4.route-metric)
|
||||
metricDefault = int64(-1)
|
||||
)
|
||||
|
||||
func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
|
||||
@@ -46,7 +47,7 @@ func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
|
||||
case PreferenceCellular:
|
||||
return m.prioritizeCellular()
|
||||
case PreferenceAuto:
|
||||
return m.balancePriorities()
|
||||
return m.restoreDefaultPriorities()
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -112,7 +113,7 @@ func (m *Manager) prioritizeCellular() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) balancePriorities() error {
|
||||
func (m *Manager) restoreDefaultPriorities() error {
|
||||
if err := m.setConnectionPriority("802-3-ethernet", priorityDefault, metricDefault); err != nil {
|
||||
log.Warnf("Failed to reset Ethernet priority: %v", err)
|
||||
}
|
||||
@@ -242,16 +243,20 @@ func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int
|
||||
}
|
||||
|
||||
func priorityMatches(variant dbus.Variant, expected int64) bool {
|
||||
value, ok := variantInt64(variant)
|
||||
return ok && value == expected
|
||||
return settingInt64(variant, int64(priorityDefault)) == expected
|
||||
}
|
||||
|
||||
func routeMetricMatches(section map[string]dbus.Variant, expected int64) bool {
|
||||
if section == nil {
|
||||
return false
|
||||
return settingInt64(section["route-metric"], metricDefault) == expected
|
||||
}
|
||||
|
||||
// GetSettings omits properties at their default, so a missing key is the default value
|
||||
func settingInt64(variant dbus.Variant, def int64) int64 {
|
||||
value, ok := variantInt64(variant)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
value, ok := variantInt64(section["route-metric"])
|
||||
return ok && value == expected
|
||||
return value
|
||||
}
|
||||
|
||||
func variantInt64(variant dbus.Variant) (int64, bool) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package network
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -45,7 +46,10 @@ func TestManager_GetConnectionPreference(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Full testing of priority operations would require mocking NetworkManager
|
||||
// D-Bus interfaces. The tests above cover the basic logic and error handling.
|
||||
// Integration tests would be needed for complete coverage of network connection
|
||||
// priority updates and reactivation.
|
||||
func TestProfileAtDefaultsMatchesAutoTargets(t *testing.T) {
|
||||
assert.True(t, priorityMatches(dbus.Variant{}, int64(priorityDefault)))
|
||||
assert.True(t, routeMetricMatches(nil, metricDefault))
|
||||
assert.True(t, routeMetricMatches(map[string]dbus.Variant{"method": dbus.MakeVariant("auto")}, metricDefault))
|
||||
assert.False(t, routeMetricMatches(map[string]dbus.Variant{"route-metric": dbus.MakeVariant(int64(100))}, metricDefault))
|
||||
assert.False(t, priorityMatches(dbus.MakeVariant(int32(100)), int64(priorityDefault)))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func convertStatus(status *ipnstate.Status) *TailscaleState {
|
||||
|
||||
if status.Self != nil {
|
||||
state.Self = convertPeerStatus(status.Self, users)
|
||||
state.Self.Online = true
|
||||
}
|
||||
|
||||
if len(status.Peer) > 0 {
|
||||
|
||||
@@ -95,7 +95,7 @@ func newManager(client tailscaleClient) *Manager {
|
||||
func (m *Manager) watchLoop(ctx context.Context) {
|
||||
defer m.watchWG.Done()
|
||||
|
||||
mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
|
||||
mask := ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
|
||||
backoff := time.Second
|
||||
unreachableSent := false
|
||||
|
||||
@@ -122,8 +122,13 @@ func (m *Manager) watchLoop(ctx context.Context) {
|
||||
log.Info("[Tailscale] Connected to IPN bus")
|
||||
m.markAvailable()
|
||||
|
||||
select {
|
||||
case m.dirty <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
for {
|
||||
notify, err := watcher.Next()
|
||||
_, err := watcher.Next()
|
||||
if err != nil {
|
||||
log.Warnf("[Tailscale] IPN bus error: %v", err)
|
||||
break
|
||||
@@ -131,9 +136,6 @@ func (m *Manager) watchLoop(ctx context.Context) {
|
||||
|
||||
backoff = time.Second
|
||||
|
||||
if notify.State == nil && notify.NetMap == nil { //nolint:staticcheck // NetMap is deprecated upstream but still the only activity signal on some platforms
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case m.dirty <- struct{}{}:
|
||||
default:
|
||||
|
||||
@@ -418,3 +418,55 @@ func TestManager_Actions_PropagateError(t *testing.T) {
|
||||
assert.Error(t, m.SetExitNode("nABC123"))
|
||||
assert.Error(t, m.SetAllowLANAccess(true))
|
||||
}
|
||||
|
||||
type dynamicWatcher struct {
|
||||
ctx context.Context
|
||||
ch chan ipn.Notify
|
||||
}
|
||||
|
||||
func (w *dynamicWatcher) Next() (ipn.Notify, error) {
|
||||
select {
|
||||
case n, ok := <-w.ch:
|
||||
if !ok {
|
||||
return ipn.Notify{}, fmt.Errorf("watcher closed")
|
||||
}
|
||||
return n, nil
|
||||
case <-w.ctx.Done():
|
||||
return ipn.Notify{}, w.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *dynamicWatcher) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWatchLoop_NotifyWithoutStateOrNetMap(t *testing.T) {
|
||||
var statusCalls atomic.Int32
|
||||
notifyCh := make(chan ipn.Notify, 1)
|
||||
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
return &dynamicWatcher{ctx: ctx, ch: notifyCh}, nil
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
statusCalls.Add(1)
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
// Wait for the initial connect-time status call to complete.
|
||||
require.Eventually(t, func() bool {
|
||||
return statusCalls.Load() == 1
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
// Send an event with State == nil and NetMap == nil.
|
||||
notifyCh <- ipn.Notify{Engine: &ipn.EngineStatus{}}
|
||||
|
||||
// Verify that the notification without State or NetMap triggers a second status call.
|
||||
require.Eventually(t, func() bool {
|
||||
return statusCalls.Load() >= 2
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule dank-qml-common updated: 26396ce432...1ae722232d
Generated
+4
-3
@@ -3,15 +3,16 @@
|
||||
"dank-qml-common": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1788376015,
|
||||
"narHash": "sha256-/tT8rTznLADndTwLBzdgzzgfJH1ZhYQibYo5v/F8U+U=",
|
||||
"lastModified": 1791392812,
|
||||
"narHash": "sha256-id6Y5XNO5K3KfXSOsEYVUwiq1ygYGNX5LeKSgHM3GR8=",
|
||||
"owner": "AvengeMedia",
|
||||
"repo": "dank-qml-common",
|
||||
"rev": "26396ce432d6c71c3f5367438f96f4a8d667e160",
|
||||
"rev": "1ae722232da7110d26cd2c355b6615e078e94197",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "AvengeMedia",
|
||||
"ref": "stable-1.6",
|
||||
"repo": "dank-qml-common",
|
||||
"type": "github"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
flake = false;
|
||||
};
|
||||
dank-qml-common = {
|
||||
url = "github:AvengeMedia/dank-qml-common";
|
||||
url = "github:AvengeMedia/dank-qml-common/stable-1.6";
|
||||
flake = false;
|
||||
};
|
||||
};
|
||||
@@ -111,7 +111,7 @@
|
||||
inherit version;
|
||||
pname = "dms-shell";
|
||||
src = ./core;
|
||||
vendorHash = "sha256-nswS/ygp0oYMXp6YHbbIxBha4xvzjKAxcAS5yOpbslg=";
|
||||
vendorHash = "sha256-ejDi+TzstUtxtWpcl5yYw4UZuTnZraOsIdJuXPKxJ6s=";
|
||||
|
||||
subPackages = [ "cmd/dms" ];
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user