mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-31 00:42:50 -05:00
Compare commits
18 Commits
e573bdba92
...
bdc0e8e0fc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdc0e8e0fc | ||
|
|
6d66f93565 | ||
|
|
9cac93b724 | ||
|
|
0709f263af | ||
|
|
4e4effd8b1 | ||
|
|
f9632cba61 | ||
|
|
38db6a41d5 | ||
|
|
7c6f0432c8 | ||
|
|
56ff9368be | ||
|
|
597e21d44d | ||
|
|
5bf54632be | ||
|
|
3a8d3ee515 | ||
|
|
1c1cf866e2 | ||
|
|
ccc1df75f1 | ||
|
|
d2c3f87656 | ||
|
|
6d62229b5f | ||
|
|
7c88865d67 | ||
|
|
c8cfe0cb5a |
8
.editorconfig
Normal file
8
.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
[*.sh]
|
||||
# like -i=4
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
[*.nix]
|
||||
# like -i=4
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$HOOK_DIR/.." && pwd)"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# =============================================================================
|
||||
# Go CI checks (when core/ files are staged)
|
||||
# =============================================================================
|
||||
STAGED_CORE_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep '^core/' || true)
|
||||
|
||||
if [[ -n "$STAGED_CORE_FILES" ]]; then
|
||||
echo "Go files staged in core/, running CI checks..."
|
||||
cd "$REPO_ROOT/core"
|
||||
|
||||
# Format check
|
||||
echo " Checking gofmt..."
|
||||
UNFORMATTED=$(gofmt -s -l . 2>/dev/null || true)
|
||||
if [[ -n "$UNFORMATTED" ]]; then
|
||||
echo "The following files are not formatted:"
|
||||
echo "$UNFORMATTED"
|
||||
echo ""
|
||||
echo "Run: cd core && gofmt -s -w ."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# golangci-lint
|
||||
if command -v golangci-lint &>/dev/null; then
|
||||
echo " Running golangci-lint..."
|
||||
golangci-lint run ./...
|
||||
else
|
||||
echo " Warning: golangci-lint not installed, skipping lint"
|
||||
echo " Install: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"
|
||||
fi
|
||||
|
||||
# Tests
|
||||
echo " Running tests..."
|
||||
if ! go test ./... >/dev/null 2>&1; then
|
||||
echo "Tests failed! Run 'go test ./...' for details."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build checks
|
||||
echo " Building..."
|
||||
mkdir -p bin
|
||||
go build -buildvcs=false -o bin/dms ./cmd/dms
|
||||
go build -buildvcs=false -o bin/dms-distro -tags distro_binary ./cmd/dms
|
||||
go build -buildvcs=false -o bin/dankinstall ./cmd/dankinstall
|
||||
|
||||
echo "All Go CI checks passed!"
|
||||
cd "$REPO_ROOT"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# i18n sync check (DISABLED for now)
|
||||
# =============================================================================
|
||||
# if [[ -n "${POEDITOR_API_TOKEN:-}" ]] && [[ -n "${POEDITOR_PROJECT_ID:-}" ]]; then
|
||||
# if command -v python3 &>/dev/null; then
|
||||
# if ! python3 scripts/i18nsync.py check &>/dev/null; then
|
||||
# echo "Translations out of sync"
|
||||
# echo "Run: python3 scripts/i18nsync.py sync"
|
||||
# exit 1
|
||||
# fi
|
||||
# fi
|
||||
# fi
|
||||
|
||||
exit 0
|
||||
14
.github/workflows/go-ci.yml
vendored
14
.github/workflows/go-ci.yml
vendored
@@ -33,20 +33,6 @@ jobs:
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
- name: Format check
|
||||
run: |
|
||||
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
|
||||
echo "The following files are not formatted:"
|
||||
gofmt -s -l .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.6
|
||||
working-directory: core
|
||||
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
|
||||
|
||||
15
.github/workflows/prek.yml
vendored
Normal file
15
.github/workflows/prek.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: Pre-commit Checks
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
jobs:
|
||||
pre-commit-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: run pre-commit hooks
|
||||
uses: j178/prek-action@v1
|
||||
4
.github/workflows/update-vendor-hash.yml
vendored
4
.github/workflows/update-vendor-hash.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Attempting nix build to get new vendorHash..."
|
||||
if output=$(nix build .#dmsCli 2>&1); then
|
||||
if output=$(nix build .#dms-shell 2>&1); then
|
||||
echo "Build succeeded, no hash update needed"
|
||||
exit 0
|
||||
fi
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
[ "$current_hash" = "$new_hash" ] && { echo "vendorHash already up to date"; exit 0; }
|
||||
sed -i "s|vendorHash = \"$current_hash\"|vendorHash = \"$new_hash\"|" flake.nix
|
||||
echo "Verifying build with new vendorHash..."
|
||||
nix build .#dmsCli
|
||||
nix build .#dms-shell
|
||||
echo "vendorHash updated successfully!"
|
||||
|
||||
- name: Commit and push vendorHash update
|
||||
|
||||
12
.pre-commit-config.yaml
Normal file
12
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: v0.10.0.1
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
args: [-e, SC2164, -e, SC2001, -e, SC2012, -e, SC2317]
|
||||
@@ -6,10 +6,10 @@ To contribute fork this repository, make your changes, and open a pull request.
|
||||
|
||||
## Setup
|
||||
|
||||
Enable pre-commit hooks to catch CI failures before pushing:
|
||||
Install [prek](https://prek.j178.dev/) then activate pre-commit hooks:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
prek install
|
||||
```
|
||||
|
||||
### Nix Development Shell
|
||||
@@ -21,6 +21,7 @@ nix develop
|
||||
```
|
||||
|
||||
This will provide:
|
||||
|
||||
- Go 1.24 toolchain (go, gopls, delve, go-tools) and GNU Make
|
||||
- Quickshell and required QML packages
|
||||
- Properly configured QML2_IMPORT_PATH
|
||||
@@ -54,6 +55,20 @@ touch .qmlls.ini
|
||||
|
||||
5. Make your changes, test, and open a pull request.
|
||||
|
||||
### I18n/Localization
|
||||
|
||||
When adding user-facing strings, ensure they are wrapped in `I18n.tr()` with context, for example.
|
||||
|
||||
```qml
|
||||
import qs.Common
|
||||
|
||||
Text {
|
||||
text: I18n.tr("Hello World", "<This is context for the translators, example> Hello world greeting that appears on the lock screen")
|
||||
}
|
||||
```
|
||||
|
||||
Preferably, try to keep new terms to a minimum and re-use existing terms where possible. See `quickshell/translations/en.json` for the list of existing terms. (This isn't always possible obviously, but instead of using `Auto-connect` you would use `Autoconnect` since it's already translated)
|
||||
|
||||
### GO (`core` directory)
|
||||
|
||||
1. Install the [Go Extension](https://code.visualstudio.com/docs/languages/go)
|
||||
|
||||
@@ -102,7 +102,11 @@ linters:
|
||||
- linters:
|
||||
- ineffassign
|
||||
path: internal/proto/
|
||||
# binary.Write to bytes.Buffer can't fail
|
||||
# binary.Write/Read to bytes.Buffer can't fail
|
||||
- linters:
|
||||
- errcheck
|
||||
text: "Error return value of `binary\\.Write` is not checked"
|
||||
text: "Error return value of `binary\\.(Write|Read)` is not checked"
|
||||
# bytes.Reader.Read can't fail (reads from memory)
|
||||
- linters:
|
||||
- errcheck
|
||||
text: "Error return value of `buf\\.Read` is not checked"
|
||||
|
||||
@@ -56,3 +56,15 @@ packages:
|
||||
outpkg: mocks_version
|
||||
interfaces:
|
||||
VersionFetcher:
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
|
||||
config:
|
||||
dir: "internal/mocks/wlcontext"
|
||||
outpkg: mocks_wlcontext
|
||||
interfaces:
|
||||
WaylandContext:
|
||||
github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
|
||||
config:
|
||||
dir: "internal/mocks/wlclient"
|
||||
outpkg: mocks_wlclient
|
||||
interfaces:
|
||||
WaylandDisplay:
|
||||
|
||||
7
core/.pre-commit-config.yaml
Normal file
7
core/.pre-commit-config.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v2.6.2
|
||||
hooks:
|
||||
- id: golangci-lint-full
|
||||
- id: golangci-lint-fmt
|
||||
- id: golangci-lint-config-verify
|
||||
628
core/cmd/dms/commands_clipboard.go
Normal file
628
core/cmd/dms/commands_clipboard.go
Normal file
@@ -0,0 +1,628 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var clipboardCmd = &cobra.Command{
|
||||
Use: "clipboard",
|
||||
Aliases: []string{"cl"},
|
||||
Short: "Manage clipboard",
|
||||
Long: "Interact with the clipboard manager",
|
||||
}
|
||||
|
||||
var clipCopyCmd = &cobra.Command{
|
||||
Use: "copy [text]",
|
||||
Short: "Copy text to clipboard",
|
||||
Long: "Copy text to clipboard. If no text provided, reads from stdin. Works without server.",
|
||||
Run: runClipCopy,
|
||||
}
|
||||
|
||||
var (
|
||||
clipCopyForeground bool
|
||||
clipCopyPasteOnce bool
|
||||
clipCopyType string
|
||||
clipJSONOutput bool
|
||||
)
|
||||
|
||||
var clipPasteCmd = &cobra.Command{
|
||||
Use: "paste",
|
||||
Short: "Paste text from clipboard",
|
||||
Long: "Paste text from clipboard to stdout. Works without server.",
|
||||
Run: runClipPaste,
|
||||
}
|
||||
|
||||
var clipWatchCmd = &cobra.Command{
|
||||
Use: "watch [command]",
|
||||
Short: "Watch clipboard for changes",
|
||||
Long: `Watch clipboard for changes and optionally execute a command.
|
||||
Works like wl-paste --watch. Does not require server.
|
||||
|
||||
If a command is provided, it will be executed each time the clipboard changes,
|
||||
with the clipboard content piped to its stdin.
|
||||
|
||||
Examples:
|
||||
dms cl watch # Print clipboard changes to stdout
|
||||
dms cl watch cat # Same as above
|
||||
dms cl watch notify-send # Send notification on clipboard change`,
|
||||
Run: runClipWatch,
|
||||
}
|
||||
|
||||
var clipHistoryCmd = &cobra.Command{
|
||||
Use: "history",
|
||||
Short: "Show clipboard history",
|
||||
Long: "Show clipboard history with previews (requires server)",
|
||||
Run: runClipHistory,
|
||||
}
|
||||
|
||||
var clipGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Get clipboard entry by ID",
|
||||
Long: "Get full clipboard entry data by ID (requires server). Use --copy to copy it to clipboard.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runClipGet,
|
||||
}
|
||||
|
||||
var clipGetCopy bool
|
||||
|
||||
var clipDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete clipboard entry",
|
||||
Long: "Delete a clipboard history entry by ID (requires server)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runClipDelete,
|
||||
}
|
||||
|
||||
var clipClearCmd = &cobra.Command{
|
||||
Use: "clear",
|
||||
Short: "Clear clipboard history",
|
||||
Long: "Clear all clipboard history (requires server)",
|
||||
Run: runClipClear,
|
||||
}
|
||||
|
||||
var clipWatchStore bool
|
||||
|
||||
var clipSearchCmd = &cobra.Command{
|
||||
Use: "search [query]",
|
||||
Short: "Search clipboard history",
|
||||
Long: "Search clipboard history with filters (requires server)",
|
||||
Run: runClipSearch,
|
||||
}
|
||||
|
||||
var (
|
||||
clipSearchLimit int
|
||||
clipSearchOffset int
|
||||
clipSearchMimeType string
|
||||
clipSearchImages bool
|
||||
clipSearchText bool
|
||||
)
|
||||
|
||||
var clipConfigCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage clipboard config",
|
||||
Long: "Get or set clipboard configuration (requires server)",
|
||||
}
|
||||
|
||||
var clipConfigGetCmd = &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "Get clipboard config",
|
||||
Run: runClipConfigGet,
|
||||
}
|
||||
|
||||
var clipConfigSetCmd = &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "Set clipboard config",
|
||||
Long: `Set clipboard configuration options.
|
||||
|
||||
Examples:
|
||||
dms cl config set --max-history 200
|
||||
dms cl config set --auto-clear-days 7
|
||||
dms cl config set --clear-at-startup`,
|
||||
Run: runClipConfigSet,
|
||||
}
|
||||
|
||||
var (
|
||||
clipConfigMaxHistory int
|
||||
clipConfigAutoClearDays int
|
||||
clipConfigClearAtStartup bool
|
||||
clipConfigNoClearStartup bool
|
||||
clipConfigDisabled bool
|
||||
clipConfigEnabled bool
|
||||
clipConfigDisableHistory bool
|
||||
clipConfigEnableHistory bool
|
||||
clipConfigDisablePersist bool
|
||||
clipConfigEnablePersist bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking")
|
||||
clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste")
|
||||
clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type")
|
||||
|
||||
clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
|
||||
clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
|
||||
clipGetCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
|
||||
clipGetCmd.Flags().BoolVarP(&clipGetCopy, "copy", "c", false, "Copy entry to clipboard")
|
||||
|
||||
clipSearchCmd.Flags().IntVarP(&clipSearchLimit, "limit", "l", 50, "Max results")
|
||||
clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset")
|
||||
clipSearchCmd.Flags().StringVarP(&clipSearchMimeType, "mime", "m", "", "Filter by MIME type")
|
||||
clipSearchCmd.Flags().BoolVar(&clipSearchImages, "images", false, "Only images")
|
||||
clipSearchCmd.Flags().BoolVar(&clipSearchText, "text", false, "Only text")
|
||||
clipSearchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
|
||||
|
||||
clipConfigSetCmd.Flags().IntVar(&clipConfigMaxHistory, "max-history", 0, "Max history entries")
|
||||
clipConfigSetCmd.Flags().IntVar(&clipConfigAutoClearDays, "auto-clear-days", -1, "Auto-clear entries older than N days (0 to disable)")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigClearAtStartup, "clear-at-startup", false, "Clear history on startup")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigNoClearStartup, "no-clear-at-startup", false, "Don't clear history on startup")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisabled, "disable", false, "Disable clipboard manager entirely")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard manager")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisableHistory, "disable-history", false, "Disable clipboard history persistence")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnableHistory, "enable-history", false, "Enable clipboard history persistence")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisablePersist, "disable-persist", false, "Disable clipboard ownership persistence")
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnablePersist, "enable-persist", false, "Enable clipboard ownership persistence")
|
||||
|
||||
clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)")
|
||||
|
||||
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
|
||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd)
|
||||
}
|
||||
|
||||
func runClipCopy(cmd *cobra.Command, args []string) {
|
||||
var data []byte
|
||||
|
||||
if len(args) > 0 {
|
||||
data = []byte(args[0])
|
||||
} else {
|
||||
var err error
|
||||
data, err = io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
log.Fatalf("read stdin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
|
||||
log.Fatalf("copy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runClipPaste(cmd *cobra.Command, args []string) {
|
||||
data, _, err := clipboard.Paste()
|
||||
if err != nil {
|
||||
log.Fatalf("paste: %v", err)
|
||||
}
|
||||
os.Stdout.Write(data)
|
||||
}
|
||||
|
||||
func runClipWatch(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
cancel()
|
||||
}()
|
||||
|
||||
switch {
|
||||
case len(args) > 0:
|
||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
runCommand(args, data)
|
||||
}); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
case clipWatchStore:
|
||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
if err := clipboard.Store(data, mimeType); err != nil {
|
||||
log.Errorf("store: %v", err)
|
||||
}
|
||||
}); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
case clipJSONOutput:
|
||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
out := map[string]any{
|
||||
"data": string(data),
|
||||
"mimeType": mimeType,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"size": len(data),
|
||||
}
|
||||
j, _ := json.Marshal(out)
|
||||
fmt.Println(string(j))
|
||||
}); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
default:
|
||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
os.Stdout.Write(data)
|
||||
os.Stdout.WriteString("\n")
|
||||
}); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runCommand(args []string, stdin []byte) {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if len(stdin) == 0 {
|
||||
cmd.Run()
|
||||
return
|
||||
}
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
cmd.Run()
|
||||
return
|
||||
}
|
||||
|
||||
cmd.Stdin = r
|
||||
go func() {
|
||||
w.Write(stdin)
|
||||
w.Close()
|
||||
}()
|
||||
cmd.Run()
|
||||
}
|
||||
|
||||
func runClipHistory(cmd *cobra.Command, args []string) {
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.getHistory",
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get clipboard history: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
|
||||
if resp.Result == nil {
|
||||
if clipJSONOutput {
|
||||
fmt.Println("[]")
|
||||
} else {
|
||||
fmt.Println("No clipboard history")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
historyList, ok := (*resp.Result).([]any)
|
||||
if !ok {
|
||||
log.Fatal("Invalid response format")
|
||||
}
|
||||
|
||||
if clipJSONOutput {
|
||||
out, _ := json.MarshalIndent(historyList, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
if len(historyList) == 0 {
|
||||
fmt.Println("No clipboard history")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Clipboard History:")
|
||||
fmt.Println()
|
||||
|
||||
for _, item := range historyList {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
id := uint64(entry["id"].(float64))
|
||||
preview := entry["preview"].(string)
|
||||
timestamp := entry["timestamp"].(string)
|
||||
isImage := entry["isImage"].(bool)
|
||||
|
||||
typeStr := "text"
|
||||
if isImage {
|
||||
typeStr = "image"
|
||||
}
|
||||
|
||||
fmt.Printf("ID: %d | %s | %s\n", id, typeStr, timestamp)
|
||||
fmt.Printf(" %s\n", preview)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func runClipGet(cmd *cobra.Command, args []string) {
|
||||
id, err := strconv.ParseUint(args[0], 10, 64)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid ID: %v", err)
|
||||
}
|
||||
|
||||
if clipGetCopy {
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.copyEntry",
|
||||
Params: map[string]any{
|
||||
"id": id,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to copy clipboard entry: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
|
||||
fmt.Printf("Copied entry %d to clipboard\n", id)
|
||||
return
|
||||
}
|
||||
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.getEntry",
|
||||
Params: map[string]any{
|
||||
"id": id,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get clipboard entry: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
|
||||
if resp.Result == nil {
|
||||
log.Fatal("Entry not found")
|
||||
}
|
||||
|
||||
entry, ok := (*resp.Result).(map[string]any)
|
||||
if !ok {
|
||||
log.Fatal("Invalid response format")
|
||||
}
|
||||
|
||||
switch {
|
||||
case clipJSONOutput:
|
||||
output, _ := json.MarshalIndent(entry, "", " ")
|
||||
fmt.Println(string(output))
|
||||
default:
|
||||
if data, ok := entry["data"].(string); ok {
|
||||
fmt.Print(data)
|
||||
} else {
|
||||
output, _ := json.MarshalIndent(entry, "", " ")
|
||||
fmt.Println(string(output))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runClipDelete(cmd *cobra.Command, args []string) {
|
||||
id, err := strconv.ParseUint(args[0], 10, 64)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid ID: %v", err)
|
||||
}
|
||||
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.deleteEntry",
|
||||
Params: map[string]any{
|
||||
"id": id,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to delete clipboard entry: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
|
||||
fmt.Printf("Deleted entry %d\n", id)
|
||||
}
|
||||
|
||||
func runClipClear(cmd *cobra.Command, args []string) {
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.clearHistory",
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to clear clipboard history: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
|
||||
fmt.Println("Clipboard history cleared")
|
||||
}
|
||||
|
||||
func runClipSearch(cmd *cobra.Command, args []string) {
|
||||
params := map[string]any{
|
||||
"limit": clipSearchLimit,
|
||||
"offset": clipSearchOffset,
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
params["query"] = args[0]
|
||||
}
|
||||
if clipSearchMimeType != "" {
|
||||
params["mimeType"] = clipSearchMimeType
|
||||
}
|
||||
if clipSearchImages {
|
||||
params["isImage"] = true
|
||||
} else if clipSearchText {
|
||||
params["isImage"] = false
|
||||
}
|
||||
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.search",
|
||||
Params: params,
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to search clipboard: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
|
||||
if resp.Result == nil {
|
||||
log.Fatal("No results")
|
||||
}
|
||||
|
||||
result, ok := (*resp.Result).(map[string]any)
|
||||
if !ok {
|
||||
log.Fatal("Invalid response format")
|
||||
}
|
||||
|
||||
if clipJSONOutput {
|
||||
out, _ := json.MarshalIndent(result, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
entries, _ := result["entries"].([]any)
|
||||
total := int(result["total"].(float64))
|
||||
hasMore := result["hasMore"].(bool)
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No results found")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Results: %d of %d\n\n", len(entries), total)
|
||||
|
||||
for _, item := range entries {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
id := uint64(entry["id"].(float64))
|
||||
preview := entry["preview"].(string)
|
||||
timestamp := entry["timestamp"].(string)
|
||||
isImage := entry["isImage"].(bool)
|
||||
|
||||
typeStr := "text"
|
||||
if isImage {
|
||||
typeStr = "image"
|
||||
}
|
||||
|
||||
fmt.Printf("ID: %d | %s | %s\n", id, typeStr, timestamp)
|
||||
fmt.Printf(" %s\n\n", preview)
|
||||
}
|
||||
|
||||
if hasMore {
|
||||
fmt.Printf("Use --offset %d to see more results\n", clipSearchOffset+clipSearchLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func runClipConfigGet(cmd *cobra.Command, args []string) {
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.getConfig",
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get config: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
|
||||
if resp.Result == nil {
|
||||
log.Fatal("No config returned")
|
||||
}
|
||||
|
||||
cfg, ok := (*resp.Result).(map[string]any)
|
||||
if !ok {
|
||||
log.Fatal("Invalid response format")
|
||||
}
|
||||
|
||||
output, _ := json.MarshalIndent(cfg, "", " ")
|
||||
fmt.Println(string(output))
|
||||
}
|
||||
|
||||
func runClipConfigSet(cmd *cobra.Command, args []string) {
|
||||
params := map[string]any{}
|
||||
|
||||
if cmd.Flags().Changed("max-history") {
|
||||
params["maxHistory"] = clipConfigMaxHistory
|
||||
}
|
||||
if cmd.Flags().Changed("auto-clear-days") {
|
||||
params["autoClearDays"] = clipConfigAutoClearDays
|
||||
}
|
||||
if clipConfigClearAtStartup {
|
||||
params["clearAtStartup"] = true
|
||||
}
|
||||
if clipConfigNoClearStartup {
|
||||
params["clearAtStartup"] = false
|
||||
}
|
||||
if clipConfigDisabled {
|
||||
params["disabled"] = true
|
||||
}
|
||||
if clipConfigEnabled {
|
||||
params["disabled"] = false
|
||||
}
|
||||
if clipConfigDisableHistory {
|
||||
params["disableHistory"] = true
|
||||
}
|
||||
if clipConfigEnableHistory {
|
||||
params["disableHistory"] = false
|
||||
}
|
||||
if clipConfigDisablePersist {
|
||||
params["disablePersist"] = true
|
||||
}
|
||||
if clipConfigEnablePersist {
|
||||
params["disablePersist"] = false
|
||||
}
|
||||
|
||||
if len(params) == 0 {
|
||||
fmt.Println("No config options specified")
|
||||
return
|
||||
}
|
||||
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.setConfig",
|
||||
Params: params,
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to set config: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
|
||||
fmt.Println("Config updated")
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/colorpicker"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -121,13 +121,7 @@ func runColorPick(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
func copyToClipboard(text string) {
|
||||
var cmd *exec.Cmd
|
||||
if _, err := exec.LookPath("wl-copy"); err == nil {
|
||||
cmd = exec.Command("wl-copy", text)
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "wl-copy not found, cannot copy to clipboard")
|
||||
return
|
||||
if err := clipboard.CopyText(text); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "clipboard copy failed:", err)
|
||||
}
|
||||
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
@@ -513,5 +513,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
screenshotCmd,
|
||||
notifyActionCmd,
|
||||
matugenCmd,
|
||||
clipboardCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -49,6 +46,7 @@ func init() {
|
||||
cmd.Flags().String("stock-colors", "", "Stock theme colors JSON")
|
||||
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
|
||||
cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant")
|
||||
cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip")
|
||||
}
|
||||
|
||||
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
|
||||
@@ -68,6 +66,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
stockColors, _ := cmd.Flags().GetString("stock-colors")
|
||||
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
|
||||
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
|
||||
skipTemplates, _ := cmd.Flags().GetString("skip-templates")
|
||||
|
||||
return matugen.Options{
|
||||
StateDir: stateDir,
|
||||
@@ -82,6 +81,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
StockColors: stockColors,
|
||||
SyncModeWithPortal: syncModeWithPortal,
|
||||
TerminalsAlwaysDark: terminalsAlwaysDark,
|
||||
SkipTemplates: skipTemplates,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,33 +97,10 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
wait, _ := cmd.Flags().GetBool("wait")
|
||||
timeout, _ := cmd.Flags().GetDuration("timeout")
|
||||
|
||||
socketPath := os.Getenv("DMS_SOCKET")
|
||||
if socketPath == "" {
|
||||
var err error
|
||||
socketPath, err = server.FindSocket()
|
||||
if err != nil {
|
||||
log.Info("No socket available, running synchronously")
|
||||
if err := matugen.Run(opts); err != nil {
|
||||
log.Fatalf("Theme generation failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := net.Dial("unix", socketPath)
|
||||
if err != nil {
|
||||
log.Info("Socket connection failed, running synchronously")
|
||||
if err := matugen.Run(opts); err != nil {
|
||||
log.Fatalf("Theme generation failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
request := map[string]any{
|
||||
"id": 1,
|
||||
"method": "matugen.queue",
|
||||
"params": map[string]any{
|
||||
request := models.Request{
|
||||
ID: 1,
|
||||
Method: "matugen.queue",
|
||||
Params: map[string]any{
|
||||
"stateDir": opts.StateDir,
|
||||
"shellDir": opts.ShellDir,
|
||||
"configDir": opts.ConfigDir,
|
||||
@@ -136,15 +113,19 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
"stockColors": opts.StockColors,
|
||||
"syncModeWithPortal": opts.SyncModeWithPortal,
|
||||
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
|
||||
"skipTemplates": opts.SkipTemplates,
|
||||
"wait": wait,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(conn).Encode(request); err != nil {
|
||||
log.Fatalf("Failed to send request: %v", err)
|
||||
}
|
||||
|
||||
if !wait {
|
||||
if err := sendServerRequestFireAndForget(request); err != nil {
|
||||
log.Info("Server unavailable, running synchronously")
|
||||
if err := matugen.Run(opts); err != nil {
|
||||
log.Fatalf("Theme generation failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
fmt.Println("Theme generation queued")
|
||||
return
|
||||
}
|
||||
@@ -154,17 +135,18 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
|
||||
resultCh := make(chan error, 1)
|
||||
go func() {
|
||||
var response struct {
|
||||
ID int `json:"id"`
|
||||
Result any `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(conn).Decode(&response); err != nil {
|
||||
resultCh <- fmt.Errorf("failed to read response: %w", err)
|
||||
resp, ok := tryServerRequest(request)
|
||||
if !ok {
|
||||
log.Info("Server unavailable, running synchronously")
|
||||
if err := matugen.Run(opts); err != nil {
|
||||
resultCh <- err
|
||||
return
|
||||
}
|
||||
if response.Error != "" {
|
||||
resultCh <- fmt.Errorf("server error: %s", response.Error)
|
||||
resultCh <- nil
|
||||
return
|
||||
}
|
||||
if resp.Error != "" {
|
||||
resultCh <- fmt.Errorf("server error: %s", resp.Error)
|
||||
return
|
||||
}
|
||||
resultCh <- nil
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -93,32 +90,6 @@ func mimeTypeToCategories(mimeType string) []string {
|
||||
}
|
||||
|
||||
func runOpen(target string) {
|
||||
socketPath, err := server.FindSocket()
|
||||
if err != nil {
|
||||
log.Warnf("DMS socket not found: %v", err)
|
||||
fmt.Println("DMS is not running. Please start DMS first.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
conn, err := net.Dial("unix", socketPath)
|
||||
if err != nil {
|
||||
log.Warnf("DMS socket connection failed: %v", err)
|
||||
fmt.Println("DMS is not running. Please start DMS first.")
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
_, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if buf[0] == '\n' {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Parse file:// URIs to extract the actual file path
|
||||
actualTarget := target
|
||||
detectedMimeType := openMimeType
|
||||
@@ -219,8 +190,9 @@ func runOpen(target string) {
|
||||
|
||||
log.Infof("Sending request - Method: %s, Params: %+v", method, params)
|
||||
|
||||
if err := json.NewEncoder(conn).Encode(req); err != nil {
|
||||
log.Fatalf("Failed to send request: %v", err)
|
||||
if err := sendServerRequestFireAndForget(req); err != nil {
|
||||
fmt.Println("DMS is not running. Please start DMS first.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log.Infof("Request sent successfully")
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -257,9 +257,7 @@ func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, q
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command("wl-copy", "--type", mimeType)
|
||||
cmd.Stdin = &data
|
||||
return cmd.Run()
|
||||
return clipboard.Copy(data.Bytes(), mimeType)
|
||||
}
|
||||
|
||||
func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error {
|
||||
|
||||
114
core/cmd/dms/server_client.go
Normal file
114
core/cmd/dms/server_client.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
)
|
||||
|
||||
func sendServerRequest(req models.Request) (*models.Response[any], error) {
|
||||
socketPath := getServerSocketPath()
|
||||
|
||||
conn, err := net.Dial("unix", socketPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to server (is it running?): %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
scanner.Scan() // discard initial capabilities message
|
||||
|
||||
reqData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.Write(reqData); err != nil {
|
||||
return nil, fmt.Errorf("failed to write request: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.Write([]byte("\n")); err != nil {
|
||||
return nil, fmt.Errorf("failed to write newline: %w", err)
|
||||
}
|
||||
|
||||
if !scanner.Scan() {
|
||||
return nil, fmt.Errorf("failed to read response")
|
||||
}
|
||||
|
||||
var resp models.Response[any]
|
||||
if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// sendServerRequestFireAndForget sends a request without waiting for a response.
|
||||
// Useful for commands that trigger UI or async operations.
|
||||
func sendServerRequestFireAndForget(req models.Request) error {
|
||||
socketPath := getServerSocketPath()
|
||||
|
||||
conn, err := net.Dial("unix", socketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to server (is it running?): %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
scanner.Scan() // discard initial capabilities message
|
||||
|
||||
reqData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.Write(reqData); err != nil {
|
||||
return fmt.Errorf("failed to write request: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.Write([]byte("\n")); err != nil {
|
||||
return fmt.Errorf("failed to write newline: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryServerRequest attempts to send a request but returns false if server unavailable.
|
||||
// Does not log errors - caller can decide what to do on failure.
|
||||
func tryServerRequest(req models.Request) (*models.Response[any], bool) {
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return resp, true
|
||||
}
|
||||
|
||||
func getServerSocketPath() string {
|
||||
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if runtimeDir == "" {
|
||||
runtimeDir = os.TempDir()
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(runtimeDir)
|
||||
if err != nil {
|
||||
return filepath.Join(runtimeDir, "danklinux.sock")
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name == "danklinux.sock" {
|
||||
return filepath.Join(runtimeDir, name)
|
||||
}
|
||||
if len(name) > 10 && name[:10] == "danklinux-" && filepath.Ext(name) == ".sock" {
|
||||
return filepath.Join(runtimeDir, name)
|
||||
}
|
||||
}
|
||||
|
||||
return server.GetSocketPath()
|
||||
}
|
||||
@@ -15,7 +15,9 @@ require (
|
||||
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
||||
golang.org/x/image v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -65,6 +67,6 @@ require (
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/text v0.31.0
|
||||
golang.org/x/text v0.32.0
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
10
core/go.sum
10
core/go.sum
@@ -131,20 +131,26 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/sh
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
@@ -55,7 +55,7 @@ curl -L "https://github.com/AvengeMedia/DankMaterialShell/releases/download/$LAT
|
||||
curl -L "https://github.com/AvengeMedia/DankMaterialShell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz.sha256" -o "expected.sha256"
|
||||
|
||||
# Get the expected checksum
|
||||
EXPECTED_CHECKSUM=$(cat expected.sha256 | awk '{print $1}')
|
||||
EXPECTED_CHECKSUM=$(awk '{print $1}' expected.sha256)
|
||||
|
||||
# Calculate actual checksum
|
||||
printf "%bVerifying checksum...%b\n" "$GREEN" "$NC"
|
||||
|
||||
332
core/internal/clipboard/clipboard.go
Normal file
332
core/internal/clipboard/clipboard.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
func Copy(data []byte, mimeType string) error {
|
||||
return CopyOpts(data, mimeType, false, false)
|
||||
}
|
||||
|
||||
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||
if !foreground {
|
||||
return copyFork(data, mimeType, pasteOnce)
|
||||
}
|
||||
return copyServe(data, mimeType, pasteOnce)
|
||||
}
|
||||
|
||||
func copyFork(data []byte, mimeType string, pasteOnce bool) error {
|
||||
args := []string{os.Args[0], "cl", "copy", "--foreground"}
|
||||
if pasteOnce {
|
||||
args = append(args, "--paste-once")
|
||||
}
|
||||
args = append(args, "--type", mimeType)
|
||||
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
|
||||
if _, err := stdin.Write(data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
stdin.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyServe(data []byte, mimeType string, pasteOnce bool) error {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
defer display.Destroy()
|
||||
|
||||
ctx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
defer registry.Destroy()
|
||||
|
||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
var seat *wlclient.Seat
|
||||
var bindErr error
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case "ext_data_control_manager_v1":
|
||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
case "wl_seat":
|
||||
if seat != nil {
|
||||
return
|
||||
}
|
||||
seat = wlclient.NewSeat(ctx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
return fmt.Errorf("registry bind: %w", bindErr)
|
||||
}
|
||||
|
||||
if dataControlMgr == nil {
|
||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
}
|
||||
defer dataControlMgr.Destroy()
|
||||
|
||||
if seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
defer device.Destroy()
|
||||
|
||||
source, err := dataControlMgr.CreateDataSource()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create data source: %w", err)
|
||||
}
|
||||
|
||||
if err := source.Offer(mimeType); err != nil {
|
||||
return fmt.Errorf("offer mime type: %w", err)
|
||||
}
|
||||
if mimeType == "text/plain;charset=utf-8" || mimeType == "text/plain" {
|
||||
if err := source.Offer("text/plain"); err != nil {
|
||||
return fmt.Errorf("offer text/plain: %w", err)
|
||||
}
|
||||
if err := source.Offer("text/plain;charset=utf-8"); err != nil {
|
||||
return fmt.Errorf("offer text/plain;charset=utf-8: %w", err)
|
||||
}
|
||||
if err := source.Offer("UTF8_STRING"); err != nil {
|
||||
return fmt.Errorf("offer UTF8_STRING: %w", err)
|
||||
}
|
||||
if err := source.Offer("STRING"); err != nil {
|
||||
return fmt.Errorf("offer STRING: %w", err)
|
||||
}
|
||||
if err := source.Offer("TEXT"); err != nil {
|
||||
return fmt.Errorf("offer TEXT: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cancelled := make(chan struct{})
|
||||
pasted := make(chan struct{}, 1)
|
||||
|
||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||
defer syscall.Close(e.Fd)
|
||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||
defer file.Close()
|
||||
file.Write(data)
|
||||
select {
|
||||
case pasted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
|
||||
close(cancelled)
|
||||
})
|
||||
|
||||
if err := device.SetSelection(source); err != nil {
|
||||
return fmt.Errorf("set selection: %w", err)
|
||||
}
|
||||
|
||||
display.Roundtrip()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-cancelled:
|
||||
return nil
|
||||
case <-pasted:
|
||||
if pasteOnce {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
if err := ctx.Dispatch(); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CopyText(text string) error {
|
||||
return Copy([]byte(text), "text/plain;charset=utf-8")
|
||||
}
|
||||
|
||||
func Paste() ([]byte, string, error) {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
defer display.Destroy()
|
||||
|
||||
ctx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
defer registry.Destroy()
|
||||
|
||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
var seat *wlclient.Seat
|
||||
var bindErr error
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case "ext_data_control_manager_v1":
|
||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
case "wl_seat":
|
||||
if seat != nil {
|
||||
return
|
||||
}
|
||||
seat = wlclient.NewSeat(ctx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
return nil, "", fmt.Errorf("registry bind: %w", bindErr)
|
||||
}
|
||||
|
||||
if dataControlMgr == nil {
|
||||
return nil, "", fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
}
|
||||
defer dataControlMgr.Destroy()
|
||||
|
||||
if seat == nil {
|
||||
return nil, "", fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(seat)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
defer device.Destroy()
|
||||
|
||||
offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string)
|
||||
|
||||
device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
|
||||
if e.Id == nil {
|
||||
return
|
||||
}
|
||||
offerMimeTypes[e.Id] = nil
|
||||
e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) {
|
||||
offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType)
|
||||
})
|
||||
})
|
||||
|
||||
var selectionOffer *ext_data_control.ExtDataControlOfferV1
|
||||
gotSelection := false
|
||||
|
||||
device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
|
||||
selectionOffer = e.Id
|
||||
gotSelection = true
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if !gotSelection || selectionOffer == nil {
|
||||
return nil, "", fmt.Errorf("no clipboard data")
|
||||
}
|
||||
|
||||
mimeTypes := offerMimeTypes[selectionOffer]
|
||||
selectedMime := selectPreferredMimeType(mimeTypes)
|
||||
if selectedMime == "" {
|
||||
return nil, "", fmt.Errorf("no supported mime type")
|
||||
}
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create pipe: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if err := selectionOffer.Receive(selectedMime, int(w.Fd())); err != nil {
|
||||
w.Close()
|
||||
return nil, "", fmt.Errorf("receive: %w", err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
display.Roundtrip()
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read: %w", err)
|
||||
}
|
||||
|
||||
return data, selectedMime, nil
|
||||
}
|
||||
|
||||
func PasteText() (string, error) {
|
||||
data, _, err := Paste()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func selectPreferredMimeType(mimes []string) string {
|
||||
preferred := []string{
|
||||
"text/plain;charset=utf-8",
|
||||
"text/plain",
|
||||
"UTF8_STRING",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
}
|
||||
|
||||
for _, pref := range preferred {
|
||||
for _, mime := range mimes {
|
||||
if mime == pref {
|
||||
return mime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(mimes) > 0 {
|
||||
return mimes[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func IsImageMimeType(mime string) bool {
|
||||
return len(mime) > 6 && mime[:6] == "image/"
|
||||
}
|
||||
253
core/internal/clipboard/store.go
Normal file
253
core/internal/clipboard/store.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "golang.org/x/image/bmp"
|
||||
_ "golang.org/x/image/tiff"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type StoreConfig struct {
|
||||
MaxHistory int
|
||||
MaxEntrySize int64
|
||||
}
|
||||
|
||||
func DefaultStoreConfig() StoreConfig {
|
||||
return StoreConfig{
|
||||
MaxHistory: 100,
|
||||
MaxEntrySize: 5 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID uint64
|
||||
Data []byte
|
||||
MimeType string
|
||||
Preview string
|
||||
Size int
|
||||
Timestamp time.Time
|
||||
IsImage bool
|
||||
}
|
||||
|
||||
func Store(data []byte, mimeType string) error {
|
||||
return StoreWithConfig(data, mimeType, DefaultStoreConfig())
|
||||
}
|
||||
|
||||
func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if int64(len(data)) > cfg.MaxEntrySize {
|
||||
return fmt.Errorf("data too large: %d > %d", len(data), cfg.MaxEntrySize)
|
||||
}
|
||||
|
||||
dbPath, err := getDBPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get db path: %w", err)
|
||||
}
|
||||
|
||||
db, err := bolt.Open(dbPath, 0644, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
entry := Entry{
|
||||
Data: data,
|
||||
MimeType: mimeType,
|
||||
Size: len(data),
|
||||
Timestamp: time.Now(),
|
||||
IsImage: IsImageMimeType(mimeType),
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.IsImage:
|
||||
entry.Preview = imagePreview(data, mimeType)
|
||||
default:
|
||||
entry.Preview = textPreview(data)
|
||||
}
|
||||
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte("clipboard"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := deduplicateInTx(b, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.ID = id
|
||||
|
||||
encoded, err := encodeEntry(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := b.Put(itob(id), encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return trimLengthInTx(b, cfg.MaxHistory)
|
||||
})
|
||||
}
|
||||
|
||||
func getDBPath() (string, error) {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cacheDir = filepath.Join(homeDir, ".cache")
|
||||
}
|
||||
|
||||
dbDir := filepath.Join(cacheDir, "dms-clipboard")
|
||||
if err := os.MkdirAll(dbDir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(dbDir, "db"), nil
|
||||
}
|
||||
|
||||
func deduplicateInTx(b *bolt.Bucket, data []byte) error {
|
||||
c := b.Cursor()
|
||||
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
||||
entry, err := decodeEntry(v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Equal(entry.Data, data) {
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func trimLengthInTx(b *bolt.Bucket, maxHistory int) error {
|
||||
c := b.Cursor()
|
||||
var count int
|
||||
for k, _ := c.Last(); k != nil; k, _ = c.Prev() {
|
||||
if count < maxHistory {
|
||||
count++
|
||||
continue
|
||||
}
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeEntry(e Entry) ([]byte, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
binary.Write(buf, binary.BigEndian, e.ID)
|
||||
binary.Write(buf, binary.BigEndian, uint32(len(e.Data)))
|
||||
buf.Write(e.Data)
|
||||
binary.Write(buf, binary.BigEndian, uint32(len(e.MimeType)))
|
||||
buf.WriteString(e.MimeType)
|
||||
binary.Write(buf, binary.BigEndian, uint32(len(e.Preview)))
|
||||
buf.WriteString(e.Preview)
|
||||
binary.Write(buf, binary.BigEndian, int32(e.Size))
|
||||
binary.Write(buf, binary.BigEndian, e.Timestamp.Unix())
|
||||
if e.IsImage {
|
||||
buf.WriteByte(1)
|
||||
} else {
|
||||
buf.WriteByte(0)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func decodeEntry(data []byte) (Entry, error) {
|
||||
buf := bytes.NewReader(data)
|
||||
var e Entry
|
||||
|
||||
binary.Read(buf, binary.BigEndian, &e.ID)
|
||||
|
||||
var dataLen uint32
|
||||
binary.Read(buf, binary.BigEndian, &dataLen)
|
||||
e.Data = make([]byte, dataLen)
|
||||
buf.Read(e.Data)
|
||||
|
||||
var mimeLen uint32
|
||||
binary.Read(buf, binary.BigEndian, &mimeLen)
|
||||
mimeBytes := make([]byte, mimeLen)
|
||||
buf.Read(mimeBytes)
|
||||
e.MimeType = string(mimeBytes)
|
||||
|
||||
var prevLen uint32
|
||||
binary.Read(buf, binary.BigEndian, &prevLen)
|
||||
prevBytes := make([]byte, prevLen)
|
||||
buf.Read(prevBytes)
|
||||
e.Preview = string(prevBytes)
|
||||
|
||||
var size int32
|
||||
binary.Read(buf, binary.BigEndian, &size)
|
||||
e.Size = int(size)
|
||||
|
||||
var timestamp int64
|
||||
binary.Read(buf, binary.BigEndian, ×tamp)
|
||||
e.Timestamp = time.Unix(timestamp, 0)
|
||||
|
||||
var isImage byte
|
||||
binary.Read(buf, binary.BigEndian, &isImage)
|
||||
e.IsImage = isImage == 1
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func itob(v uint64) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(b, v)
|
||||
return b
|
||||
}
|
||||
|
||||
func textPreview(data []byte) string {
|
||||
text := string(data)
|
||||
text = strings.TrimSpace(text)
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
|
||||
if len(text) > 100 {
|
||||
return text[:100] + "…"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func imagePreview(data []byte, format string) string {
|
||||
config, imgFmt, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Sprintf("[[ image %s %s ]]", sizeStr(len(data)), format)
|
||||
}
|
||||
return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height)
|
||||
}
|
||||
|
||||
func sizeStr(size int) string {
|
||||
units := []string{"B", "KiB", "MiB"}
|
||||
var i int
|
||||
fsize := float64(size)
|
||||
for fsize >= 1024 && i < len(units)-1 {
|
||||
fsize /= 1024
|
||||
i++
|
||||
}
|
||||
return fmt.Sprintf("%.0f %s", fsize, units[i])
|
||||
}
|
||||
160
core/internal/clipboard/watch.go
Normal file
160
core/internal/clipboard/watch.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type ClipboardChange struct {
|
||||
Data []byte
|
||||
MimeType string
|
||||
}
|
||||
|
||||
func Watch(ctx context.Context, callback func(data []byte, mimeType string)) error {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
defer display.Destroy()
|
||||
|
||||
wlCtx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
defer registry.Destroy()
|
||||
|
||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
var seat *wlclient.Seat
|
||||
var bindErr error
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case "ext_data_control_manager_v1":
|
||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(wlCtx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
case "wl_seat":
|
||||
if seat != nil {
|
||||
return
|
||||
}
|
||||
seat = wlclient.NewSeat(wlCtx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
return fmt.Errorf("registry bind: %w", bindErr)
|
||||
}
|
||||
|
||||
if dataControlMgr == nil {
|
||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
}
|
||||
defer dataControlMgr.Destroy()
|
||||
|
||||
if seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
defer device.Destroy()
|
||||
|
||||
offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string)
|
||||
|
||||
device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
|
||||
if e.Id == nil {
|
||||
return
|
||||
}
|
||||
offerMimeTypes[e.Id] = nil
|
||||
e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) {
|
||||
offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType)
|
||||
})
|
||||
})
|
||||
|
||||
device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
|
||||
if e.Id == nil {
|
||||
return
|
||||
}
|
||||
|
||||
mimes := offerMimeTypes[e.Id]
|
||||
selectedMime := selectPreferredMimeType(mimes)
|
||||
if selectedMime == "" {
|
||||
return
|
||||
}
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil {
|
||||
w.Close()
|
||||
r.Close()
|
||||
return
|
||||
}
|
||||
w.Close()
|
||||
|
||||
go func() {
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
callback(data, selectedMime)
|
||||
}()
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
|
||||
return fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
if err := wlCtx.Dispatch(); err != nil && err != os.ErrDeadlineExceeded {
|
||||
return fmt.Errorf("dispatch: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WatchChan(ctx context.Context) (<-chan ClipboardChange, <-chan error) {
|
||||
ch := make(chan ClipboardChange, 16)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
err := Watch(ctx, func(data []byte, mimeType string) {
|
||||
select {
|
||||
case ch <- ClipboardChange{Data: data, MimeType: mimeType}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
if err != nil && err != context.Canceled {
|
||||
errCh <- err
|
||||
}
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return ch, errCh
|
||||
}
|
||||
314
core/internal/colorpicker/state_test.go
Normal file
314
core/internal/colorpicker/state_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package colorpicker
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSurfaceState_ConcurrentPointerMotion(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 50
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
s.OnPointerMotion(float64(id*10+j), float64(id*10+j))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSurfaceState_ConcurrentScaleAccess(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
s.SetScale(int32(id%3 + 1))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
scale := s.Scale()
|
||||
assert.GreaterOrEqual(t, scale, int32(1))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSurfaceState_ConcurrentLogicalSize(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
_ = s.OnLayerConfigure(1920+id, 1080+j)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
w, h := s.LogicalSize()
|
||||
_ = w
|
||||
_ = h
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSurfaceState_ConcurrentIsDone(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/3; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
s.OnPointerButton(0x110, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/3; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
s.OnKey(1, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/3; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
picked, cancelled := s.IsDone()
|
||||
_ = picked
|
||||
_ = cancelled
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSurfaceState_ConcurrentIsReady(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
_ = s.IsReady()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSurfaceState_ConcurrentSwapBuffers(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
s.SwapBuffers()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSurfaceState_ZeroScale(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
s.SetScale(0)
|
||||
assert.Equal(t, int32(1), s.Scale())
|
||||
}
|
||||
|
||||
func TestSurfaceState_NegativeScale(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
s.SetScale(-5)
|
||||
assert.Equal(t, int32(1), s.Scale())
|
||||
}
|
||||
|
||||
func TestSurfaceState_ZeroDimensionConfigure(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
|
||||
err := s.OnLayerConfigure(0, 100)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = s.OnLayerConfigure(100, 0)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = s.OnLayerConfigure(-1, 100)
|
||||
assert.NoError(t, err)
|
||||
|
||||
w, h := s.LogicalSize()
|
||||
assert.Equal(t, 0, w)
|
||||
assert.Equal(t, 0, h)
|
||||
}
|
||||
|
||||
func TestSurfaceState_PickColorNilBuffer(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
color, ok := s.PickColor()
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, Color{}, color)
|
||||
}
|
||||
|
||||
func TestSurfaceState_RedrawNilBuffer(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
buf := s.Redraw()
|
||||
assert.Nil(t, buf)
|
||||
}
|
||||
|
||||
func TestSurfaceState_RedrawScreenOnlyNilBuffer(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
buf := s.RedrawScreenOnly()
|
||||
assert.Nil(t, buf)
|
||||
}
|
||||
|
||||
func TestSurfaceState_FrontRenderBufferNil(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
buf := s.FrontRenderBuffer()
|
||||
assert.Nil(t, buf)
|
||||
}
|
||||
|
||||
func TestSurfaceState_ScreenBufferNil(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
buf := s.ScreenBuffer()
|
||||
assert.Nil(t, buf)
|
||||
}
|
||||
|
||||
func TestSurfaceState_DestroyMultipleTimes(t *testing.T) {
|
||||
s := NewSurfaceState(FormatHex, false)
|
||||
s.Destroy()
|
||||
s.Destroy()
|
||||
}
|
||||
|
||||
func TestClamp(t *testing.T) {
|
||||
tests := []struct {
|
||||
v, lo, hi, expected int
|
||||
}{
|
||||
{5, 0, 10, 5},
|
||||
{-5, 0, 10, 0},
|
||||
{15, 0, 10, 10},
|
||||
{0, 0, 10, 0},
|
||||
{10, 0, 10, 10},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := clamp(tt.v, tt.lo, tt.hi)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampF(t *testing.T) {
|
||||
tests := []struct {
|
||||
v, lo, hi, expected float64
|
||||
}{
|
||||
{5.0, 0.0, 10.0, 5.0},
|
||||
{-5.0, 0.0, 10.0, 0.0},
|
||||
{15.0, 0.0, 10.0, 10.0},
|
||||
{0.0, 0.0, 10.0, 0.0},
|
||||
{10.0, 0.0, 10.0, 10.0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := clampF(tt.v, tt.lo, tt.hi)
|
||||
assert.InDelta(t, tt.expected, result, 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbs(t *testing.T) {
|
||||
tests := []struct {
|
||||
v, expected int
|
||||
}{
|
||||
{5, 5},
|
||||
{-5, 5},
|
||||
{0, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := abs(tt.v)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlendColors(t *testing.T) {
|
||||
bg := Color{R: 0, G: 0, B: 0, A: 255}
|
||||
fg := Color{R: 255, G: 255, B: 255, A: 255}
|
||||
|
||||
result := blendColors(bg, fg, 0.0)
|
||||
assert.Equal(t, bg.R, result.R)
|
||||
assert.Equal(t, bg.G, result.G)
|
||||
assert.Equal(t, bg.B, result.B)
|
||||
|
||||
result = blendColors(bg, fg, 1.0)
|
||||
assert.Equal(t, fg.R, result.R)
|
||||
assert.Equal(t, fg.G, result.G)
|
||||
assert.Equal(t, fg.B, result.B)
|
||||
|
||||
result = blendColors(bg, fg, 0.5)
|
||||
assert.InDelta(t, 127, int(result.R), 1)
|
||||
assert.InDelta(t, 127, int(result.G), 1)
|
||||
assert.InDelta(t, 127, int(result.B), 1)
|
||||
|
||||
result = blendColors(bg, fg, -1.0)
|
||||
assert.Equal(t, bg.R, result.R)
|
||||
|
||||
result = blendColors(bg, fg, 2.0)
|
||||
assert.Equal(t, fg.R, result.R)
|
||||
}
|
||||
@@ -615,10 +615,11 @@ func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalComma
|
||||
|
||||
spawnDms := `spawn-at-startup "dms" "run"`
|
||||
if !strings.Contains(config, spawnDms) {
|
||||
config = strings.Replace(config,
|
||||
`spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"`,
|
||||
`spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"`+"\n"+spawnDms,
|
||||
1)
|
||||
// Insert spawn-at-startup for dms after the environment block
|
||||
envBlockEnd := regexp.MustCompile(`environment \{[^}]*\}`)
|
||||
if loc := envBlockEnd.FindStringIndex(config); loc != nil {
|
||||
config = config[:loc[1]] + "\n" + spawnDms + config[loc[1]:]
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
@@ -5,15 +5,13 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
func LocateDMSConfig() (string, error) {
|
||||
var primaryPaths []string
|
||||
|
||||
configHome := utils.XDGConfigHome()
|
||||
if configHome != "" {
|
||||
configHome, err := os.UserConfigDir()
|
||||
if err == nil && configHome != "" {
|
||||
primaryPaths = append(primaryPaths, filepath.Join(configHome, "quickshell", "dms"))
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ monitor = , preferred,auto,auto
|
||||
# ==================
|
||||
exec-once = dbus-update-activation-environment --systemd --all
|
||||
exec-once = systemctl --user start hyprland-session.target
|
||||
exec-once = bash -c "wl-paste --watch cliphist store &"
|
||||
|
||||
# ==================
|
||||
# INPUT CONFIG
|
||||
|
||||
@@ -109,7 +109,6 @@ overview {
|
||||
// which may be more convenient to use.
|
||||
// See the binds section below for more spawn examples.
|
||||
// This line starts waybar, a commonly used bar for Wayland compositors.
|
||||
spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"
|
||||
environment {
|
||||
XDG_CURRENT_DESKTOP "niri"
|
||||
}
|
||||
|
||||
@@ -103,10 +103,8 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
||||
dependencies = append(dependencies, a.detectXwaylandSatellite())
|
||||
}
|
||||
|
||||
// Base detections (common across distros)
|
||||
dependencies = append(dependencies, a.detectMatugen())
|
||||
dependencies = append(dependencies, a.detectDgop())
|
||||
dependencies = append(dependencies, a.detectClipboardTools()...)
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -139,8 +137,6 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
|
||||
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||
}
|
||||
|
||||
@@ -185,37 +185,6 @@ func (b *BaseDistribution) detectSpecificTerminal(terminal deps.Terminal) deps.D
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectClipboardTools() []deps.Dependency {
|
||||
var dependencies []deps.Dependency
|
||||
|
||||
cliphist := deps.StatusMissing
|
||||
if b.commandExists("cliphist") {
|
||||
cliphist = deps.StatusInstalled
|
||||
}
|
||||
|
||||
wlClipboard := deps.StatusMissing
|
||||
if b.commandExists("wl-copy") && b.commandExists("wl-paste") {
|
||||
wlClipboard = deps.StatusInstalled
|
||||
}
|
||||
|
||||
dependencies = append(dependencies,
|
||||
deps.Dependency{
|
||||
Name: "cliphist",
|
||||
Status: cliphist,
|
||||
Description: "Wayland clipboard manager",
|
||||
Required: true,
|
||||
},
|
||||
deps.Dependency{
|
||||
Name: "wl-clipboard",
|
||||
Status: wlClipboard,
|
||||
Description: "Wayland clipboard utilities",
|
||||
Required: true,
|
||||
},
|
||||
)
|
||||
|
||||
return dependencies
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectHyprlandTools() []deps.Dependency {
|
||||
var dependencies []deps.Dependency
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, d.detectMatugen())
|
||||
dependencies = append(dependencies, d.detectDgop())
|
||||
dependencies = append(dependencies, d.detectClipboardTools()...)
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -102,7 +101,6 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"git": {Name: "git", Repository: RepoTypeSystem},
|
||||
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||
|
||||
@@ -111,7 +109,6 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"quickshell": d.getQuickshellMapping(variants["quickshell"]),
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"cliphist": {Name: "cliphist", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
}
|
||||
|
||||
@@ -549,7 +546,7 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua
|
||||
if err := d.installRust(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Rust: %w", err)
|
||||
}
|
||||
case "cliphist", "dgop":
|
||||
case "dgop":
|
||||
if err := d.installGo(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Go: %w", err)
|
||||
}
|
||||
|
||||
@@ -88,10 +88,8 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
dependencies = append(dependencies, f.detectXwaylandSatellite())
|
||||
}
|
||||
|
||||
// Base detections (common across distros)
|
||||
dependencies = append(dependencies, f.detectMatugen())
|
||||
dependencies = append(dependencies, f.detectDgop())
|
||||
dependencies = append(dependencies, f.detectClipboardTools()...)
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -117,14 +115,12 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||
|
||||
// COPR packages
|
||||
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"cliphist": {Name: "cliphist", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
}
|
||||
|
||||
@@ -107,7 +107,6 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, g.detectMatugen())
|
||||
dependencies = append(dependencies, g.detectDgop())
|
||||
dependencies = append(dependencies, g.detectClipboardTools()...)
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -140,7 +139,6 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"git": {Name: "dev-vcs/git", Repository: RepoTypeSystem},
|
||||
"kitty": {Name: "x11-terms/kitty", Repository: RepoTypeSystem, UseFlags: "X wayland"},
|
||||
"alacritty": {Name: "x11-terms/alacritty", Repository: RepoTypeSystem, UseFlags: "X wayland"},
|
||||
"wl-clipboard": {Name: "gui-apps/wl-clipboard", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "sys-apps/xdg-desktop-portal-gtk", Repository: RepoTypeSystem, UseFlags: "wayland X"},
|
||||
"accountsservice": {Name: "sys-apps/accountsservice", Repository: RepoTypeSystem},
|
||||
|
||||
@@ -151,7 +149,6 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
|
||||
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
|
||||
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
"cliphist": {Name: "app-misc/cliphist", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
"dms (DankMaterialShell)": g.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
|
||||
}
|
||||
|
||||
@@ -74,10 +74,6 @@ func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, pack
|
||||
if err := m.installHyprland(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install hyprland: %w", err)
|
||||
}
|
||||
case "hyprpicker":
|
||||
if err := m.installHyprpicker(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install hyprpicker: %w", err)
|
||||
}
|
||||
case "ghostty":
|
||||
if err := m.installGhostty(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install ghostty: %w", err)
|
||||
@@ -86,10 +82,6 @@ func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, pack
|
||||
if err := m.installMatugen(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install matugen: %w", err)
|
||||
}
|
||||
case "cliphist":
|
||||
if err := m.installCliphist(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install cliphist: %w", err)
|
||||
}
|
||||
case "xwayland-satellite":
|
||||
if err := m.installXwaylandSatellite(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install xwayland-satellite: %w", err)
|
||||
@@ -405,184 +397,6 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ManualPackageInstaller) installHyprpicker(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
m.log("Installing hyprpicker from source...")
|
||||
|
||||
homeDir := os.Getenv("HOME")
|
||||
if homeDir == "" {
|
||||
return fmt.Errorf("HOME environment variable not set")
|
||||
}
|
||||
|
||||
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
|
||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
// Install hyprutils first
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.05,
|
||||
Step: "Building hyprutils dependency...",
|
||||
IsComplete: false,
|
||||
CommandInfo: "git clone https://github.com/hyprwm/hyprutils.git",
|
||||
}
|
||||
|
||||
hyprutilsDir := filepath.Join(cacheDir, "hyprutils-build")
|
||||
if err := os.MkdirAll(hyprutilsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create hyprutils directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(hyprutilsDir)
|
||||
|
||||
cloneUtilsCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/hyprwm/hyprutils.git", hyprutilsDir)
|
||||
if err := cloneUtilsCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to clone hyprutils: %w", err)
|
||||
}
|
||||
|
||||
configureUtilsCmd := exec.CommandContext(ctx, "cmake",
|
||||
"--no-warn-unused-cli",
|
||||
"-DCMAKE_BUILD_TYPE:STRING=Release",
|
||||
"-DCMAKE_INSTALL_PREFIX:PATH=/usr",
|
||||
"-DBUILD_TESTING=off",
|
||||
"-S", ".",
|
||||
"-B", "./build")
|
||||
configureUtilsCmd.Dir = hyprutilsDir
|
||||
configureUtilsCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
|
||||
if err := m.runWithProgressStep(configureUtilsCmd, progressChan, PhaseSystemPackages, 0.05, 0.1, "Configuring hyprutils..."); err != nil {
|
||||
return fmt.Errorf("failed to configure hyprutils: %w", err)
|
||||
}
|
||||
|
||||
buildUtilsCmd := exec.CommandContext(ctx, "cmake", "--build", "./build", "--config", "Release", "--target", "all")
|
||||
buildUtilsCmd.Dir = hyprutilsDir
|
||||
buildUtilsCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
|
||||
if err := m.runWithProgressStep(buildUtilsCmd, progressChan, PhaseSystemPackages, 0.1, 0.2, "Building hyprutils..."); err != nil {
|
||||
return fmt.Errorf("failed to build hyprutils: %w", err)
|
||||
}
|
||||
|
||||
installUtilsCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install ./build")
|
||||
installUtilsCmd.Dir = hyprutilsDir
|
||||
if err := installUtilsCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to install hyprutils: %w", err)
|
||||
}
|
||||
|
||||
// Install hyprwayland-scanner
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.2,
|
||||
Step: "Building hyprwayland-scanner dependency...",
|
||||
IsComplete: false,
|
||||
CommandInfo: "git clone https://github.com/hyprwm/hyprwayland-scanner.git",
|
||||
}
|
||||
|
||||
scannerDir := filepath.Join(cacheDir, "hyprwayland-scanner-build")
|
||||
if err := os.MkdirAll(scannerDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create scanner directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(scannerDir)
|
||||
|
||||
cloneScannerCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/hyprwm/hyprwayland-scanner.git", scannerDir)
|
||||
if err := cloneScannerCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to clone hyprwayland-scanner: %w", err)
|
||||
}
|
||||
|
||||
configureScannerCmd := exec.CommandContext(ctx, "cmake",
|
||||
"-DCMAKE_INSTALL_PREFIX=/usr",
|
||||
"-B", "build")
|
||||
configureScannerCmd.Dir = scannerDir
|
||||
configureScannerCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
|
||||
if err := m.runWithProgressStep(configureScannerCmd, progressChan, PhaseSystemPackages, 0.2, 0.25, "Configuring hyprwayland-scanner..."); err != nil {
|
||||
return fmt.Errorf("failed to configure hyprwayland-scanner: %w", err)
|
||||
}
|
||||
|
||||
buildScannerCmd := exec.CommandContext(ctx, "cmake", "--build", "build", "-j")
|
||||
buildScannerCmd.Dir = scannerDir
|
||||
buildScannerCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
|
||||
if err := m.runWithProgressStep(buildScannerCmd, progressChan, PhaseSystemPackages, 0.25, 0.35, "Building hyprwayland-scanner..."); err != nil {
|
||||
return fmt.Errorf("failed to build hyprwayland-scanner: %w", err)
|
||||
}
|
||||
|
||||
installScannerCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
|
||||
installScannerCmd.Dir = scannerDir
|
||||
if err := installScannerCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to install hyprwayland-scanner: %w", err)
|
||||
}
|
||||
|
||||
// Now build hyprpicker
|
||||
tmpDir := filepath.Join(cacheDir, "hyprpicker-build")
|
||||
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.35,
|
||||
Step: "Cloning hyprpicker repository...",
|
||||
IsComplete: false,
|
||||
CommandInfo: "git clone https://github.com/hyprwm/hyprpicker.git",
|
||||
}
|
||||
|
||||
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/hyprwm/hyprpicker.git", tmpDir)
|
||||
if err := cloneCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to clone hyprpicker: %w", err)
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.45,
|
||||
Step: "Configuring hyprpicker build...",
|
||||
IsComplete: false,
|
||||
CommandInfo: "cmake -B build -S . -DCMAKE_BUILD_TYPE=Release",
|
||||
}
|
||||
|
||||
configureCmd := exec.CommandContext(ctx, "cmake",
|
||||
"--no-warn-unused-cli",
|
||||
"-DCMAKE_BUILD_TYPE:STRING=Release",
|
||||
"-DCMAKE_INSTALL_PREFIX:PATH=/usr",
|
||||
"-S", ".",
|
||||
"-B", "./build")
|
||||
configureCmd.Dir = tmpDir
|
||||
configureCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
|
||||
|
||||
output, err := configureCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
m.log(fmt.Sprintf("cmake configure failed. Output:\n%s", string(output)))
|
||||
return fmt.Errorf("failed to configure hyprpicker: %w\nCMake output:\n%s", err, string(output))
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.55,
|
||||
Step: "Building hyprpicker...",
|
||||
IsComplete: false,
|
||||
CommandInfo: "cmake --build build --target hyprpicker",
|
||||
}
|
||||
|
||||
buildCmd := exec.CommandContext(ctx, "cmake", "--build", "./build", "--config", "Release", "--target", "hyprpicker")
|
||||
buildCmd.Dir = tmpDir
|
||||
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
|
||||
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.55, 0.8, "Building hyprpicker..."); err != nil {
|
||||
return fmt.Errorf("failed to build hyprpicker: %w", err)
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.8,
|
||||
Step: "Installing hyprpicker...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: "sudo cmake --install build",
|
||||
}
|
||||
|
||||
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install ./build")
|
||||
installCmd.Dir = tmpDir
|
||||
if err := installCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to install hyprpicker: %w", err)
|
||||
}
|
||||
|
||||
m.log("hyprpicker installed successfully from source")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
m.log("Installing Ghostty from source...")
|
||||
|
||||
@@ -803,52 +617,6 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ManualPackageInstaller) installCliphist(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
m.log("Installing cliphist from source...")
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.1,
|
||||
Step: "Installing cliphist via go install...",
|
||||
IsComplete: false,
|
||||
CommandInfo: "go install go.senan.xyz/cliphist@latest",
|
||||
}
|
||||
|
||||
installCmd := exec.CommandContext(ctx, "go", "install", "go.senan.xyz/cliphist@latest")
|
||||
if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building cliphist..."); err != nil {
|
||||
return fmt.Errorf("failed to install cliphist: %w", err)
|
||||
}
|
||||
|
||||
homeDir := os.Getenv("HOME")
|
||||
sourcePath := filepath.Join(homeDir, "go", "bin", "cliphist")
|
||||
targetPath := "/usr/local/bin/cliphist"
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.7,
|
||||
Step: "Installing cliphist binary to system...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
|
||||
}
|
||||
|
||||
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
|
||||
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
||||
if err := copyCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to copy cliphist to /usr/local/bin: %w", err)
|
||||
}
|
||||
|
||||
// Make it executable
|
||||
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
|
||||
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
||||
if err := chmodCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to make cliphist executable: %w", err)
|
||||
}
|
||||
|
||||
m.log("cliphist installed successfully from source")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
m.log("Installing xwayland-satellite from source...")
|
||||
|
||||
|
||||
@@ -78,10 +78,8 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
|
||||
dependencies = append(dependencies, o.detectXwaylandSatellite())
|
||||
}
|
||||
|
||||
// Base detections (common across distros)
|
||||
dependencies = append(dependencies, o.detectMatugen())
|
||||
dependencies = append(dependencies, o.detectDgop())
|
||||
dependencies = append(dependencies, o.detectClipboardTools()...)
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -107,10 +105,8 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
|
||||
|
||||
// DMS packages from OBS
|
||||
"dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
|
||||
@@ -76,10 +76,8 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
dependencies = append(dependencies, u.detectXwaylandSatellite())
|
||||
}
|
||||
|
||||
// Base detections (common across distros)
|
||||
dependencies = append(dependencies, u.detectMatugen())
|
||||
dependencies = append(dependencies, u.detectDgop())
|
||||
dependencies = append(dependencies, u.detectClipboardTools()...)
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -112,7 +110,6 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"git": {Name: "git", Repository: RepoTypeSystem},
|
||||
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||
|
||||
@@ -121,7 +118,6 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"quickshell": u.getQuickshellMapping(variants["quickshell"]),
|
||||
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"cliphist": {Name: "cliphist", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
}
|
||||
|
||||
@@ -539,8 +535,6 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
|
||||
buildDeps["libpam0g-dev"] = true
|
||||
case "matugen":
|
||||
buildDeps["curl"] = true
|
||||
case "cliphist":
|
||||
// Go will be installed separately with PPA
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,7 +544,7 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
|
||||
if err := u.installRust(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Rust: %w", err)
|
||||
}
|
||||
case "cliphist", "dgop":
|
||||
case "dgop":
|
||||
if err := u.installGo(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Go: %w", err)
|
||||
}
|
||||
|
||||
@@ -518,7 +518,7 @@ func (m Model) categorizeDependencies() map[string][]DependencyInfo {
|
||||
categories["Hyprland Components"] = append(categories["Hyprland Components"], dep)
|
||||
case "niri":
|
||||
categories["Niri Components"] = append(categories["Niri Components"], dep)
|
||||
case "kitty", "alacritty", "ghostty", "hyprpicker":
|
||||
case "kitty", "alacritty", "ghostty":
|
||||
categories["Shared Components"] = append(categories["Shared Components"], dep)
|
||||
default:
|
||||
categories["Shared Components"] = append(categories["Shared Components"], dep)
|
||||
|
||||
@@ -16,9 +16,9 @@ type DiscoveryConfig struct {
|
||||
func DefaultDiscoveryConfig() *DiscoveryConfig {
|
||||
var searchPaths []string
|
||||
|
||||
configHome := utils.XDGConfigHome()
|
||||
if configHome != "" {
|
||||
searchPaths = append(searchPaths, filepath.Join(configHome, "DankMaterialShell", "cheatsheets"))
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err == nil && configDir != "" {
|
||||
searchPaths = append(searchPaths, filepath.Join(configDir, "DankMaterialShell", "cheatsheets"))
|
||||
}
|
||||
|
||||
configDirs := os.Getenv("XDG_CONFIG_DIRS")
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/sblinch/kdl-go"
|
||||
"github.com/sblinch/kdl-go/document"
|
||||
)
|
||||
@@ -30,7 +29,11 @@ func NewNiriProvider(configDir string) *NiriProvider {
|
||||
}
|
||||
|
||||
func defaultNiriConfigDir() string {
|
||||
return filepath.Join(utils.XDGConfigHome(), "niri")
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(configDir, "niri")
|
||||
}
|
||||
|
||||
func (n *NiriProvider) Name() string {
|
||||
|
||||
@@ -34,6 +34,7 @@ type Options struct {
|
||||
StockColors string
|
||||
SyncModeWithPortal bool
|
||||
TerminalsAlwaysDark bool
|
||||
SkipTemplates string
|
||||
}
|
||||
|
||||
type ColorsOutput struct {
|
||||
@@ -47,6 +48,18 @@ func (o *Options) ColorsOutput() string {
|
||||
return filepath.Join(o.StateDir, "dms-colors.json")
|
||||
}
|
||||
|
||||
func (o *Options) ShouldSkipTemplate(name string) bool {
|
||||
if o.SkipTemplates == "" {
|
||||
return false
|
||||
}
|
||||
for _, skip := range strings.Split(o.SkipTemplates, ",") {
|
||||
if strings.TrimSpace(skip) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Run(opts Options) error {
|
||||
if opts.StateDir == "" {
|
||||
return fmt.Errorf("state-dir is required")
|
||||
@@ -218,34 +231,66 @@ output_path = '%s'
|
||||
|
||||
`, opts.ShellDir, opts.ColorsOutput())
|
||||
|
||||
if !opts.ShouldSkipTemplate("gtk") {
|
||||
switch opts.Mode {
|
||||
case "light":
|
||||
appendConfig(opts, cfgFile, "skip", "gtk3-light.toml")
|
||||
default:
|
||||
appendConfig(opts, cfgFile, "skip", "gtk3-dark.toml")
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.ShouldSkipTemplate("niri") {
|
||||
appendConfig(opts, cfgFile, "niri", "niri.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("qt5ct") {
|
||||
appendConfig(opts, cfgFile, "qt5ct", "qt5ct.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("qt6ct") {
|
||||
appendConfig(opts, cfgFile, "qt6ct", "qt6ct.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("firefox") {
|
||||
appendConfig(opts, cfgFile, "firefox", "firefox.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("pywalfox") {
|
||||
appendConfig(opts, cfgFile, "pywalfox", "pywalfox.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("vesktop") {
|
||||
appendConfig(opts, cfgFile, "vesktop", "vesktop.toml")
|
||||
}
|
||||
|
||||
if !opts.ShouldSkipTemplate("ghostty") {
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, "ghostty", "ghostty.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("kitty") {
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, "kitty", "kitty.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("foot") {
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, "foot", "foot.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("alacritty") {
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, "alacritty", "alacritty.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("wezterm") {
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, "wezterm", "wezterm.toml")
|
||||
}
|
||||
|
||||
if !opts.ShouldSkipTemplate("dgop") {
|
||||
appendConfig(opts, cfgFile, "dgop", "dgop.toml")
|
||||
}
|
||||
|
||||
if !opts.ShouldSkipTemplate("kcolorscheme") {
|
||||
appendConfig(opts, cfgFile, "skip", "kcolorscheme.toml")
|
||||
}
|
||||
|
||||
if !opts.ShouldSkipTemplate("vscode") {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
|
||||
}
|
||||
|
||||
if opts.RunUserTemplates {
|
||||
if data, err := os.ReadFile(userConfigPath); err == nil {
|
||||
|
||||
229
core/internal/mocks/wlclient/mock_WaylandDisplay.go
Normal file
229
core/internal/mocks/wlclient/mock_WaylandDisplay.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// Code generated by mockery v2.53.5. DO NOT EDIT.
|
||||
|
||||
package mocks_wlclient
|
||||
|
||||
import (
|
||||
client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockWaylandDisplay is an autogenerated mock type for the WaylandDisplay type
|
||||
type MockWaylandDisplay struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockWaylandDisplay_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockWaylandDisplay) EXPECT() *MockWaylandDisplay_Expecter {
|
||||
return &MockWaylandDisplay_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Context provides a mock function with no fields
|
||||
func (_m *MockWaylandDisplay) Context() *client.Context {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Context")
|
||||
}
|
||||
|
||||
var r0 *client.Context
|
||||
if rf, ok := ret.Get(0).(func() *client.Context); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*client.Context)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockWaylandDisplay_Context_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Context'
|
||||
type MockWaylandDisplay_Context_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Context is a helper method to define mock.On call
|
||||
func (_e *MockWaylandDisplay_Expecter) Context() *MockWaylandDisplay_Context_Call {
|
||||
return &MockWaylandDisplay_Context_Call{Call: _e.mock.On("Context")}
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_Context_Call) Run(run func()) *MockWaylandDisplay_Context_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_Context_Call) Return(_a0 *client.Context) *MockWaylandDisplay_Context_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_Context_Call) RunAndReturn(run func() *client.Context) *MockWaylandDisplay_Context_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Destroy provides a mock function with no fields
|
||||
func (_m *MockWaylandDisplay) Destroy() error {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Destroy")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockWaylandDisplay_Destroy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Destroy'
|
||||
type MockWaylandDisplay_Destroy_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Destroy is a helper method to define mock.On call
|
||||
func (_e *MockWaylandDisplay_Expecter) Destroy() *MockWaylandDisplay_Destroy_Call {
|
||||
return &MockWaylandDisplay_Destroy_Call{Call: _e.mock.On("Destroy")}
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_Destroy_Call) Run(run func()) *MockWaylandDisplay_Destroy_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_Destroy_Call) Return(_a0 error) *MockWaylandDisplay_Destroy_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_Destroy_Call) RunAndReturn(run func() error) *MockWaylandDisplay_Destroy_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetRegistry provides a mock function with no fields
|
||||
func (_m *MockWaylandDisplay) GetRegistry() (*client.Registry, error) {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetRegistry")
|
||||
}
|
||||
|
||||
var r0 *client.Registry
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func() (*client.Registry, error)); ok {
|
||||
return rf()
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func() *client.Registry); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*client.Registry)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func() error); ok {
|
||||
r1 = rf()
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockWaylandDisplay_GetRegistry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRegistry'
|
||||
type MockWaylandDisplay_GetRegistry_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetRegistry is a helper method to define mock.On call
|
||||
func (_e *MockWaylandDisplay_Expecter) GetRegistry() *MockWaylandDisplay_GetRegistry_Call {
|
||||
return &MockWaylandDisplay_GetRegistry_Call{Call: _e.mock.On("GetRegistry")}
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_GetRegistry_Call) Run(run func()) *MockWaylandDisplay_GetRegistry_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_GetRegistry_Call) Return(_a0 *client.Registry, _a1 error) *MockWaylandDisplay_GetRegistry_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_GetRegistry_Call) RunAndReturn(run func() (*client.Registry, error)) *MockWaylandDisplay_GetRegistry_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Roundtrip provides a mock function with no fields
|
||||
func (_m *MockWaylandDisplay) Roundtrip() error {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Roundtrip")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockWaylandDisplay_Roundtrip_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Roundtrip'
|
||||
type MockWaylandDisplay_Roundtrip_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Roundtrip is a helper method to define mock.On call
|
||||
func (_e *MockWaylandDisplay_Expecter) Roundtrip() *MockWaylandDisplay_Roundtrip_Call {
|
||||
return &MockWaylandDisplay_Roundtrip_Call{Call: _e.mock.On("Roundtrip")}
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_Roundtrip_Call) Run(run func()) *MockWaylandDisplay_Roundtrip_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_Roundtrip_Call) Return(_a0 error) *MockWaylandDisplay_Roundtrip_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandDisplay_Roundtrip_Call) RunAndReturn(run func() error) *MockWaylandDisplay_Roundtrip_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockWaylandDisplay creates a new instance of MockWaylandDisplay. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockWaylandDisplay(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockWaylandDisplay {
|
||||
mock := &MockWaylandDisplay{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
226
core/internal/mocks/wlcontext/mock_WaylandContext.go
Normal file
226
core/internal/mocks/wlcontext/mock_WaylandContext.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Code generated by mockery v2.53.5. DO NOT EDIT.
|
||||
|
||||
package mocks_wlcontext
|
||||
|
||||
import (
|
||||
client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockWaylandContext is an autogenerated mock type for the WaylandContext type
|
||||
type MockWaylandContext struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockWaylandContext_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockWaylandContext) EXPECT() *MockWaylandContext_Expecter {
|
||||
return &MockWaylandContext_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Close provides a mock function with no fields
|
||||
func (_m *MockWaylandContext) Close() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// MockWaylandContext_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
|
||||
type MockWaylandContext_Close_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Close is a helper method to define mock.On call
|
||||
func (_e *MockWaylandContext_Expecter) Close() *MockWaylandContext_Close_Call {
|
||||
return &MockWaylandContext_Close_Call{Call: _e.mock.On("Close")}
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Close_Call) Run(run func()) *MockWaylandContext_Close_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Close_Call) Return() *MockWaylandContext_Close_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Close_Call) RunAndReturn(run func()) *MockWaylandContext_Close_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Display provides a mock function with no fields
|
||||
func (_m *MockWaylandContext) Display() *client.Display {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Display")
|
||||
}
|
||||
|
||||
var r0 *client.Display
|
||||
if rf, ok := ret.Get(0).(func() *client.Display); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*client.Display)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockWaylandContext_Display_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Display'
|
||||
type MockWaylandContext_Display_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Display is a helper method to define mock.On call
|
||||
func (_e *MockWaylandContext_Expecter) Display() *MockWaylandContext_Display_Call {
|
||||
return &MockWaylandContext_Display_Call{Call: _e.mock.On("Display")}
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Display_Call) Run(run func()) *MockWaylandContext_Display_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Display_Call) Return(_a0 *client.Display) *MockWaylandContext_Display_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Display_Call) RunAndReturn(run func() *client.Display) *MockWaylandContext_Display_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// FatalError provides a mock function with no fields
|
||||
func (_m *MockWaylandContext) FatalError() <-chan error {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for FatalError")
|
||||
}
|
||||
|
||||
var r0 <-chan error
|
||||
if rf, ok := ret.Get(0).(func() <-chan error); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(<-chan error)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockWaylandContext_FatalError_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FatalError'
|
||||
type MockWaylandContext_FatalError_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// FatalError is a helper method to define mock.On call
|
||||
func (_e *MockWaylandContext_Expecter) FatalError() *MockWaylandContext_FatalError_Call {
|
||||
return &MockWaylandContext_FatalError_Call{Call: _e.mock.On("FatalError")}
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_FatalError_Call) Run(run func()) *MockWaylandContext_FatalError_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_FatalError_Call) Return(_a0 <-chan error) *MockWaylandContext_FatalError_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_FatalError_Call) RunAndReturn(run func() <-chan error) *MockWaylandContext_FatalError_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Post provides a mock function with given fields: fn
|
||||
func (_m *MockWaylandContext) Post(fn func()) {
|
||||
_m.Called(fn)
|
||||
}
|
||||
|
||||
// MockWaylandContext_Post_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Post'
|
||||
type MockWaylandContext_Post_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Post is a helper method to define mock.On call
|
||||
// - fn func()
|
||||
func (_e *MockWaylandContext_Expecter) Post(fn interface{}) *MockWaylandContext_Post_Call {
|
||||
return &MockWaylandContext_Post_Call{Call: _e.mock.On("Post", fn)}
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Post_Call) Run(run func(fn func())) *MockWaylandContext_Post_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(func()))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Post_Call) Return() *MockWaylandContext_Post_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Post_Call) RunAndReturn(run func(func())) *MockWaylandContext_Post_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Start provides a mock function with no fields
|
||||
func (_m *MockWaylandContext) Start() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// MockWaylandContext_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start'
|
||||
type MockWaylandContext_Start_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Start is a helper method to define mock.On call
|
||||
func (_e *MockWaylandContext_Expecter) Start() *MockWaylandContext_Start_Call {
|
||||
return &MockWaylandContext_Start_Call{Call: _e.mock.On("Start")}
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Start_Call) Run(run func()) *MockWaylandContext_Start_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Start_Call) Return() *MockWaylandContext_Start_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWaylandContext_Start_Call) RunAndReturn(run func()) *MockWaylandContext_Start_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockWaylandContext creates a new instance of MockWaylandContext. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockWaylandContext(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockWaylandContext {
|
||||
mock := &MockWaylandContext{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -33,7 +33,12 @@ func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
|
||||
}
|
||||
|
||||
func getPluginsDir() string {
|
||||
return filepath.Join(utils.XDGConfigHome(), "DankMaterialShell", "plugins")
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Error("failed to get user config dir", "err", err)
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(configDir, "DankMaterialShell", "plugins")
|
||||
}
|
||||
|
||||
func (m *Manager) IsInstalled(plugin Plugin) (bool, error) {
|
||||
|
||||
695
core/internal/proto/ext_data_control/data_control.go
Normal file
695
core/internal/proto/ext_data_control/data_control.go
Normal file
@@ -0,0 +1,695 @@
|
||||
// Generated by go-wayland-scanner
|
||||
// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
|
||||
// XML file : internal/proto/xml/ext-data-control-v1.xml
|
||||
//
|
||||
// ext_data_control_v1 Protocol Copyright:
|
||||
//
|
||||
// Copyright © 2018 Simon Ser
|
||||
// Copyright © 2019 Ivan Molodetskikh
|
||||
// Copyright © 2024 Neal Gompa
|
||||
//
|
||||
// Permission to use, copy, modify, distribute, and sell this
|
||||
// software and its documentation for any purpose is hereby granted
|
||||
// without fee, provided that the above copyright notice appear in
|
||||
// all copies and that both that copyright notice and this permission
|
||||
// notice appear in supporting documentation, and that the name of
|
||||
// the copyright holders not be used in advertising or publicity
|
||||
// pertaining to distribution of the software without specific,
|
||||
// written prior permission. The copyright holders make no
|
||||
// representations about the suitability of this software for any
|
||||
// purpose. It is provided "as is" without express or implied
|
||||
// warranty.
|
||||
//
|
||||
// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
|
||||
// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
|
||||
// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
||||
// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
// THIS SOFTWARE.
|
||||
|
||||
package ext_data_control
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ExtDataControlManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ExtDataControlManagerV1InterfaceName = "ext_data_control_manager_v1"
|
||||
|
||||
// ExtDataControlManagerV1 : manager to control data devices
|
||||
//
|
||||
// This interface is a manager that allows creating per-seat data device
|
||||
// controls.
|
||||
type ExtDataControlManagerV1 struct {
|
||||
client.BaseProxy
|
||||
}
|
||||
|
||||
// NewExtDataControlManagerV1 : manager to control data devices
|
||||
//
|
||||
// This interface is a manager that allows creating per-seat data device
|
||||
// controls.
|
||||
func NewExtDataControlManagerV1(ctx *client.Context) *ExtDataControlManagerV1 {
|
||||
extDataControlManagerV1 := &ExtDataControlManagerV1{}
|
||||
ctx.Register(extDataControlManagerV1)
|
||||
return extDataControlManagerV1
|
||||
}
|
||||
|
||||
// CreateDataSource : create a new data source
|
||||
//
|
||||
// Create a new data source.
|
||||
func (i *ExtDataControlManagerV1) CreateDataSource() (*ExtDataControlSourceV1, error) {
|
||||
id := NewExtDataControlSourceV1(i.Context())
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], id.ID())
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// GetDataDevice : get a data device for a seat
|
||||
//
|
||||
// Create a data device that can be used to manage a seat's selection.
|
||||
func (i *ExtDataControlManagerV1) GetDataDevice(seat *client.Seat) (*ExtDataControlDeviceV1, error) {
|
||||
id := NewExtDataControlDeviceV1(i.Context())
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], id.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], seat.ID())
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// GetDataDeviceWithProxy : get a data device for a seat using a pre-created proxy
|
||||
//
|
||||
// Like GetDataDevice, but uses a pre-created ExtDataControlDeviceV1 proxy.
|
||||
// This allows setting up event handlers before the request is sent.
|
||||
func (i *ExtDataControlManagerV1) GetDataDeviceWithProxy(device *ExtDataControlDeviceV1, seat *client.Seat) error {
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], device.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], seat.ID())
|
||||
l += 4
|
||||
return i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
}
|
||||
|
||||
// Destroy : destroy the manager
|
||||
//
|
||||
// All objects created by the manager will still remain valid, until their
|
||||
// appropriate destroy request has been called.
|
||||
func (i *ExtDataControlManagerV1) Destroy() error {
|
||||
defer i.MarkZombie()
|
||||
const opcode = 2
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExtDataControlDeviceV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ExtDataControlDeviceV1InterfaceName = "ext_data_control_device_v1"
|
||||
|
||||
// ExtDataControlDeviceV1 : manage a data device for a seat
|
||||
//
|
||||
// This interface allows a client to manage a seat's selection.
|
||||
//
|
||||
// When the seat is destroyed, this object becomes inert.
|
||||
type ExtDataControlDeviceV1 struct {
|
||||
client.BaseProxy
|
||||
dataOfferHandler ExtDataControlDeviceV1DataOfferHandlerFunc
|
||||
selectionHandler ExtDataControlDeviceV1SelectionHandlerFunc
|
||||
finishedHandler ExtDataControlDeviceV1FinishedHandlerFunc
|
||||
primarySelectionHandler ExtDataControlDeviceV1PrimarySelectionHandlerFunc
|
||||
}
|
||||
|
||||
// NewExtDataControlDeviceV1 : manage a data device for a seat
|
||||
//
|
||||
// This interface allows a client to manage a seat's selection.
|
||||
//
|
||||
// When the seat is destroyed, this object becomes inert.
|
||||
func NewExtDataControlDeviceV1(ctx *client.Context) *ExtDataControlDeviceV1 {
|
||||
extDataControlDeviceV1 := &ExtDataControlDeviceV1{}
|
||||
ctx.Register(extDataControlDeviceV1)
|
||||
return extDataControlDeviceV1
|
||||
}
|
||||
|
||||
// SetSelection : copy data to the selection
|
||||
//
|
||||
// This request asks the compositor to set the selection to the data from
|
||||
// the source on behalf of the client.
|
||||
//
|
||||
// The given source may not be used in any further set_selection or
|
||||
// set_primary_selection requests. Attempting to use a previously used
|
||||
// source triggers the used_source protocol error.
|
||||
//
|
||||
// To unset the selection, set the source to NULL.
|
||||
func (i *ExtDataControlDeviceV1) SetSelection(source *ExtDataControlSourceV1) error {
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
if source == nil {
|
||||
client.PutUint32(_reqBuf[l:l+4], 0)
|
||||
l += 4
|
||||
} else {
|
||||
client.PutUint32(_reqBuf[l:l+4], source.ID())
|
||||
l += 4
|
||||
}
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Destroy : destroy this data device
|
||||
//
|
||||
// Destroys the data device object.
|
||||
func (i *ExtDataControlDeviceV1) Destroy() error {
|
||||
defer i.MarkZombie()
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPrimarySelection : copy data to the primary selection
|
||||
//
|
||||
// This request asks the compositor to set the primary selection to the
|
||||
// data from the source on behalf of the client.
|
||||
//
|
||||
// The given source may not be used in any further set_selection or
|
||||
// set_primary_selection requests. Attempting to use a previously used
|
||||
// source triggers the used_source protocol error.
|
||||
//
|
||||
// To unset the primary selection, set the source to NULL.
|
||||
//
|
||||
// The compositor will ignore this request if it does not support primary
|
||||
// selection.
|
||||
func (i *ExtDataControlDeviceV1) SetPrimarySelection(source *ExtDataControlSourceV1) error {
|
||||
const opcode = 2
|
||||
const _reqBufLen = 8 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
if source == nil {
|
||||
client.PutUint32(_reqBuf[l:l+4], 0)
|
||||
l += 4
|
||||
} else {
|
||||
client.PutUint32(_reqBuf[l:l+4], source.ID())
|
||||
l += 4
|
||||
}
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
type ExtDataControlDeviceV1Error uint32
|
||||
|
||||
// ExtDataControlDeviceV1Error :
|
||||
const (
|
||||
// ExtDataControlDeviceV1ErrorUsedSource : source given to set_selection or set_primary_selection was already used before
|
||||
ExtDataControlDeviceV1ErrorUsedSource ExtDataControlDeviceV1Error = 1
|
||||
)
|
||||
|
||||
func (e ExtDataControlDeviceV1Error) Name() string {
|
||||
switch e {
|
||||
case ExtDataControlDeviceV1ErrorUsedSource:
|
||||
return "used_source"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ExtDataControlDeviceV1Error) Value() string {
|
||||
switch e {
|
||||
case ExtDataControlDeviceV1ErrorUsedSource:
|
||||
return "1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ExtDataControlDeviceV1Error) String() string {
|
||||
return e.Name() + "=" + e.Value()
|
||||
}
|
||||
|
||||
// ExtDataControlDeviceV1DataOfferEvent : introduce a new ext_data_control_offer
|
||||
//
|
||||
// The data_offer event introduces a new ext_data_control_offer object,
|
||||
// which will subsequently be used in either the
|
||||
// ext_data_control_device.selection event (for the regular clipboard
|
||||
// selections) or the ext_data_control_device.primary_selection event (for
|
||||
// the primary clipboard selections). Immediately following the
|
||||
// ext_data_control_device.data_offer event, the new data_offer object
|
||||
// will send out ext_data_control_offer.offer events to describe the MIME
|
||||
// types it offers.
|
||||
type ExtDataControlDeviceV1DataOfferEvent struct {
|
||||
Id *ExtDataControlOfferV1
|
||||
}
|
||||
type ExtDataControlDeviceV1DataOfferHandlerFunc func(ExtDataControlDeviceV1DataOfferEvent)
|
||||
|
||||
// SetDataOfferHandler : sets handler for ExtDataControlDeviceV1DataOfferEvent
|
||||
func (i *ExtDataControlDeviceV1) SetDataOfferHandler(f ExtDataControlDeviceV1DataOfferHandlerFunc) {
|
||||
i.dataOfferHandler = f
|
||||
}
|
||||
|
||||
// ExtDataControlDeviceV1SelectionEvent : advertise new selection
|
||||
//
|
||||
// The selection event is sent out to notify the client of a new
|
||||
// ext_data_control_offer for the selection for this device. The
|
||||
// ext_data_control_device.data_offer and the ext_data_control_offer.offer
|
||||
// events are sent out immediately before this event to introduce the data
|
||||
// offer object. The selection event is sent to a client when a new
|
||||
// selection is set. The ext_data_control_offer is valid until a new
|
||||
// ext_data_control_offer or NULL is received. The client must destroy the
|
||||
// previous selection ext_data_control_offer, if any, upon receiving this
|
||||
// event. Regardless, the previous selection will be ignored once a new
|
||||
// selection ext_data_control_offer is received.
|
||||
//
|
||||
// The first selection event is sent upon binding the
|
||||
// ext_data_control_device object.
|
||||
type ExtDataControlDeviceV1SelectionEvent struct {
|
||||
Id *ExtDataControlOfferV1
|
||||
OfferId uint32 // Raw object ID for external registry lookups
|
||||
}
|
||||
type ExtDataControlDeviceV1SelectionHandlerFunc func(ExtDataControlDeviceV1SelectionEvent)
|
||||
|
||||
// SetSelectionHandler : sets handler for ExtDataControlDeviceV1SelectionEvent
|
||||
func (i *ExtDataControlDeviceV1) SetSelectionHandler(f ExtDataControlDeviceV1SelectionHandlerFunc) {
|
||||
i.selectionHandler = f
|
||||
}
|
||||
|
||||
// ExtDataControlDeviceV1FinishedEvent : this data control is no longer valid
|
||||
//
|
||||
// This data control object is no longer valid and should be destroyed by
|
||||
// the client.
|
||||
type ExtDataControlDeviceV1FinishedEvent struct{}
|
||||
type ExtDataControlDeviceV1FinishedHandlerFunc func(ExtDataControlDeviceV1FinishedEvent)
|
||||
|
||||
// SetFinishedHandler : sets handler for ExtDataControlDeviceV1FinishedEvent
|
||||
func (i *ExtDataControlDeviceV1) SetFinishedHandler(f ExtDataControlDeviceV1FinishedHandlerFunc) {
|
||||
i.finishedHandler = f
|
||||
}
|
||||
|
||||
// ExtDataControlDeviceV1PrimarySelectionEvent : advertise new primary selection
|
||||
//
|
||||
// The primary_selection event is sent out to notify the client of a new
|
||||
// ext_data_control_offer for the primary selection for this device. The
|
||||
// ext_data_control_device.data_offer and the ext_data_control_offer.offer
|
||||
// events are sent out immediately before this event to introduce the data
|
||||
// offer object. The primary_selection event is sent to a client when a
|
||||
// new primary selection is set. The ext_data_control_offer is valid until
|
||||
// a new ext_data_control_offer or NULL is received. The client must
|
||||
// destroy the previous primary selection ext_data_control_offer, if any,
|
||||
// upon receiving this event. Regardless, the previous primary selection
|
||||
// will be ignored once a new primary selection ext_data_control_offer is
|
||||
// received.
|
||||
//
|
||||
// If the compositor supports primary selection, the first
|
||||
// primary_selection event is sent upon binding the
|
||||
// ext_data_control_device object.
|
||||
type ExtDataControlDeviceV1PrimarySelectionEvent struct {
|
||||
Id *ExtDataControlOfferV1
|
||||
OfferId uint32 // Raw object ID for external registry lookups
|
||||
}
|
||||
type ExtDataControlDeviceV1PrimarySelectionHandlerFunc func(ExtDataControlDeviceV1PrimarySelectionEvent)
|
||||
|
||||
// SetPrimarySelectionHandler : sets handler for ExtDataControlDeviceV1PrimarySelectionEvent
|
||||
func (i *ExtDataControlDeviceV1) SetPrimarySelectionHandler(f ExtDataControlDeviceV1PrimarySelectionHandlerFunc) {
|
||||
i.primarySelectionHandler = f
|
||||
}
|
||||
|
||||
func (i *ExtDataControlDeviceV1) Dispatch(opcode uint32, fd int, data []byte) {
|
||||
switch opcode {
|
||||
case 0:
|
||||
// data_offer event: server creates a new object (new_id)
|
||||
if i.dataOfferHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlDeviceV1DataOfferEvent
|
||||
l := 0
|
||||
newID := client.Uint32(data[l : l+4])
|
||||
l += 4
|
||||
|
||||
ctx := i.Context()
|
||||
offer := &ExtDataControlOfferV1{}
|
||||
offer.SetContext(ctx)
|
||||
offer.SetID(newID)
|
||||
ctx.RegisterWithID(offer, newID)
|
||||
e.Id = offer
|
||||
|
||||
i.dataOfferHandler(e)
|
||||
case 1:
|
||||
// selection event: nullable object reference
|
||||
if i.selectionHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlDeviceV1SelectionEvent
|
||||
l := 0
|
||||
objID := client.Uint32(data[l : l+4])
|
||||
l += 4
|
||||
|
||||
e.OfferId = objID
|
||||
if objID != 0 {
|
||||
if p := i.Context().GetProxy(objID); p != nil {
|
||||
e.Id = p.(*ExtDataControlOfferV1)
|
||||
}
|
||||
}
|
||||
|
||||
i.selectionHandler(e)
|
||||
case 2:
|
||||
if i.finishedHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlDeviceV1FinishedEvent
|
||||
|
||||
i.finishedHandler(e)
|
||||
case 3:
|
||||
// primary_selection event: nullable object reference
|
||||
if i.primarySelectionHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlDeviceV1PrimarySelectionEvent
|
||||
l := 0
|
||||
objID := client.Uint32(data[l : l+4])
|
||||
l += 4
|
||||
|
||||
e.OfferId = objID
|
||||
if objID != 0 {
|
||||
if p := i.Context().GetProxy(objID); p != nil {
|
||||
e.Id = p.(*ExtDataControlOfferV1)
|
||||
}
|
||||
}
|
||||
|
||||
i.primarySelectionHandler(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ExtDataControlSourceV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ExtDataControlSourceV1InterfaceName = "ext_data_control_source_v1"
|
||||
|
||||
// ExtDataControlSourceV1 : offer to transfer data
|
||||
//
|
||||
// The ext_data_control_source object is the source side of a
|
||||
// ext_data_control_offer. It is created by the source client in a data
|
||||
// transfer and provides a way to describe the offered data and a way to
|
||||
// respond to requests to transfer the data.
|
||||
type ExtDataControlSourceV1 struct {
|
||||
client.BaseProxy
|
||||
sendHandler ExtDataControlSourceV1SendHandlerFunc
|
||||
cancelledHandler ExtDataControlSourceV1CancelledHandlerFunc
|
||||
}
|
||||
|
||||
// NewExtDataControlSourceV1 : offer to transfer data
|
||||
//
|
||||
// The ext_data_control_source object is the source side of a
|
||||
// ext_data_control_offer. It is created by the source client in a data
|
||||
// transfer and provides a way to describe the offered data and a way to
|
||||
// respond to requests to transfer the data.
|
||||
func NewExtDataControlSourceV1(ctx *client.Context) *ExtDataControlSourceV1 {
|
||||
extDataControlSourceV1 := &ExtDataControlSourceV1{}
|
||||
ctx.Register(extDataControlSourceV1)
|
||||
return extDataControlSourceV1
|
||||
}
|
||||
|
||||
// Offer : add an offered MIME type
|
||||
//
|
||||
// This request adds a MIME type to the set of MIME types advertised to
|
||||
// targets. Can be called several times to offer multiple types.
|
||||
//
|
||||
// Calling this after ext_data_control_device.set_selection is a protocol
|
||||
// error.
|
||||
//
|
||||
// mimeType: MIME type offered by the data source
|
||||
func (i *ExtDataControlSourceV1) Offer(mimeType string) error {
|
||||
const opcode = 0
|
||||
mimeTypeLen := client.PaddedLen(len(mimeType) + 1)
|
||||
_reqBufLen := 8 + (4 + mimeTypeLen)
|
||||
_reqBuf := make([]byte, _reqBufLen)
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutString(_reqBuf[l:l+(4+mimeTypeLen)], mimeType)
|
||||
l += (4 + mimeTypeLen)
|
||||
err := i.Context().WriteMsg(_reqBuf, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Destroy : destroy this source
|
||||
//
|
||||
// Destroys the data source object.
|
||||
func (i *ExtDataControlSourceV1) Destroy() error {
|
||||
defer i.MarkZombie()
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
type ExtDataControlSourceV1Error uint32
|
||||
|
||||
// ExtDataControlSourceV1Error :
|
||||
const (
|
||||
// ExtDataControlSourceV1ErrorInvalidOffer : offer sent after ext_data_control_device.set_selection
|
||||
ExtDataControlSourceV1ErrorInvalidOffer ExtDataControlSourceV1Error = 1
|
||||
)
|
||||
|
||||
func (e ExtDataControlSourceV1Error) Name() string {
|
||||
switch e {
|
||||
case ExtDataControlSourceV1ErrorInvalidOffer:
|
||||
return "invalid_offer"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ExtDataControlSourceV1Error) Value() string {
|
||||
switch e {
|
||||
case ExtDataControlSourceV1ErrorInvalidOffer:
|
||||
return "1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ExtDataControlSourceV1Error) String() string {
|
||||
return e.Name() + "=" + e.Value()
|
||||
}
|
||||
|
||||
// ExtDataControlSourceV1SendEvent : send the data
|
||||
//
|
||||
// Request for data from the client. Send the data as the specified MIME
|
||||
// type over the passed file descriptor, then close it.
|
||||
type ExtDataControlSourceV1SendEvent struct {
|
||||
MimeType string
|
||||
Fd int
|
||||
}
|
||||
type ExtDataControlSourceV1SendHandlerFunc func(ExtDataControlSourceV1SendEvent)
|
||||
|
||||
// SetSendHandler : sets handler for ExtDataControlSourceV1SendEvent
|
||||
func (i *ExtDataControlSourceV1) SetSendHandler(f ExtDataControlSourceV1SendHandlerFunc) {
|
||||
i.sendHandler = f
|
||||
}
|
||||
|
||||
// ExtDataControlSourceV1CancelledEvent : selection was cancelled
|
||||
//
|
||||
// This data source is no longer valid. The data source has been replaced
|
||||
// by another data source.
|
||||
//
|
||||
// The client should clean up and destroy this data source.
|
||||
type ExtDataControlSourceV1CancelledEvent struct{}
|
||||
type ExtDataControlSourceV1CancelledHandlerFunc func(ExtDataControlSourceV1CancelledEvent)
|
||||
|
||||
// SetCancelledHandler : sets handler for ExtDataControlSourceV1CancelledEvent
|
||||
func (i *ExtDataControlSourceV1) SetCancelledHandler(f ExtDataControlSourceV1CancelledHandlerFunc) {
|
||||
i.cancelledHandler = f
|
||||
}
|
||||
|
||||
func (i *ExtDataControlSourceV1) Dispatch(opcode uint32, fd int, data []byte) {
|
||||
switch opcode {
|
||||
case 0:
|
||||
if i.sendHandler == nil {
|
||||
if fd != -1 {
|
||||
unix.Close(fd)
|
||||
}
|
||||
return
|
||||
}
|
||||
var e ExtDataControlSourceV1SendEvent
|
||||
l := 0
|
||||
mimeTypeLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
||||
l += 4
|
||||
e.MimeType = client.String(data[l : l+mimeTypeLen])
|
||||
l += mimeTypeLen
|
||||
e.Fd = fd
|
||||
|
||||
i.sendHandler(e)
|
||||
case 1:
|
||||
if i.cancelledHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlSourceV1CancelledEvent
|
||||
|
||||
i.cancelledHandler(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ExtDataControlOfferV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ExtDataControlOfferV1InterfaceName = "ext_data_control_offer_v1"
|
||||
|
||||
// ExtDataControlOfferV1 : offer to transfer data
|
||||
//
|
||||
// A ext_data_control_offer represents a piece of data offered for transfer
|
||||
// by another client (the source client). The offer describes the different
|
||||
// MIME types that the data can be converted to and provides the mechanism
|
||||
// for transferring the data directly from the source client.
|
||||
type ExtDataControlOfferV1 struct {
|
||||
client.BaseProxy
|
||||
offerHandler ExtDataControlOfferV1OfferHandlerFunc
|
||||
}
|
||||
|
||||
// NewExtDataControlOfferV1 : offer to transfer data
|
||||
//
|
||||
// A ext_data_control_offer represents a piece of data offered for transfer
|
||||
// by another client (the source client). The offer describes the different
|
||||
// MIME types that the data can be converted to and provides the mechanism
|
||||
// for transferring the data directly from the source client.
|
||||
func NewExtDataControlOfferV1(ctx *client.Context) *ExtDataControlOfferV1 {
|
||||
extDataControlOfferV1 := &ExtDataControlOfferV1{}
|
||||
ctx.Register(extDataControlOfferV1)
|
||||
return extDataControlOfferV1
|
||||
}
|
||||
|
||||
// Receive : request that the data is transferred
|
||||
//
|
||||
// To transfer the offered data, the client issues this request and
|
||||
// indicates the MIME type it wants to receive. The transfer happens
|
||||
// through the passed file descriptor (typically created with the pipe
|
||||
// system call). The source client writes the data in the MIME type
|
||||
// representation requested and then closes the file descriptor.
|
||||
//
|
||||
// The receiving client reads from the read end of the pipe until EOF and
|
||||
// then closes its end, at which point the transfer is complete.
|
||||
//
|
||||
// This request may happen multiple times for different MIME types.
|
||||
//
|
||||
// mimeType: MIME type desired by receiver
|
||||
// fd: file descriptor for data transfer
|
||||
func (i *ExtDataControlOfferV1) Receive(mimeType string, fd int) error {
|
||||
const opcode = 0
|
||||
mimeTypeLen := client.PaddedLen(len(mimeType) + 1)
|
||||
_reqBufLen := 8 + (4 + mimeTypeLen)
|
||||
_reqBuf := make([]byte, _reqBufLen)
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutString(_reqBuf[l:l+(4+mimeTypeLen)], mimeType)
|
||||
l += (4 + mimeTypeLen)
|
||||
oob := unix.UnixRights(int(fd))
|
||||
err := i.Context().WriteMsg(_reqBuf, oob)
|
||||
return err
|
||||
}
|
||||
|
||||
// Destroy : destroy this offer
|
||||
//
|
||||
// Destroys the data offer object.
|
||||
func (i *ExtDataControlOfferV1) Destroy() error {
|
||||
defer i.MarkZombie()
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExtDataControlOfferV1OfferEvent : advertise offered MIME type
|
||||
//
|
||||
// Sent immediately after creating the ext_data_control_offer object.
|
||||
// One event per offered MIME type.
|
||||
type ExtDataControlOfferV1OfferEvent struct {
|
||||
MimeType string
|
||||
}
|
||||
type ExtDataControlOfferV1OfferHandlerFunc func(ExtDataControlOfferV1OfferEvent)
|
||||
|
||||
// SetOfferHandler : sets handler for ExtDataControlOfferV1OfferEvent
|
||||
func (i *ExtDataControlOfferV1) SetOfferHandler(f ExtDataControlOfferV1OfferHandlerFunc) {
|
||||
i.offerHandler = f
|
||||
}
|
||||
|
||||
func (i *ExtDataControlOfferV1) Dispatch(opcode uint32, fd int, data []byte) {
|
||||
switch opcode {
|
||||
case 0:
|
||||
if i.offerHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlOfferV1OfferEvent
|
||||
l := 0
|
||||
mimeTypeLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
||||
l += 4
|
||||
e.MimeType = client.String(data[l : l+mimeTypeLen])
|
||||
l += mimeTypeLen
|
||||
|
||||
i.offerHandler(e)
|
||||
}
|
||||
}
|
||||
276
core/internal/proto/xml/ext-data-control-v1.xml
Normal file
276
core/internal/proto/xml/ext-data-control-v1.xml
Normal file
@@ -0,0 +1,276 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="ext_data_control_v1">
|
||||
<copyright>
|
||||
Copyright © 2018 Simon Ser
|
||||
Copyright © 2019 Ivan Molodetskikh
|
||||
Copyright © 2024 Neal Gompa
|
||||
|
||||
Permission to use, copy, modify, distribute, and sell this
|
||||
software and its documentation for any purpose is hereby granted
|
||||
without fee, provided that the above copyright notice appear in
|
||||
all copies and that both that copyright notice and this permission
|
||||
notice appear in supporting documentation, and that the name of
|
||||
the copyright holders not be used in advertising or publicity
|
||||
pertaining to distribution of the software without specific,
|
||||
written prior permission. The copyright holders make no
|
||||
representations about the suitability of this software for any
|
||||
purpose. It is provided "as is" without express or implied
|
||||
warranty.
|
||||
|
||||
THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
|
||||
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
|
||||
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
||||
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
THIS SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<description summary="control data devices">
|
||||
This protocol allows a privileged client to control data devices. In
|
||||
particular, the client will be able to manage the current selection and take
|
||||
the role of a clipboard manager.
|
||||
|
||||
Warning! The protocol described in this file is currently in the testing
|
||||
phase. Backward compatible changes may be added together with the
|
||||
corresponding interface version bump. Backward incompatible changes can
|
||||
only be done by creating a new major version of the extension.
|
||||
</description>
|
||||
|
||||
<interface name="ext_data_control_manager_v1" version="1">
|
||||
<description summary="manager to control data devices">
|
||||
This interface is a manager that allows creating per-seat data device
|
||||
controls.
|
||||
</description>
|
||||
|
||||
<request name="create_data_source">
|
||||
<description summary="create a new data source">
|
||||
Create a new data source.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="ext_data_control_source_v1"
|
||||
summary="data source to create"/>
|
||||
</request>
|
||||
|
||||
<request name="get_data_device">
|
||||
<description summary="get a data device for a seat">
|
||||
Create a data device that can be used to manage a seat's selection.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="ext_data_control_device_v1"/>
|
||||
<arg name="seat" type="object" interface="wl_seat"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the manager">
|
||||
All objects created by the manager will still remain valid, until their
|
||||
appropriate destroy request has been called.
|
||||
</description>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="ext_data_control_device_v1" version="1">
|
||||
<description summary="manage a data device for a seat">
|
||||
This interface allows a client to manage a seat's selection.
|
||||
|
||||
When the seat is destroyed, this object becomes inert.
|
||||
</description>
|
||||
|
||||
<request name="set_selection">
|
||||
<description summary="copy data to the selection">
|
||||
This request asks the compositor to set the selection to the data from
|
||||
the source on behalf of the client.
|
||||
|
||||
The given source may not be used in any further set_selection or
|
||||
set_primary_selection requests. Attempting to use a previously used
|
||||
source triggers the used_source protocol error.
|
||||
|
||||
To unset the selection, set the source to NULL.
|
||||
</description>
|
||||
<arg name="source" type="object" interface="ext_data_control_source_v1"
|
||||
allow-null="true"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy this data device">
|
||||
Destroys the data device object.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="data_offer">
|
||||
<description summary="introduce a new ext_data_control_offer">
|
||||
The data_offer event introduces a new ext_data_control_offer object,
|
||||
which will subsequently be used in either the
|
||||
ext_data_control_device.selection event (for the regular clipboard
|
||||
selections) or the ext_data_control_device.primary_selection event (for
|
||||
the primary clipboard selections). Immediately following the
|
||||
ext_data_control_device.data_offer event, the new data_offer object
|
||||
will send out ext_data_control_offer.offer events to describe the MIME
|
||||
types it offers.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="ext_data_control_offer_v1"/>
|
||||
</event>
|
||||
|
||||
<event name="selection">
|
||||
<description summary="advertise new selection">
|
||||
The selection event is sent out to notify the client of a new
|
||||
ext_data_control_offer for the selection for this device. The
|
||||
ext_data_control_device.data_offer and the ext_data_control_offer.offer
|
||||
events are sent out immediately before this event to introduce the data
|
||||
offer object. The selection event is sent to a client when a new
|
||||
selection is set. The ext_data_control_offer is valid until a new
|
||||
ext_data_control_offer or NULL is received. The client must destroy the
|
||||
previous selection ext_data_control_offer, if any, upon receiving this
|
||||
event. Regardless, the previous selection will be ignored once a new
|
||||
selection ext_data_control_offer is received.
|
||||
|
||||
The first selection event is sent upon binding the
|
||||
ext_data_control_device object.
|
||||
</description>
|
||||
<arg name="id" type="object" interface="ext_data_control_offer_v1"
|
||||
allow-null="true"/>
|
||||
</event>
|
||||
|
||||
<event name="finished">
|
||||
<description summary="this data control is no longer valid">
|
||||
This data control object is no longer valid and should be destroyed by
|
||||
the client.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="primary_selection">
|
||||
<description summary="advertise new primary selection">
|
||||
The primary_selection event is sent out to notify the client of a new
|
||||
ext_data_control_offer for the primary selection for this device. The
|
||||
ext_data_control_device.data_offer and the ext_data_control_offer.offer
|
||||
events are sent out immediately before this event to introduce the data
|
||||
offer object. The primary_selection event is sent to a client when a
|
||||
new primary selection is set. The ext_data_control_offer is valid until
|
||||
a new ext_data_control_offer or NULL is received. The client must
|
||||
destroy the previous primary selection ext_data_control_offer, if any,
|
||||
upon receiving this event. Regardless, the previous primary selection
|
||||
will be ignored once a new primary selection ext_data_control_offer is
|
||||
received.
|
||||
|
||||
If the compositor supports primary selection, the first
|
||||
primary_selection event is sent upon binding the
|
||||
ext_data_control_device object.
|
||||
</description>
|
||||
<arg name="id" type="object" interface="ext_data_control_offer_v1"
|
||||
allow-null="true"/>
|
||||
</event>
|
||||
|
||||
<request name="set_primary_selection">
|
||||
<description summary="copy data to the primary selection">
|
||||
This request asks the compositor to set the primary selection to the
|
||||
data from the source on behalf of the client.
|
||||
|
||||
The given source may not be used in any further set_selection or
|
||||
set_primary_selection requests. Attempting to use a previously used
|
||||
source triggers the used_source protocol error.
|
||||
|
||||
To unset the primary selection, set the source to NULL.
|
||||
|
||||
The compositor will ignore this request if it does not support primary
|
||||
selection.
|
||||
</description>
|
||||
<arg name="source" type="object" interface="ext_data_control_source_v1"
|
||||
allow-null="true"/>
|
||||
</request>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="used_source" value="1"
|
||||
summary="source given to set_selection or set_primary_selection was already used before"/>
|
||||
</enum>
|
||||
</interface>
|
||||
|
||||
<interface name="ext_data_control_source_v1" version="1">
|
||||
<description summary="offer to transfer data">
|
||||
The ext_data_control_source object is the source side of a
|
||||
ext_data_control_offer. It is created by the source client in a data
|
||||
transfer and provides a way to describe the offered data and a way to
|
||||
respond to requests to transfer the data.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="invalid_offer" value="1"
|
||||
summary="offer sent after ext_data_control_device.set_selection"/>
|
||||
</enum>
|
||||
|
||||
<request name="offer">
|
||||
<description summary="add an offered MIME type">
|
||||
This request adds a MIME type to the set of MIME types advertised to
|
||||
targets. Can be called several times to offer multiple types.
|
||||
|
||||
Calling this after ext_data_control_device.set_selection is a protocol
|
||||
error.
|
||||
</description>
|
||||
<arg name="mime_type" type="string"
|
||||
summary="MIME type offered by the data source"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy this source">
|
||||
Destroys the data source object.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="send">
|
||||
<description summary="send the data">
|
||||
Request for data from the client. Send the data as the specified MIME
|
||||
type over the passed file descriptor, then close it.
|
||||
</description>
|
||||
<arg name="mime_type" type="string" summary="MIME type for the data"/>
|
||||
<arg name="fd" type="fd" summary="file descriptor for the data"/>
|
||||
</event>
|
||||
|
||||
<event name="cancelled">
|
||||
<description summary="selection was cancelled">
|
||||
This data source is no longer valid. The data source has been replaced
|
||||
by another data source.
|
||||
|
||||
The client should clean up and destroy this data source.
|
||||
</description>
|
||||
</event>
|
||||
</interface>
|
||||
|
||||
<interface name="ext_data_control_offer_v1" version="1">
|
||||
<description summary="offer to transfer data">
|
||||
A ext_data_control_offer represents a piece of data offered for transfer
|
||||
by another client (the source client). The offer describes the different
|
||||
MIME types that the data can be converted to and provides the mechanism
|
||||
for transferring the data directly from the source client.
|
||||
</description>
|
||||
|
||||
<request name="receive">
|
||||
<description summary="request that the data is transferred">
|
||||
To transfer the offered data, the client issues this request and
|
||||
indicates the MIME type it wants to receive. The transfer happens
|
||||
through the passed file descriptor (typically created with the pipe
|
||||
system call). The source client writes the data in the MIME type
|
||||
representation requested and then closes the file descriptor.
|
||||
|
||||
The receiving client reads from the read end of the pipe until EOF and
|
||||
then closes its end, at which point the transfer is complete.
|
||||
|
||||
This request may happen multiple times for different MIME types.
|
||||
</description>
|
||||
<arg name="mime_type" type="string"
|
||||
summary="MIME type desired by receiver"/>
|
||||
<arg name="fd" type="fd" summary="file descriptor for data transfer"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy this offer">
|
||||
Destroys the data offer object.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="offer">
|
||||
<description summary="advertise offered MIME type">
|
||||
Sent immediately after creating the ext_data_control_offer object.
|
||||
One event per offered MIME type.
|
||||
</description>
|
||||
<arg name="mime_type" type="string" summary="offered MIME type"/>
|
||||
</event>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
@@ -119,7 +120,12 @@ func GetOutputDir() string {
|
||||
}
|
||||
|
||||
func getXDGPicturesDir() string {
|
||||
userDirsFile := filepath.Join(utils.XDGConfigHome(), "user-dirs.dirs")
|
||||
userConfigDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Error("failed to get user config dir", "err", err)
|
||||
return ""
|
||||
}
|
||||
userDirsFile := filepath.Join(userConfigDir, "user-dirs.dirs")
|
||||
data, err := os.ReadFile(userDirsFile)
|
||||
if err != nil {
|
||||
return ""
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
type ThemeColors struct {
|
||||
@@ -74,7 +74,12 @@ func loadColorsFile() *ColorScheme {
|
||||
}
|
||||
|
||||
func getColorsFilePath() string {
|
||||
return filepath.Join(utils.XDGCacheHome(), "DankMaterialShell", "dms-colors.json")
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
log.Error("Failed to get user cache dir", "err", err)
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(cacheDir, "DankMaterialShell", "dms-colors.json")
|
||||
}
|
||||
|
||||
func isLightMode() bool {
|
||||
|
||||
238
core/internal/server/clipboard/handlers.go
Normal file
238
core/internal/server/clipboard/handlers.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||
)
|
||||
|
||||
func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
|
||||
switch req.Method {
|
||||
case "clipboard.getState":
|
||||
handleGetState(conn, req, m)
|
||||
case "clipboard.getHistory":
|
||||
handleGetHistory(conn, req, m)
|
||||
case "clipboard.getEntry":
|
||||
handleGetEntry(conn, req, m)
|
||||
case "clipboard.deleteEntry":
|
||||
handleDeleteEntry(conn, req, m)
|
||||
case "clipboard.clearHistory":
|
||||
handleClearHistory(conn, req, m)
|
||||
case "clipboard.copy":
|
||||
handleCopy(conn, req, m)
|
||||
case "clipboard.copyEntry":
|
||||
handleCopyEntry(conn, req, m)
|
||||
case "clipboard.paste":
|
||||
handlePaste(conn, req, m)
|
||||
case "clipboard.subscribe":
|
||||
handleSubscribe(conn, req, m)
|
||||
case "clipboard.search":
|
||||
handleSearch(conn, req, m)
|
||||
case "clipboard.getConfig":
|
||||
handleGetConfig(conn, req, m)
|
||||
case "clipboard.setConfig":
|
||||
handleSetConfig(conn, req, m)
|
||||
case "clipboard.store":
|
||||
handleStore(conn, req, m)
|
||||
default:
|
||||
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetState(conn net.Conn, req models.Request, m *Manager) {
|
||||
models.Respond(conn, req.ID, m.GetState())
|
||||
}
|
||||
|
||||
func handleGetHistory(conn net.Conn, req models.Request, m *Manager) {
|
||||
history := m.GetHistory()
|
||||
for i := range history {
|
||||
history[i].Data = nil
|
||||
}
|
||||
models.Respond(conn, req.ID, history)
|
||||
}
|
||||
|
||||
func handleGetEntry(conn net.Conn, req models.Request, m *Manager) {
|
||||
id, err := params.Int(req.Params, "id")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := m.GetEntry(uint64(id))
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, entry)
|
||||
}
|
||||
|
||||
func handleDeleteEntry(conn net.Conn, req models.Request, m *Manager) {
|
||||
id, err := params.Int(req.Params, "id")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.DeleteEntry(uint64(id)); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry deleted"})
|
||||
}
|
||||
|
||||
func handleClearHistory(conn net.Conn, req models.Request, m *Manager) {
|
||||
m.ClearHistory()
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "history cleared"})
|
||||
}
|
||||
|
||||
func handleCopy(conn net.Conn, req models.Request, m *Manager) {
|
||||
text, err := params.String(req.Params, "text")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.CopyText(text); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"})
|
||||
}
|
||||
|
||||
func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
|
||||
id, err := params.Int(req.Params, "id")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := m.GetEntry(uint64(id))
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"})
|
||||
}
|
||||
|
||||
func handlePaste(conn net.Conn, req models.Request, m *Manager) {
|
||||
text, err := m.PasteText()
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, map[string]string{"text": text})
|
||||
}
|
||||
|
||||
func handleSubscribe(conn net.Conn, req models.Request, m *Manager) {
|
||||
clientID := fmt.Sprintf("clipboard-%d", req.ID)
|
||||
|
||||
ch := m.Subscribe(clientID)
|
||||
defer m.Unsubscribe(clientID)
|
||||
|
||||
initialState := m.GetState()
|
||||
if err := json.NewEncoder(conn).Encode(models.Response[State]{
|
||||
ID: req.ID,
|
||||
Result: &initialState,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for state := range ch {
|
||||
if err := json.NewEncoder(conn).Encode(models.Response[State]{
|
||||
ID: req.ID,
|
||||
Result: &state,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearch(conn net.Conn, req models.Request, m *Manager) {
|
||||
p := SearchParams{
|
||||
Query: params.StringOpt(req.Params, "query", ""),
|
||||
MimeType: params.StringOpt(req.Params, "mimeType", ""),
|
||||
Limit: params.IntOpt(req.Params, "limit", 50),
|
||||
Offset: params.IntOpt(req.Params, "offset", 0),
|
||||
}
|
||||
|
||||
if img, ok := req.Params["isImage"].(bool); ok {
|
||||
p.IsImage = &img
|
||||
}
|
||||
if b, ok := req.Params["before"].(float64); ok {
|
||||
v := int64(b)
|
||||
p.Before = &v
|
||||
}
|
||||
if a, ok := req.Params["after"].(float64); ok {
|
||||
v := int64(a)
|
||||
p.After = &v
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, m.Search(p))
|
||||
}
|
||||
|
||||
func handleGetConfig(conn net.Conn, req models.Request, m *Manager) {
|
||||
models.Respond(conn, req.ID, m.GetConfig())
|
||||
}
|
||||
|
||||
func handleSetConfig(conn net.Conn, req models.Request, m *Manager) {
|
||||
cfg := m.GetConfig()
|
||||
|
||||
if _, ok := req.Params["maxHistory"]; ok {
|
||||
cfg.MaxHistory = params.IntOpt(req.Params, "maxHistory", cfg.MaxHistory)
|
||||
}
|
||||
if _, ok := req.Params["maxEntrySize"]; ok {
|
||||
cfg.MaxEntrySize = int64(params.IntOpt(req.Params, "maxEntrySize", int(cfg.MaxEntrySize)))
|
||||
}
|
||||
if _, ok := req.Params["autoClearDays"]; ok {
|
||||
cfg.AutoClearDays = params.IntOpt(req.Params, "autoClearDays", cfg.AutoClearDays)
|
||||
}
|
||||
if v, ok := req.Params["clearAtStartup"].(bool); ok {
|
||||
cfg.ClearAtStartup = v
|
||||
}
|
||||
if v, ok := req.Params["disabled"].(bool); ok {
|
||||
cfg.Disabled = v
|
||||
}
|
||||
if v, ok := req.Params["disableHistory"].(bool); ok {
|
||||
cfg.DisableHistory = v
|
||||
}
|
||||
if v, ok := req.Params["disablePersist"].(bool); ok {
|
||||
cfg.DisablePersist = v
|
||||
}
|
||||
|
||||
if err := m.SetConfig(cfg); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "config updated"})
|
||||
}
|
||||
|
||||
func handleStore(conn net.Conn, req models.Request, m *Manager) {
|
||||
data, err := params.String(req.Params, "data")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
mimeType := params.StringOpt(req.Params, "mimeType", "text/plain;charset=utf-8")
|
||||
|
||||
if err := m.StoreData([]byte(data), mimeType); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "stored"})
|
||||
}
|
||||
1290
core/internal/server/clipboard/manager.go
Normal file
1290
core/internal/server/clipboard/manager.go
Normal file
File diff suppressed because it is too large
Load Diff
531
core/internal/server/clipboard/manager_test.go
Normal file
531
core/internal/server/clipboard/manager_test.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext"
|
||||
)
|
||||
|
||||
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
|
||||
original := Entry{
|
||||
ID: 12345,
|
||||
Data: []byte("hello world"),
|
||||
MimeType: "text/plain;charset=utf-8",
|
||||
Preview: "hello world",
|
||||
Size: 11,
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
IsImage: false,
|
||||
}
|
||||
|
||||
encoded, err := encodeEntry(original)
|
||||
assert.NoError(t, err)
|
||||
|
||||
decoded, err := decodeEntry(encoded)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, original.ID, decoded.ID)
|
||||
assert.Equal(t, original.Data, decoded.Data)
|
||||
assert.Equal(t, original.MimeType, decoded.MimeType)
|
||||
assert.Equal(t, original.Preview, decoded.Preview)
|
||||
assert.Equal(t, original.Size, decoded.Size)
|
||||
assert.Equal(t, original.Timestamp.Unix(), decoded.Timestamp.Unix())
|
||||
assert.Equal(t, original.IsImage, decoded.IsImage)
|
||||
}
|
||||
|
||||
func TestEncodeDecodeEntry_Image(t *testing.T) {
|
||||
original := Entry{
|
||||
ID: 99999,
|
||||
Data: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
|
||||
MimeType: "image/png",
|
||||
Preview: "[[ image 8 B png 100x100 ]]",
|
||||
Size: 8,
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
IsImage: true,
|
||||
}
|
||||
|
||||
encoded, err := encodeEntry(original)
|
||||
assert.NoError(t, err)
|
||||
|
||||
decoded, err := decodeEntry(encoded)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, original.ID, decoded.ID)
|
||||
assert.Equal(t, original.Data, decoded.Data)
|
||||
assert.True(t, decoded.IsImage)
|
||||
assert.Equal(t, original.Preview, decoded.Preview)
|
||||
}
|
||||
|
||||
func TestEncodeDecodeEntry_EmptyData(t *testing.T) {
|
||||
original := Entry{
|
||||
ID: 1,
|
||||
Data: []byte{},
|
||||
MimeType: "text/plain",
|
||||
Preview: "",
|
||||
Size: 0,
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
IsImage: false,
|
||||
}
|
||||
|
||||
encoded, err := encodeEntry(original)
|
||||
assert.NoError(t, err)
|
||||
|
||||
decoded, err := decodeEntry(encoded)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, original.ID, decoded.ID)
|
||||
assert.Empty(t, decoded.Data)
|
||||
}
|
||||
|
||||
func TestEncodeDecodeEntry_LargeData(t *testing.T) {
|
||||
largeData := make([]byte, 100000)
|
||||
for i := range largeData {
|
||||
largeData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
original := Entry{
|
||||
ID: 777,
|
||||
Data: largeData,
|
||||
MimeType: "application/octet-stream",
|
||||
Preview: "binary data...",
|
||||
Size: len(largeData),
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
IsImage: false,
|
||||
}
|
||||
|
||||
encoded, err := encodeEntry(original)
|
||||
assert.NoError(t, err)
|
||||
|
||||
decoded, err := decodeEntry(encoded)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, original.Data, decoded.Data)
|
||||
assert.Equal(t, original.Size, decoded.Size)
|
||||
}
|
||||
|
||||
func TestStateEqual_BothNil(t *testing.T) {
|
||||
assert.False(t, stateEqual(nil, nil))
|
||||
}
|
||||
|
||||
func TestStateEqual_OneNil(t *testing.T) {
|
||||
s := &State{Enabled: true}
|
||||
assert.False(t, stateEqual(s, nil))
|
||||
assert.False(t, stateEqual(nil, s))
|
||||
}
|
||||
|
||||
func TestStateEqual_EnabledDiffers(t *testing.T) {
|
||||
a := &State{Enabled: true, History: []Entry{}}
|
||||
b := &State{Enabled: false, History: []Entry{}}
|
||||
assert.False(t, stateEqual(a, b))
|
||||
}
|
||||
|
||||
func TestStateEqual_HistoryLengthDiffers(t *testing.T) {
|
||||
a := &State{Enabled: true, History: []Entry{{ID: 1}}}
|
||||
b := &State{Enabled: true, History: []Entry{}}
|
||||
assert.False(t, stateEqual(a, b))
|
||||
}
|
||||
|
||||
func TestStateEqual_BothEqual(t *testing.T) {
|
||||
a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}}
|
||||
b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}}
|
||||
assert.True(t, stateEqual(a, b))
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
subscribers: make(map[string]chan State),
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
subID := string(rune('a' + id))
|
||||
ch := m.Subscribe(subID)
|
||||
assert.NotNil(t, ch)
|
||||
time.Sleep(time.Millisecond)
|
||||
m.Unsubscribe(subID)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentGetState(t *testing.T) {
|
||||
m := &Manager{
|
||||
state: &State{
|
||||
Enabled: true,
|
||||
History: []Entry{{ID: 1}, {ID: 2}},
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 50
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
s := m.GetState()
|
||||
_ = s.Enabled
|
||||
_ = len(s.History)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.stateMutex.Lock()
|
||||
m.state = &State{
|
||||
Enabled: j%2 == 0,
|
||||
History: []Entry{{ID: uint64(j)}},
|
||||
}
|
||||
m.stateMutex.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentConfigAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
config: DefaultConfig(),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
cfg := m.getConfig()
|
||||
_ = cfg.MaxHistory
|
||||
_ = cfg.MaxEntrySize
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.configMutex.Lock()
|
||||
m.config.MaxHistory = 50 + j
|
||||
m.config.MaxEntrySize = int64(1024 * j)
|
||||
m.configMutex.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
|
||||
m := &Manager{
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
m.notifySubscribers()
|
||||
}
|
||||
|
||||
assert.Len(t, m.dirty, 1)
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentOfferAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
offerMimeTypes: make(map[any][]string),
|
||||
offerRegistry: make(map[uint32]any),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 50
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
key := uint32(id)
|
||||
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.offerMutex.Lock()
|
||||
m.offerRegistry[key] = struct{}{}
|
||||
m.offerMimeTypes[key] = []string{"text/plain"}
|
||||
m.offerMutex.Unlock()
|
||||
|
||||
m.offerMutex.RLock()
|
||||
_ = m.offerRegistry[key]
|
||||
_ = m.offerMimeTypes[key]
|
||||
m.offerMutex.RUnlock()
|
||||
|
||||
m.offerMutex.Lock()
|
||||
delete(m.offerRegistry, key)
|
||||
delete(m.offerMimeTypes, key)
|
||||
m.offerMutex.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentPersistAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
persistData: make(map[string][]byte),
|
||||
persistMimeTypes: []string{},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 50
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.persistMutex.RLock()
|
||||
_ = m.persistData
|
||||
_ = m.persistMimeTypes
|
||||
m.persistMutex.RUnlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.persistMutex.Lock()
|
||||
m.persistMimeTypes = []string{"text/plain", "text/html"}
|
||||
m.persistData = map[string][]byte{
|
||||
"text/plain": []byte("test"),
|
||||
}
|
||||
m.persistMutex.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentOwnerAccess(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.ownerLock.Lock()
|
||||
_ = m.isOwner
|
||||
m.ownerLock.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.ownerLock.Lock()
|
||||
m.isOwner = j%2 == 0
|
||||
m.ownerLock.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestItob(t *testing.T) {
|
||||
tests := []struct {
|
||||
input uint64
|
||||
expected []byte
|
||||
}{
|
||||
{0, []byte{0, 0, 0, 0, 0, 0, 0, 0}},
|
||||
{1, []byte{0, 0, 0, 0, 0, 0, 0, 1}},
|
||||
{256, []byte{0, 0, 0, 0, 0, 0, 1, 0}},
|
||||
{0xFFFFFFFFFFFFFFFF, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := itob(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeStr(t *testing.T) {
|
||||
tests := []struct {
|
||||
input int
|
||||
expected string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{100, "100 B"},
|
||||
{1024, "1 KiB"},
|
||||
{2048, "2 KiB"},
|
||||
{1048576, "1 MiB"},
|
||||
{5242880, "5 MiB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := sizeStr(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMimeType(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
tests := []struct {
|
||||
mimes []string
|
||||
expected string
|
||||
}{
|
||||
{[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"},
|
||||
{[]string{"text/html", "text/plain"}, "text/plain"},
|
||||
{[]string{"application/json", "image/png"}, "image/png"},
|
||||
{[]string{"application/json", "application/xml"}, ""},
|
||||
{[]string{}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := m.selectMimeType(tt.mimes)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsImageMimeType(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
assert.True(t, m.isImageMimeType("image/png"))
|
||||
assert.True(t, m.isImageMimeType("image/jpeg"))
|
||||
assert.True(t, m.isImageMimeType("image/gif"))
|
||||
assert.False(t, m.isImageMimeType("text/plain"))
|
||||
assert.False(t, m.isImageMimeType("application/json"))
|
||||
}
|
||||
|
||||
func TestTextPreview(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
short := m.textPreview([]byte("hello world"))
|
||||
assert.Equal(t, "hello world", short)
|
||||
|
||||
withWhitespace := m.textPreview([]byte(" hello world "))
|
||||
assert.Equal(t, "hello world", withWhitespace)
|
||||
|
||||
longText := make([]byte, 200)
|
||||
for i := range longText {
|
||||
longText[i] = 'a'
|
||||
}
|
||||
preview := m.textPreview(longText)
|
||||
assert.True(t, len(preview) > 100)
|
||||
assert.Contains(t, preview, "…")
|
||||
}
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
assert.Equal(t, 100, cfg.MaxHistory)
|
||||
assert.Equal(t, int64(5*1024*1024), cfg.MaxEntrySize)
|
||||
assert.Equal(t, 0, cfg.AutoClearDays)
|
||||
assert.False(t, cfg.ClearAtStartup)
|
||||
assert.False(t, cfg.Disabled)
|
||||
assert.False(t, cfg.DisableHistory)
|
||||
assert.False(t, cfg.DisablePersist)
|
||||
}
|
||||
|
||||
func TestManager_PostDelegatesToWlContext(t *testing.T) {
|
||||
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
||||
|
||||
var called atomic.Bool
|
||||
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
|
||||
called.Store(true)
|
||||
fn()
|
||||
}).Once()
|
||||
|
||||
m := &Manager{
|
||||
wlCtx: mockCtx,
|
||||
}
|
||||
|
||||
executed := false
|
||||
m.post(func() {
|
||||
executed = true
|
||||
})
|
||||
|
||||
assert.True(t, called.Load())
|
||||
assert.True(t, executed)
|
||||
}
|
||||
|
||||
func TestManager_PostExecutesFunctionViaContext(t *testing.T) {
|
||||
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
||||
|
||||
var capturedFn func()
|
||||
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
|
||||
capturedFn = fn
|
||||
}).Times(3)
|
||||
|
||||
m := &Manager{
|
||||
wlCtx: mockCtx,
|
||||
}
|
||||
|
||||
counter := 0
|
||||
m.post(func() { counter++ })
|
||||
m.post(func() { counter += 10 })
|
||||
m.post(func() { counter += 100 })
|
||||
|
||||
assert.NotNil(t, capturedFn)
|
||||
capturedFn()
|
||||
assert.Equal(t, 100, counter)
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentPostWithMock(t *testing.T) {
|
||||
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
||||
|
||||
var postCount atomic.Int32
|
||||
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
|
||||
postCount.Add(1)
|
||||
}).Times(100)
|
||||
|
||||
m := &Manager{
|
||||
wlCtx: mockCtx,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 10; j++ {
|
||||
m.post(func() {})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
assert.Equal(t, int32(100), postCount.Load())
|
||||
}
|
||||
191
core/internal/server/clipboard/types.go
Normal file
191
core/internal/server/clipboard/types.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MaxHistory int `json:"maxHistory"`
|
||||
MaxEntrySize int64 `json:"maxEntrySize"`
|
||||
AutoClearDays int `json:"autoClearDays"`
|
||||
ClearAtStartup bool `json:"clearAtStartup"`
|
||||
|
||||
Disabled bool `json:"disabled"`
|
||||
DisableHistory bool `json:"disableHistory"`
|
||||
DisablePersist bool `json:"disablePersist"`
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
MaxHistory: 100,
|
||||
MaxEntrySize: 5 * 1024 * 1024,
|
||||
AutoClearDays: 0,
|
||||
ClearAtStartup: false,
|
||||
}
|
||||
}
|
||||
|
||||
func getConfigPath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(configDir, "DankMaterialShell", "clsettings.json"), nil
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
path, err := getConfigPath()
|
||||
if err != nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return DefaultConfig()
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func SaveConfig(cfg Config) error {
|
||||
path, err := getConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
type SearchParams struct {
|
||||
Query string `json:"query"`
|
||||
MimeType string `json:"mimeType"`
|
||||
IsImage *bool `json:"isImage"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Before *int64 `json:"before"`
|
||||
After *int64 `json:"after"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Entries []Entry `json:"entries"`
|
||||
Total int `json:"total"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID uint64 `json:"id"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Preview string `json:"preview"`
|
||||
Size int `json:"size"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
IsImage bool `json:"isImage"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
History []Entry `json:"history"`
|
||||
Current *Entry `json:"current,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
config Config
|
||||
configMutex sync.RWMutex
|
||||
configPath string
|
||||
|
||||
display wlclient.WaylandDisplay
|
||||
wlCtx wlcontext.WaylandContext
|
||||
|
||||
registry *wlclient.Registry
|
||||
dataControlMgr any
|
||||
seat *wlclient.Seat
|
||||
dataDevice any
|
||||
currentOffer any
|
||||
currentSource any
|
||||
seatName uint32
|
||||
mimeTypes []string
|
||||
offerMimeTypes map[any][]string
|
||||
offerMutex sync.RWMutex
|
||||
offerRegistry map[uint32]any
|
||||
|
||||
sourceMimeTypes []string
|
||||
sourceMutex sync.RWMutex
|
||||
|
||||
persistData map[string][]byte
|
||||
persistMimeTypes []string
|
||||
persistMutex sync.RWMutex
|
||||
|
||||
isOwner bool
|
||||
ownerLock sync.Mutex
|
||||
initialized bool
|
||||
|
||||
alive bool
|
||||
stopChan chan struct{}
|
||||
|
||||
db *bolt.DB
|
||||
dbPath string
|
||||
|
||||
state *State
|
||||
stateMutex sync.RWMutex
|
||||
|
||||
subscribers map[string]chan State
|
||||
subMutex sync.RWMutex
|
||||
dirty chan struct{}
|
||||
notifierWg sync.WaitGroup
|
||||
lastState *State
|
||||
}
|
||||
|
||||
func (m *Manager) GetState() State {
|
||||
m.stateMutex.RLock()
|
||||
defer m.stateMutex.RUnlock()
|
||||
if m.state == nil {
|
||||
return State{}
|
||||
}
|
||||
return *m.state
|
||||
}
|
||||
|
||||
func (m *Manager) Subscribe(id string) chan State {
|
||||
ch := make(chan State, 64)
|
||||
m.subMutex.Lock()
|
||||
m.subscribers[id] = ch
|
||||
m.subMutex.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) Unsubscribe(id string) {
|
||||
m.subMutex.Lock()
|
||||
if ch, ok := m.subscribers[id]; ok {
|
||||
close(ch)
|
||||
delete(m.subscribers, id)
|
||||
}
|
||||
m.subMutex.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) notifySubscribers() {
|
||||
select {
|
||||
case m.dirty <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/dwl_ipc"
|
||||
)
|
||||
|
||||
func NewManager(display *wlclient.Display) (*Manager, error) {
|
||||
func NewManager(display wlclient.WaylandDisplay) (*Manager, error) {
|
||||
m := &Manager{
|
||||
display: display,
|
||||
ctx: display.Context(),
|
||||
|
||||
366
core/internal/server/dwl/manager_test.go
Normal file
366
core/internal/server/dwl/manager_test.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package dwl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
|
||||
)
|
||||
|
||||
func TestStateChanged_BothNil(t *testing.T) {
|
||||
assert.True(t, stateChanged(nil, nil))
|
||||
}
|
||||
|
||||
func TestStateChanged_OneNil(t *testing.T) {
|
||||
s := &State{TagCount: 9}
|
||||
assert.True(t, stateChanged(s, nil))
|
||||
assert.True(t, stateChanged(nil, s))
|
||||
}
|
||||
|
||||
func TestStateChanged_TagCountDiffers(t *testing.T) {
|
||||
a := &State{TagCount: 9, Outputs: make(map[string]*OutputState), Layouts: []string{}}
|
||||
b := &State{TagCount: 10, Outputs: make(map[string]*OutputState), Layouts: []string{}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_LayoutLengthDiffers(t *testing.T) {
|
||||
a := &State{TagCount: 9, Layouts: []string{"tile"}, Outputs: make(map[string]*OutputState)}
|
||||
b := &State{TagCount: 9, Layouts: []string{"tile", "monocle"}, Outputs: make(map[string]*OutputState)}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_ActiveOutputDiffers(t *testing.T) {
|
||||
a := &State{TagCount: 9, ActiveOutput: "eDP-1", Outputs: make(map[string]*OutputState), Layouts: []string{}}
|
||||
b := &State{TagCount: 9, ActiveOutput: "HDMI-A-1", Outputs: make(map[string]*OutputState), Layouts: []string{}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_OutputCountDiffers(t *testing.T) {
|
||||
a := &State{
|
||||
TagCount: 9,
|
||||
Outputs: map[string]*OutputState{"eDP-1": {}},
|
||||
Layouts: []string{},
|
||||
}
|
||||
b := &State{
|
||||
TagCount: 9,
|
||||
Outputs: map[string]*OutputState{},
|
||||
Layouts: []string{},
|
||||
}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_OutputFieldsDiffer(t *testing.T) {
|
||||
a := &State{
|
||||
TagCount: 9,
|
||||
Layouts: []string{},
|
||||
Outputs: map[string]*OutputState{
|
||||
"eDP-1": {Active: 1, Layout: 0, Title: "Firefox"},
|
||||
},
|
||||
}
|
||||
b := &State{
|
||||
TagCount: 9,
|
||||
Layouts: []string{},
|
||||
Outputs: map[string]*OutputState{
|
||||
"eDP-1": {Active: 0, Layout: 0, Title: "Firefox"},
|
||||
},
|
||||
}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
|
||||
b.Outputs["eDP-1"].Active = 1
|
||||
b.Outputs["eDP-1"].Layout = 1
|
||||
assert.True(t, stateChanged(a, b))
|
||||
|
||||
b.Outputs["eDP-1"].Layout = 0
|
||||
b.Outputs["eDP-1"].Title = "Code"
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_TagsDiffer(t *testing.T) {
|
||||
a := &State{
|
||||
TagCount: 9,
|
||||
Layouts: []string{},
|
||||
Outputs: map[string]*OutputState{
|
||||
"eDP-1": {Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}}},
|
||||
},
|
||||
}
|
||||
b := &State{
|
||||
TagCount: 9,
|
||||
Layouts: []string{},
|
||||
Outputs: map[string]*OutputState{
|
||||
"eDP-1": {Tags: []TagState{{Tag: 1, State: 2, Clients: 2, Focused: 1}}},
|
||||
},
|
||||
}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
|
||||
b.Outputs["eDP-1"].Tags[0].State = 1
|
||||
b.Outputs["eDP-1"].Tags[0].Clients = 3
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_Equal(t *testing.T) {
|
||||
a := &State{
|
||||
TagCount: 9,
|
||||
ActiveOutput: "eDP-1",
|
||||
Layouts: []string{"tile", "monocle"},
|
||||
Outputs: map[string]*OutputState{
|
||||
"eDP-1": {
|
||||
Name: "eDP-1",
|
||||
Active: 1,
|
||||
Layout: 0,
|
||||
LayoutSymbol: "[]=",
|
||||
Title: "Firefox",
|
||||
AppID: "firefox",
|
||||
KbLayout: "us",
|
||||
Keymode: "",
|
||||
Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}},
|
||||
},
|
||||
},
|
||||
}
|
||||
b := &State{
|
||||
TagCount: 9,
|
||||
ActiveOutput: "eDP-1",
|
||||
Layouts: []string{"tile", "monocle"},
|
||||
Outputs: map[string]*OutputState{
|
||||
"eDP-1": {
|
||||
Name: "eDP-1",
|
||||
Active: 1,
|
||||
Layout: 0,
|
||||
LayoutSymbol: "[]=",
|
||||
Title: "Firefox",
|
||||
AppID: "firefox",
|
||||
KbLayout: "us",
|
||||
Keymode: "",
|
||||
Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}},
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.False(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentGetState(t *testing.T) {
|
||||
m := &Manager{
|
||||
state: &State{
|
||||
TagCount: 9,
|
||||
Layouts: []string{"tile"},
|
||||
Outputs: map[string]*OutputState{"eDP-1": {Name: "eDP-1"}},
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 50
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
s := m.GetState()
|
||||
_ = s.TagCount
|
||||
_ = s.Outputs
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.stateMutex.Lock()
|
||||
m.state = &State{
|
||||
TagCount: uint32(j % 10),
|
||||
Layouts: []string{"tile", "monocle"},
|
||||
Outputs: map[string]*OutputState{"eDP-1": {Active: uint32(j % 2)}},
|
||||
}
|
||||
m.stateMutex.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
stopChan: make(chan struct{}),
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
subID := string(rune('a' + id))
|
||||
ch := m.Subscribe(subID)
|
||||
assert.NotNil(t, ch)
|
||||
time.Sleep(time.Millisecond)
|
||||
m.Unsubscribe(subID)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 50
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
key := uint32(id)
|
||||
|
||||
for j := 0; j < iterations; j++ {
|
||||
state := &outputState{
|
||||
id: key,
|
||||
name: "test-output",
|
||||
active: uint32(j % 2),
|
||||
tags: []TagState{{Tag: uint32(j), State: 1}},
|
||||
}
|
||||
m.outputs.Store(key, state)
|
||||
|
||||
if loaded, ok := m.outputs.Load(key); ok {
|
||||
assert.Equal(t, key, loaded.id)
|
||||
}
|
||||
|
||||
m.outputs.Range(func(k uint32, v *outputState) bool {
|
||||
_ = v.name
|
||||
_ = v.active
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
m.outputs.Delete(key)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
|
||||
m := &Manager{
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
m.notifySubscribers()
|
||||
}
|
||||
|
||||
assert.Len(t, m.dirty, 1)
|
||||
}
|
||||
|
||||
func TestManager_PostQueueFull(t *testing.T) {
|
||||
m := &Manager{
|
||||
cmdq: make(chan cmd, 2),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
m.post(func() {})
|
||||
m.post(func() {})
|
||||
m.post(func() {})
|
||||
m.post(func() {})
|
||||
|
||||
assert.Len(t, m.cmdq, 2)
|
||||
}
|
||||
|
||||
func TestManager_GetStateNilState(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
s := m.GetState()
|
||||
assert.NotNil(t, s.Outputs)
|
||||
assert.NotNil(t, s.Layouts)
|
||||
assert.Equal(t, uint32(0), s.TagCount)
|
||||
}
|
||||
|
||||
func TestTagState_Fields(t *testing.T) {
|
||||
tag := TagState{
|
||||
Tag: 1,
|
||||
State: 2,
|
||||
Clients: 3,
|
||||
Focused: 1,
|
||||
}
|
||||
|
||||
assert.Equal(t, uint32(1), tag.Tag)
|
||||
assert.Equal(t, uint32(2), tag.State)
|
||||
assert.Equal(t, uint32(3), tag.Clients)
|
||||
assert.Equal(t, uint32(1), tag.Focused)
|
||||
}
|
||||
|
||||
func TestOutputState_Fields(t *testing.T) {
|
||||
out := OutputState{
|
||||
Name: "eDP-1",
|
||||
Active: 1,
|
||||
Tags: []TagState{{Tag: 1}},
|
||||
Layout: 0,
|
||||
LayoutSymbol: "[]=",
|
||||
Title: "Firefox",
|
||||
AppID: "firefox",
|
||||
KbLayout: "us",
|
||||
Keymode: "",
|
||||
}
|
||||
|
||||
assert.Equal(t, "eDP-1", out.Name)
|
||||
assert.Equal(t, uint32(1), out.Active)
|
||||
assert.Len(t, out.Tags, 1)
|
||||
assert.Equal(t, "[]=", out.LayoutSymbol)
|
||||
}
|
||||
|
||||
func TestStateChanged_NewOutputAppears(t *testing.T) {
|
||||
a := &State{
|
||||
TagCount: 9,
|
||||
Layouts: []string{},
|
||||
Outputs: map[string]*OutputState{
|
||||
"eDP-1": {Name: "eDP-1"},
|
||||
},
|
||||
}
|
||||
b := &State{
|
||||
TagCount: 9,
|
||||
Layouts: []string{},
|
||||
Outputs: map[string]*OutputState{
|
||||
"eDP-1": {Name: "eDP-1"},
|
||||
"HDMI-A-1": {Name: "HDMI-A-1"},
|
||||
},
|
||||
}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_TagsLengthDiffers(t *testing.T) {
|
||||
a := &State{
|
||||
TagCount: 9,
|
||||
Layouts: []string{},
|
||||
Outputs: map[string]*OutputState{
|
||||
"eDP-1": {Tags: []TagState{{Tag: 1}}},
|
||||
},
|
||||
}
|
||||
b := &State{
|
||||
TagCount: 9,
|
||||
Layouts: []string{},
|
||||
Outputs: map[string]*OutputState{
|
||||
"eDP-1": {Tags: []TagState{{Tag: 1}, {Tag: 2}}},
|
||||
},
|
||||
}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestNewManager_GetRegistryError(t *testing.T) {
|
||||
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
|
||||
|
||||
mockDisplay.EXPECT().Context().Return(nil)
|
||||
mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
|
||||
|
||||
_, err := NewManager(mockDisplay)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to get registry")
|
||||
}
|
||||
@@ -38,7 +38,7 @@ type cmd struct {
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
display *wlclient.Display
|
||||
display wlclient.WaylandDisplay
|
||||
ctx *wlclient.Context
|
||||
registry *wlclient.Registry
|
||||
manager any
|
||||
|
||||
@@ -38,7 +38,7 @@ func CheckCapability() bool {
|
||||
return found
|
||||
}
|
||||
|
||||
func NewManager(display *wlclient.Display) (*Manager, error) {
|
||||
func NewManager(display wlclient.WaylandDisplay) (*Manager, error) {
|
||||
m := &Manager{
|
||||
display: display,
|
||||
ctx: display.Context(),
|
||||
|
||||
392
core/internal/server/extworkspace/manager_test.go
Normal file
392
core/internal/server/extworkspace/manager_test.go
Normal file
@@ -0,0 +1,392 @@
|
||||
package extworkspace
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
|
||||
)
|
||||
|
||||
func TestStateChanged_BothNil(t *testing.T) {
|
||||
assert.True(t, stateChanged(nil, nil))
|
||||
}
|
||||
|
||||
func TestStateChanged_OneNil(t *testing.T) {
|
||||
s := &State{Groups: []*WorkspaceGroup{}}
|
||||
assert.True(t, stateChanged(s, nil))
|
||||
assert.True(t, stateChanged(nil, s))
|
||||
}
|
||||
|
||||
func TestStateChanged_GroupCountDiffers(t *testing.T) {
|
||||
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1"}}}
|
||||
b := &State{Groups: []*WorkspaceGroup{}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_GroupIDDiffers(t *testing.T) {
|
||||
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{}, Workspaces: []*Workspace{}}}}
|
||||
b := &State{Groups: []*WorkspaceGroup{{ID: "group-2", Outputs: []string{}, Workspaces: []*Workspace{}}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_OutputCountDiffers(t *testing.T) {
|
||||
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}, Workspaces: []*Workspace{}}}}
|
||||
b := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{}, Workspaces: []*Workspace{}}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_OutputNameDiffers(t *testing.T) {
|
||||
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}, Workspaces: []*Workspace{}}}}
|
||||
b := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"HDMI-A-1"}, Workspaces: []*Workspace{}}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_WorkspaceCountDiffers(t *testing.T) {
|
||||
a := &State{Groups: []*WorkspaceGroup{{
|
||||
ID: "group-1",
|
||||
Outputs: []string{},
|
||||
Workspaces: []*Workspace{{ID: "1", Name: "ws1"}},
|
||||
}}}
|
||||
b := &State{Groups: []*WorkspaceGroup{{
|
||||
ID: "group-1",
|
||||
Outputs: []string{},
|
||||
Workspaces: []*Workspace{},
|
||||
}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_WorkspaceFieldsDiffer(t *testing.T) {
|
||||
a := &State{Groups: []*WorkspaceGroup{{
|
||||
ID: "group-1",
|
||||
Outputs: []string{},
|
||||
Workspaces: []*Workspace{{
|
||||
ID: "1", Name: "ws1", State: 0, Active: false, Urgent: false, Hidden: false,
|
||||
}},
|
||||
}}}
|
||||
b := &State{Groups: []*WorkspaceGroup{{
|
||||
ID: "group-1",
|
||||
Outputs: []string{},
|
||||
Workspaces: []*Workspace{{
|
||||
ID: "2", Name: "ws1", State: 0, Active: false, Urgent: false, Hidden: false,
|
||||
}},
|
||||
}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
|
||||
b.Groups[0].Workspaces[0].ID = "1"
|
||||
b.Groups[0].Workspaces[0].Name = "ws2"
|
||||
assert.True(t, stateChanged(a, b))
|
||||
|
||||
b.Groups[0].Workspaces[0].Name = "ws1"
|
||||
b.Groups[0].Workspaces[0].State = 1
|
||||
assert.True(t, stateChanged(a, b))
|
||||
|
||||
b.Groups[0].Workspaces[0].State = 0
|
||||
b.Groups[0].Workspaces[0].Active = true
|
||||
assert.True(t, stateChanged(a, b))
|
||||
|
||||
b.Groups[0].Workspaces[0].Active = false
|
||||
b.Groups[0].Workspaces[0].Urgent = true
|
||||
assert.True(t, stateChanged(a, b))
|
||||
|
||||
b.Groups[0].Workspaces[0].Urgent = false
|
||||
b.Groups[0].Workspaces[0].Hidden = true
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_WorkspaceCoordinatesDiffer(t *testing.T) {
|
||||
a := &State{Groups: []*WorkspaceGroup{{
|
||||
ID: "group-1",
|
||||
Outputs: []string{},
|
||||
Workspaces: []*Workspace{{
|
||||
ID: "1", Name: "ws1", Coordinates: []uint32{0, 0},
|
||||
}},
|
||||
}}}
|
||||
b := &State{Groups: []*WorkspaceGroup{{
|
||||
ID: "group-1",
|
||||
Outputs: []string{},
|
||||
Workspaces: []*Workspace{{
|
||||
ID: "1", Name: "ws1", Coordinates: []uint32{1, 0},
|
||||
}},
|
||||
}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
|
||||
b.Groups[0].Workspaces[0].Coordinates = []uint32{0}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_Equal(t *testing.T) {
|
||||
a := &State{Groups: []*WorkspaceGroup{{
|
||||
ID: "group-1",
|
||||
Outputs: []string{"eDP-1", "HDMI-A-1"},
|
||||
Workspaces: []*Workspace{
|
||||
{ID: "1", Name: "ws1", Coordinates: []uint32{0, 0}, State: 1, Active: true},
|
||||
{ID: "2", Name: "ws2", Coordinates: []uint32{1, 0}, State: 0, Active: false},
|
||||
},
|
||||
}}}
|
||||
b := &State{Groups: []*WorkspaceGroup{{
|
||||
ID: "group-1",
|
||||
Outputs: []string{"eDP-1", "HDMI-A-1"},
|
||||
Workspaces: []*Workspace{
|
||||
{ID: "1", Name: "ws1", Coordinates: []uint32{0, 0}, State: 1, Active: true},
|
||||
{ID: "2", Name: "ws2", Coordinates: []uint32{1, 0}, State: 0, Active: false},
|
||||
},
|
||||
}}}
|
||||
assert.False(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentGetState(t *testing.T) {
|
||||
m := &Manager{
|
||||
state: &State{
|
||||
Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}}},
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 50
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
s := m.GetState()
|
||||
_ = s.Groups
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.stateMutex.Lock()
|
||||
m.state = &State{
|
||||
Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}}},
|
||||
}
|
||||
m.stateMutex.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
stopChan: make(chan struct{}),
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
subID := string(rune('a' + id))
|
||||
ch := m.Subscribe(subID)
|
||||
assert.NotNil(t, ch)
|
||||
time.Sleep(time.Millisecond)
|
||||
m.Unsubscribe(subID)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_SyncmapGroupsConcurrentAccess(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 50
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
key := uint32(id)
|
||||
|
||||
for j := 0; j < iterations; j++ {
|
||||
state := &workspaceGroupState{
|
||||
id: key,
|
||||
outputIDs: map[uint32]bool{1: true},
|
||||
workspaceIDs: []uint32{uint32(j)},
|
||||
}
|
||||
m.groups.Store(key, state)
|
||||
|
||||
if loaded, ok := m.groups.Load(key); ok {
|
||||
assert.Equal(t, key, loaded.id)
|
||||
}
|
||||
|
||||
m.groups.Range(func(k uint32, v *workspaceGroupState) bool {
|
||||
_ = v.id
|
||||
_ = v.outputIDs
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
m.groups.Delete(key)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_SyncmapWorkspacesConcurrentAccess(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 50
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
key := uint32(id)
|
||||
|
||||
for j := 0; j < iterations; j++ {
|
||||
state := &workspaceState{
|
||||
id: key,
|
||||
workspaceID: "ws-1",
|
||||
name: "workspace",
|
||||
state: uint32(j % 4),
|
||||
coordinates: []uint32{uint32(j), 0},
|
||||
}
|
||||
m.workspaces.Store(key, state)
|
||||
|
||||
if loaded, ok := m.workspaces.Load(key); ok {
|
||||
assert.Equal(t, key, loaded.id)
|
||||
}
|
||||
|
||||
m.workspaces.Range(func(k uint32, v *workspaceState) bool {
|
||||
_ = v.name
|
||||
_ = v.state
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
m.workspaces.Delete(key)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_SyncmapOutputNamesConcurrentAccess(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 50
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
key := uint32(id)
|
||||
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.outputNames.Store(key, "eDP-1")
|
||||
|
||||
if loaded, ok := m.outputNames.Load(key); ok {
|
||||
assert.NotEmpty(t, loaded)
|
||||
}
|
||||
|
||||
m.outputNames.Range(func(k uint32, v string) bool {
|
||||
_ = v
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
m.outputNames.Delete(key)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
|
||||
m := &Manager{
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
m.notifySubscribers()
|
||||
}
|
||||
|
||||
assert.Len(t, m.dirty, 1)
|
||||
}
|
||||
|
||||
func TestManager_PostQueueFull(t *testing.T) {
|
||||
m := &Manager{
|
||||
cmdq: make(chan cmd, 2),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
m.post(func() {})
|
||||
m.post(func() {})
|
||||
m.post(func() {})
|
||||
m.post(func() {})
|
||||
|
||||
assert.Len(t, m.cmdq, 2)
|
||||
}
|
||||
|
||||
func TestManager_GetStateNilState(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
s := m.GetState()
|
||||
assert.NotNil(t, s.Groups)
|
||||
assert.Empty(t, s.Groups)
|
||||
}
|
||||
|
||||
func TestWorkspace_Fields(t *testing.T) {
|
||||
ws := Workspace{
|
||||
ID: "ws-1",
|
||||
Name: "workspace 1",
|
||||
Coordinates: []uint32{0, 0},
|
||||
State: 1,
|
||||
Active: true,
|
||||
Urgent: false,
|
||||
Hidden: false,
|
||||
}
|
||||
|
||||
assert.Equal(t, "ws-1", ws.ID)
|
||||
assert.Equal(t, "workspace 1", ws.Name)
|
||||
assert.True(t, ws.Active)
|
||||
assert.False(t, ws.Urgent)
|
||||
assert.False(t, ws.Hidden)
|
||||
}
|
||||
|
||||
func TestWorkspaceGroup_Fields(t *testing.T) {
|
||||
group := WorkspaceGroup{
|
||||
ID: "group-1",
|
||||
Outputs: []string{"eDP-1", "HDMI-A-1"},
|
||||
Workspaces: []*Workspace{
|
||||
{ID: "ws-1", Name: "workspace 1"},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "group-1", group.ID)
|
||||
assert.Len(t, group.Outputs, 2)
|
||||
assert.Len(t, group.Workspaces, 1)
|
||||
}
|
||||
|
||||
func TestNewManager_GetRegistryError(t *testing.T) {
|
||||
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
|
||||
|
||||
mockDisplay.EXPECT().Context().Return(nil)
|
||||
mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
|
||||
|
||||
_, err := NewManager(mockDisplay)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to get registry")
|
||||
}
|
||||
@@ -33,7 +33,7 @@ type cmd struct {
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
display *wlclient.Display
|
||||
display wlclient.WaylandDisplay
|
||||
ctx *wlclient.Context
|
||||
registry *wlclient.Registry
|
||||
manager *ext_workspace.ExtWorkspaceManagerV1
|
||||
|
||||
@@ -42,6 +42,7 @@ func handleMatugenQueue(conn net.Conn, req models.Request) {
|
||||
StockColors: getString("stockColors"),
|
||||
SyncModeWithPortal: getBool("syncModeWithPortal", false),
|
||||
TerminalsAlwaysDark: getBool("terminalsAlwaysDark", false),
|
||||
SkipTemplates: getString("skipTemplates"),
|
||||
}
|
||||
|
||||
wait := getBool("wait", true)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
||||
@@ -147,6 +148,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "clipboard.") {
|
||||
if clipboardManager == nil {
|
||||
models.RespondError(conn, req.ID, "clipboard manager not initialized")
|
||||
return
|
||||
}
|
||||
clipboard.HandleRequest(conn, req, clipboardManager)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "ping":
|
||||
models.Respond(conn, req.ID, "pong")
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
||||
@@ -32,7 +33,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
)
|
||||
|
||||
const APIVersion = 22
|
||||
const APIVersion = 23
|
||||
|
||||
var CLIVersion = "dev"
|
||||
|
||||
@@ -63,6 +64,7 @@ var extWorkspaceManager *extworkspace.Manager
|
||||
var brightnessManager *brightness.Manager
|
||||
var wlrOutputManager *wlroutput.Manager
|
||||
var evdevManager *evdev.Manager
|
||||
var clipboardManager *clipboard.Manager
|
||||
var wlContext *wlcontext.SharedContext
|
||||
|
||||
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
|
||||
@@ -336,6 +338,31 @@ func InitializeEvdevManager() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeClipboardManager() error {
|
||||
log.Info("Attempting to initialize clipboard manager...")
|
||||
|
||||
if wlContext == nil {
|
||||
ctx, err := wlcontext.New()
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create shared Wayland context: %v", err)
|
||||
return err
|
||||
}
|
||||
wlContext = ctx
|
||||
}
|
||||
|
||||
config := clipboard.LoadConfig()
|
||||
manager, err := clipboard.NewManager(wlContext, config)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to initialize clipboard manager: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
clipboardManager = manager
|
||||
|
||||
log.Info("Clipboard manager initialized successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleConnection(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -409,6 +436,10 @@ func getCapabilities() Capabilities {
|
||||
caps = append(caps, "evdev")
|
||||
}
|
||||
|
||||
if clipboardManager != nil {
|
||||
caps = append(caps, "clipboard")
|
||||
}
|
||||
|
||||
return Capabilities{Capabilities: caps}
|
||||
}
|
||||
|
||||
@@ -463,6 +494,10 @@ func getServerInfo() ServerInfo {
|
||||
caps = append(caps, "evdev")
|
||||
}
|
||||
|
||||
if clipboardManager != nil {
|
||||
caps = append(caps, "clipboard")
|
||||
}
|
||||
|
||||
return ServerInfo{
|
||||
APIVersion: APIVersion,
|
||||
CLIVersion: CLIVersion,
|
||||
@@ -1034,6 +1069,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
||||
}()
|
||||
}
|
||||
|
||||
if shouldSubscribe("clipboard") && clipboardManager != nil {
|
||||
wg.Add(1)
|
||||
clipboardChan := clipboardManager.Subscribe(clientID + "-clipboard")
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer clipboardManager.Unsubscribe(clientID + "-clipboard")
|
||||
|
||||
initialState := clipboardManager.GetState()
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "clipboard", Data: initialState}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case state, ok := <-clipboardChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "clipboard", Data: state}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(eventChan)
|
||||
@@ -1096,6 +1163,9 @@ func cleanupManagers() {
|
||||
if evdevManager != nil {
|
||||
evdevManager.Close()
|
||||
}
|
||||
if clipboardManager != nil {
|
||||
clipboardManager.Close()
|
||||
}
|
||||
if wlContext != nil {
|
||||
wlContext.Close()
|
||||
}
|
||||
@@ -1259,6 +1329,18 @@ func Start(printDocs bool) error {
|
||||
log.Info("Evdev:")
|
||||
log.Info(" evdev.getState - Get current evdev state (caps lock)")
|
||||
log.Info(" evdev.subscribe - Subscribe to evdev state changes (streaming)")
|
||||
log.Info("Clipboard:")
|
||||
log.Info(" clipboard.getState - Get clipboard state (enabled, history, current)")
|
||||
log.Info(" clipboard.getHistory - Get clipboard history with previews")
|
||||
log.Info(" clipboard.getEntry - Get full entry by ID (params: id)")
|
||||
log.Info(" clipboard.deleteEntry - Delete entry by ID (params: id)")
|
||||
log.Info(" clipboard.clearHistory - Clear all clipboard history")
|
||||
log.Info(" clipboard.copy - Copy text to clipboard (params: text)")
|
||||
log.Info(" clipboard.paste - Get current clipboard text")
|
||||
log.Info(" clipboard.search - Search history (params: query?, mimeType?, isImage?, limit?, offset?, before?, after?)")
|
||||
log.Info(" clipboard.getConfig - Get clipboard configuration")
|
||||
log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)")
|
||||
log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)")
|
||||
log.Info("")
|
||||
}
|
||||
log.Info("Initializing managers...")
|
||||
@@ -1366,10 +1448,15 @@ func Start(printDocs bool) error {
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := InitializeClipboardManager(); err != nil {
|
||||
log.Warnf("Clipboard manager unavailable: %v", err)
|
||||
}
|
||||
if wlContext != nil {
|
||||
wlContext.Start()
|
||||
log.Info("Wayland event dispatcher started")
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info("")
|
||||
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
const animKelvinStep = 25
|
||||
|
||||
func NewManager(display *wlclient.Display, config Config) (*Manager, error) {
|
||||
func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -268,19 +268,23 @@ func (m *Manager) setupOutputControls(outputs []*wlclient.Output, manager *wlr_g
|
||||
}
|
||||
|
||||
func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_control.ZwlrGammaControlV1) {
|
||||
outputID := state.id
|
||||
|
||||
control.SetGammaSizeHandler(func(e wlr_gamma_control.ZwlrGammaControlV1GammaSizeEvent) {
|
||||
if out, ok := m.outputs.Load(state.id); ok {
|
||||
out.rampSize = e.Size
|
||||
size := e.Size
|
||||
m.post(func() {
|
||||
if out, ok := m.outputs.Load(outputID); ok {
|
||||
out.rampSize = size
|
||||
out.failed = false
|
||||
out.retryCount = 0
|
||||
}
|
||||
m.post(func() {
|
||||
m.applyCurrentTemp()
|
||||
})
|
||||
})
|
||||
|
||||
control.SetFailedHandler(func(_ wlr_gamma_control.ZwlrGammaControlV1FailedEvent) {
|
||||
out, ok := m.outputs.Load(state.id)
|
||||
m.post(func() {
|
||||
out, ok := m.outputs.Load(outputID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -296,6 +300,7 @@ func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_co
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) addOutputControl(output *wlclient.Output) error {
|
||||
@@ -583,7 +588,7 @@ func (m *Manager) schedulerLoop() {
|
||||
m.configMutex.RUnlock()
|
||||
|
||||
if enabled {
|
||||
m.applyCurrentTemp()
|
||||
m.post(func() { m.applyCurrentTemp() })
|
||||
}
|
||||
|
||||
var timer *time.Timer
|
||||
@@ -625,14 +630,14 @@ func (m *Manager) schedulerLoop() {
|
||||
enabled := m.config.Enabled
|
||||
m.configMutex.RUnlock()
|
||||
if enabled {
|
||||
m.applyCurrentTemp()
|
||||
m.post(func() { m.applyCurrentTemp() })
|
||||
}
|
||||
case <-timer.C:
|
||||
m.configMutex.RLock()
|
||||
enabled := m.config.Enabled
|
||||
m.configMutex.RUnlock()
|
||||
if enabled {
|
||||
m.applyCurrentTemp()
|
||||
m.post(func() { m.applyCurrentTemp() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -643,6 +648,16 @@ func (m *Manager) applyCurrentTemp() {
|
||||
return
|
||||
}
|
||||
|
||||
m.configMutex.RLock()
|
||||
low, high := m.config.LowTemp, m.config.HighTemp
|
||||
m.configMutex.RUnlock()
|
||||
|
||||
if low == high {
|
||||
m.applyGamma(low)
|
||||
m.updateStateFromSchedule()
|
||||
return
|
||||
}
|
||||
|
||||
if !m.hasValidSchedule() {
|
||||
m.updateStateFromSchedule()
|
||||
return
|
||||
|
||||
414
core/internal/server/wayland/manager_test.go
Normal file
414
core/internal/server/wayland/manager_test.go
Normal file
@@ -0,0 +1,414 @@
|
||||
package wayland
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
|
||||
)
|
||||
|
||||
func TestManager_ActorSerializesOutputStateAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
cmdq: make(chan cmd, 128),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
m.wg.Add(1)
|
||||
go m.waylandActor()
|
||||
|
||||
state := &outputState{
|
||||
id: 1,
|
||||
registryName: 100,
|
||||
rampSize: 256,
|
||||
}
|
||||
m.outputs.Store(state.id, state)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 50
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.post(func() {
|
||||
if out, ok := m.outputs.Load(state.id); ok {
|
||||
out.rampSize = uint32(j)
|
||||
out.failed = j%2 == 0
|
||||
out.retryCount = j
|
||||
out.lastFailTime = time.Now()
|
||||
}
|
||||
})
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
done := make(chan struct{})
|
||||
m.post(func() { close(done) })
|
||||
<-done
|
||||
|
||||
close(m.stopChan)
|
||||
m.wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
stopChan: make(chan struct{}),
|
||||
dirty: make(chan struct{}, 1),
|
||||
updateTrigger: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
subID := string(rune('a' + id))
|
||||
ch := m.Subscribe(subID)
|
||||
assert.NotNil(t, ch)
|
||||
time.Sleep(time.Millisecond)
|
||||
m.Unsubscribe(subID)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentGetState(t *testing.T) {
|
||||
m := &Manager{
|
||||
state: &State{
|
||||
CurrentTemp: 5000,
|
||||
IsDay: true,
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 50
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
s := m.GetState()
|
||||
assert.GreaterOrEqual(t, s.CurrentTemp, 0)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.stateMutex.Lock()
|
||||
m.state = &State{
|
||||
CurrentTemp: 4000 + i*100,
|
||||
IsDay: j%2 == 0,
|
||||
}
|
||||
m.stateMutex.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentConfigAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
config: DefaultConfig(),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.configMutex.RLock()
|
||||
_ = m.config.LowTemp
|
||||
_ = m.config.HighTemp
|
||||
_ = m.config.Enabled
|
||||
m.configMutex.RUnlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.configMutex.Lock()
|
||||
m.config.LowTemp = 3000 + j
|
||||
m.config.HighTemp = 7000 - j
|
||||
m.config.Enabled = j%2 == 0
|
||||
m.configMutex.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 50
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
key := uint32(id)
|
||||
|
||||
for j := 0; j < iterations; j++ {
|
||||
state := &outputState{
|
||||
id: key,
|
||||
rampSize: uint32(j),
|
||||
failed: j%2 == 0,
|
||||
}
|
||||
m.outputs.Store(key, state)
|
||||
|
||||
if loaded, ok := m.outputs.Load(key); ok {
|
||||
assert.Equal(t, key, loaded.id)
|
||||
}
|
||||
|
||||
m.outputs.Range(func(k uint32, v *outputState) bool {
|
||||
_ = v.rampSize
|
||||
_ = v.failed
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
m.outputs.Delete(key)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_LocationCacheConcurrentAccess(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.locationMutex.RLock()
|
||||
_ = m.cachedIPLat
|
||||
_ = m.cachedIPLon
|
||||
m.locationMutex.RUnlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
lat := float64(40 + i)
|
||||
lon := float64(-74 + j)
|
||||
m.locationMutex.Lock()
|
||||
m.cachedIPLat = &lat
|
||||
m.cachedIPLon = &lon
|
||||
m.locationMutex.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ScheduleConcurrentAccess(t *testing.T) {
|
||||
now := time.Now()
|
||||
m := &Manager{
|
||||
schedule: sunSchedule{
|
||||
times: SunTimes{
|
||||
Dawn: now,
|
||||
Sunrise: now.Add(time.Hour),
|
||||
Sunset: now.Add(12 * time.Hour),
|
||||
Night: now.Add(13 * time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.scheduleMutex.RLock()
|
||||
_ = m.schedule.times.Dawn
|
||||
_ = m.schedule.times.Sunrise
|
||||
_ = m.schedule.times.Sunset
|
||||
_ = m.schedule.condition
|
||||
m.scheduleMutex.RUnlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.scheduleMutex.Lock()
|
||||
m.schedule.times.Dawn = time.Now()
|
||||
m.schedule.times.Sunrise = time.Now().Add(time.Hour)
|
||||
m.schedule.condition = SunNormal
|
||||
m.scheduleMutex.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestInterpolate_EdgeCases(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
start time.Time
|
||||
stop time.Time
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "same start and stop",
|
||||
now: now,
|
||||
start: now,
|
||||
stop: now,
|
||||
expected: 1.0,
|
||||
},
|
||||
{
|
||||
name: "now before start",
|
||||
now: now,
|
||||
start: now.Add(time.Hour),
|
||||
stop: now.Add(2 * time.Hour),
|
||||
expected: 0.0,
|
||||
},
|
||||
{
|
||||
name: "now after stop",
|
||||
now: now.Add(3 * time.Hour),
|
||||
start: now,
|
||||
stop: now.Add(time.Hour),
|
||||
expected: 1.0,
|
||||
},
|
||||
{
|
||||
name: "now at midpoint",
|
||||
now: now.Add(30 * time.Minute),
|
||||
start: now,
|
||||
stop: now.Add(time.Hour),
|
||||
expected: 0.5,
|
||||
},
|
||||
{
|
||||
name: "now equals start",
|
||||
now: now,
|
||||
start: now,
|
||||
stop: now.Add(time.Hour),
|
||||
expected: 0.0,
|
||||
},
|
||||
{
|
||||
name: "now equals stop",
|
||||
now: now.Add(time.Hour),
|
||||
start: now,
|
||||
stop: now.Add(time.Hour),
|
||||
expected: 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := interpolate(tt.now, tt.start, tt.stop)
|
||||
assert.InDelta(t, tt.expected, result, 0.01)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateGammaRamp_ZeroSize(t *testing.T) {
|
||||
ramp := GenerateGammaRamp(0, 5000, 1.0)
|
||||
assert.Empty(t, ramp.Red)
|
||||
assert.Empty(t, ramp.Green)
|
||||
assert.Empty(t, ramp.Blue)
|
||||
}
|
||||
|
||||
func TestGenerateGammaRamp_ValidSizes(t *testing.T) {
|
||||
sizes := []uint32{1, 256, 1024}
|
||||
temps := []int{1000, 4000, 6500, 10000}
|
||||
gammas := []float64{0.5, 1.0, 2.0}
|
||||
|
||||
for _, size := range sizes {
|
||||
for _, temp := range temps {
|
||||
for _, gamma := range gammas {
|
||||
ramp := GenerateGammaRamp(size, temp, gamma)
|
||||
assert.Len(t, ramp.Red, int(size))
|
||||
assert.Len(t, ramp.Green, int(size))
|
||||
assert.Len(t, ramp.Blue, int(size))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifySubscribers_NonBlocking(t *testing.T) {
|
||||
m := &Manager{
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
m.notifySubscribers()
|
||||
}
|
||||
|
||||
assert.Len(t, m.dirty, 1)
|
||||
}
|
||||
|
||||
func TestNewManager_GetRegistryError(t *testing.T) {
|
||||
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
|
||||
|
||||
mockDisplay.EXPECT().Context().Return(nil)
|
||||
mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
|
||||
|
||||
config := DefaultConfig()
|
||||
_, err := NewManager(mockDisplay, config)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "get registry")
|
||||
}
|
||||
|
||||
func TestNewManager_InvalidConfig(t *testing.T) {
|
||||
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
|
||||
|
||||
config := Config{
|
||||
LowTemp: 500,
|
||||
HighTemp: 6500,
|
||||
Gamma: 1.0,
|
||||
}
|
||||
|
||||
_, err := NewManager(mockDisplay, config)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ type Manager struct {
|
||||
state *State
|
||||
stateMutex sync.RWMutex
|
||||
|
||||
display *wlclient.Display
|
||||
display wlclient.WaylandDisplay
|
||||
ctx *wlclient.Context
|
||||
registry *wlclient.Registry
|
||||
gammaControl any
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
package wlcontext
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type WaylandContext interface {
|
||||
Display() *wlclient.Display
|
||||
Post(fn func())
|
||||
FatalError() <-chan error
|
||||
Start()
|
||||
Close()
|
||||
}
|
||||
|
||||
var _ WaylandContext = (*SharedContext)(nil)
|
||||
|
||||
type SharedContext struct {
|
||||
display *wlclient.Display
|
||||
stopChan chan struct{}
|
||||
fatalError chan error
|
||||
cmdQueue chan func()
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
@@ -28,6 +42,7 @@ func New() (*SharedContext, error) {
|
||||
display: display,
|
||||
stopChan: make(chan struct{}),
|
||||
fatalError: make(chan error, 1),
|
||||
cmdQueue: make(chan func(), 256),
|
||||
started: false,
|
||||
}
|
||||
|
||||
@@ -51,6 +66,13 @@ func (sc *SharedContext) Display() *wlclient.Display {
|
||||
return sc.display
|
||||
}
|
||||
|
||||
func (sc *SharedContext) Post(fn func()) {
|
||||
select {
|
||||
case sc.cmdQueue <- fn:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SharedContext) FatalError() <-chan error {
|
||||
return sc.fatalError
|
||||
}
|
||||
@@ -74,12 +96,37 @@ func (sc *SharedContext) eventDispatcher() {
|
||||
case <-sc.stopChan:
|
||||
return
|
||||
default:
|
||||
if err := ctx.Dispatch(); err != nil {
|
||||
}
|
||||
|
||||
sc.drainCmdQueue()
|
||||
|
||||
if err := ctx.SetReadDeadline(time.Now().Add(50 * time.Millisecond)); err != nil {
|
||||
log.Errorf("Failed to set read deadline: %v", err)
|
||||
}
|
||||
err := ctx.Dispatch()
|
||||
if err := ctx.SetReadDeadline(time.Time{}); err != nil {
|
||||
log.Errorf("Failed to clear read deadline: %v", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, os.ErrDeadlineExceeded):
|
||||
default:
|
||||
log.Errorf("Wayland connection error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SharedContext) drainCmdQueue() {
|
||||
for {
|
||||
select {
|
||||
case fn := <-sc.cmdQueue:
|
||||
fn()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SharedContext) Close() {
|
||||
|
||||
127
core/internal/server/wlcontext/context_test.go
Normal file
127
core/internal/server/wlcontext/context_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package wlcontext
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSharedContext_ConcurrentPostNonBlocking(t *testing.T) {
|
||||
sc := &SharedContext{
|
||||
cmdQueue: make(chan func(), 256),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 100
|
||||
const iterations = 50
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
sc.Post(func() {
|
||||
_ = id + j
|
||||
})
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSharedContext_PostQueueFull(t *testing.T) {
|
||||
sc := &SharedContext{
|
||||
cmdQueue: make(chan func(), 2),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
sc.Post(func() {})
|
||||
sc.Post(func() {})
|
||||
sc.Post(func() {})
|
||||
sc.Post(func() {})
|
||||
|
||||
assert.Len(t, sc.cmdQueue, 2)
|
||||
}
|
||||
|
||||
func TestSharedContext_StartMultipleTimes(t *testing.T) {
|
||||
sc := &SharedContext{
|
||||
cmdQueue: make(chan func(), 256),
|
||||
stopChan: make(chan struct{}),
|
||||
started: false,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 10
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sc.Start()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.True(t, sc.started)
|
||||
}
|
||||
|
||||
func TestSharedContext_DrainCmdQueue(t *testing.T) {
|
||||
sc := &SharedContext{
|
||||
cmdQueue: make(chan func(), 256),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
counter := 0
|
||||
for i := 0; i < 10; i++ {
|
||||
sc.cmdQueue <- func() {
|
||||
counter++
|
||||
}
|
||||
}
|
||||
|
||||
sc.drainCmdQueue()
|
||||
|
||||
assert.Equal(t, 10, counter)
|
||||
assert.Len(t, sc.cmdQueue, 0)
|
||||
}
|
||||
|
||||
func TestSharedContext_DrainCmdQueueEmpty(t *testing.T) {
|
||||
sc := &SharedContext{
|
||||
cmdQueue: make(chan func(), 256),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
sc.drainCmdQueue()
|
||||
|
||||
assert.Len(t, sc.cmdQueue, 0)
|
||||
}
|
||||
|
||||
func TestSharedContext_ConcurrentDrainAndPost(t *testing.T) {
|
||||
sc := &SharedContext{
|
||||
cmdQueue: make(chan func(), 256),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
sc.Post(func() {})
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
sc.drainCmdQueue()
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
func NewManager(display *wlclient.Display) (*Manager, error) {
|
||||
func NewManager(display wlclient.WaylandDisplay) (*Manager, error) {
|
||||
m := &Manager{
|
||||
display: display,
|
||||
ctx: display.Context(),
|
||||
|
||||
414
core/internal/server/wlroutput/manager_test.go
Normal file
414
core/internal/server/wlroutput/manager_test.go
Normal file
@@ -0,0 +1,414 @@
|
||||
package wlroutput
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
|
||||
)
|
||||
|
||||
func TestStateChanged_BothNil(t *testing.T) {
|
||||
assert.True(t, stateChanged(nil, nil))
|
||||
}
|
||||
|
||||
func TestStateChanged_OneNil(t *testing.T) {
|
||||
s := &State{Serial: 1}
|
||||
assert.True(t, stateChanged(s, nil))
|
||||
assert.True(t, stateChanged(nil, s))
|
||||
}
|
||||
|
||||
func TestStateChanged_SerialDiffers(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{}}
|
||||
b := &State{Serial: 2, Outputs: []Output{}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_OutputCountDiffers(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_OutputNameDiffers(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: true}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{{Name: "HDMI-A-1", Enabled: true}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_OutputEnabledDiffers(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: true}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: false}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_OutputPositionDiffers(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", X: 0, Y: 0}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", X: 1920, Y: 0}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_OutputTransformDiffers(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Transform: 0}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Transform: 1}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_OutputScaleDiffers(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Scale: 1.0}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Scale: 2.0}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_OutputAdaptiveSyncDiffers(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", AdaptiveSync: 0}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", AdaptiveSync: 1}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_CurrentModeNilVsNonNil(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: &OutputMode{Width: 1920}}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_CurrentModeDiffers(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{
|
||||
Name: "eDP-1",
|
||||
CurrentMode: &OutputMode{Width: 1920, Height: 1080, Refresh: 60000},
|
||||
}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{{
|
||||
Name: "eDP-1",
|
||||
CurrentMode: &OutputMode{Width: 2560, Height: 1440, Refresh: 60000},
|
||||
}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
|
||||
b.Outputs[0].CurrentMode.Width = 1920
|
||||
b.Outputs[0].CurrentMode.Height = 1080
|
||||
b.Outputs[0].CurrentMode.Refresh = 144000
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_ModesLengthDiffers(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Modes: []OutputMode{{Width: 1920}}}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Modes: []OutputMode{{Width: 1920}, {Width: 1280}}}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_Equal(t *testing.T) {
|
||||
mode := OutputMode{Width: 1920, Height: 1080, Refresh: 60000, Preferred: true}
|
||||
a := &State{
|
||||
Serial: 5,
|
||||
Outputs: []Output{{
|
||||
Name: "eDP-1",
|
||||
Description: "Built-in display",
|
||||
Make: "BOE",
|
||||
Model: "0x0ABC",
|
||||
SerialNumber: "12345",
|
||||
PhysicalWidth: 309,
|
||||
PhysicalHeight: 174,
|
||||
Enabled: true,
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Transform: 0,
|
||||
Scale: 1.0,
|
||||
CurrentMode: &mode,
|
||||
Modes: []OutputMode{mode},
|
||||
AdaptiveSync: 0,
|
||||
}},
|
||||
}
|
||||
b := &State{
|
||||
Serial: 5,
|
||||
Outputs: []Output{{
|
||||
Name: "eDP-1",
|
||||
Description: "Built-in display",
|
||||
Make: "BOE",
|
||||
Model: "0x0ABC",
|
||||
SerialNumber: "12345",
|
||||
PhysicalWidth: 309,
|
||||
PhysicalHeight: 174,
|
||||
Enabled: true,
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Transform: 0,
|
||||
Scale: 1.0,
|
||||
CurrentMode: &mode,
|
||||
Modes: []OutputMode{mode},
|
||||
AdaptiveSync: 0,
|
||||
}},
|
||||
}
|
||||
assert.False(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentGetState(t *testing.T) {
|
||||
m := &Manager{
|
||||
state: &State{
|
||||
Serial: 1,
|
||||
Outputs: []Output{{Name: "eDP-1", Enabled: true}},
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 50
|
||||
const iterations = 100
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
s := m.GetState()
|
||||
_ = s.Serial
|
||||
_ = s.Outputs
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.stateMutex.Lock()
|
||||
m.state = &State{
|
||||
Serial: uint32(j),
|
||||
Outputs: []Output{{Name: "eDP-1", Scale: float64(j % 3)}},
|
||||
}
|
||||
m.stateMutex.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
stopChan: make(chan struct{}),
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
subID := string(rune('a' + id))
|
||||
ch := m.Subscribe(subID)
|
||||
assert.NotNil(t, ch)
|
||||
time.Sleep(time.Millisecond)
|
||||
m.Unsubscribe(subID)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_SyncmapHeadsConcurrentAccess(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 50
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
key := uint32(id)
|
||||
|
||||
for j := 0; j < iterations; j++ {
|
||||
state := &headState{
|
||||
id: key,
|
||||
name: "test-head",
|
||||
enabled: j%2 == 0,
|
||||
scale: float64(j % 3),
|
||||
modeIDs: []uint32{uint32(j)},
|
||||
}
|
||||
m.heads.Store(key, state)
|
||||
|
||||
if loaded, ok := m.heads.Load(key); ok {
|
||||
assert.Equal(t, key, loaded.id)
|
||||
}
|
||||
|
||||
m.heads.Range(func(k uint32, v *headState) bool {
|
||||
_ = v.name
|
||||
_ = v.enabled
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
m.heads.Delete(key)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_SyncmapModesConcurrentAccess(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 30
|
||||
const iterations = 50
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
key := uint32(id)
|
||||
|
||||
for j := 0; j < iterations; j++ {
|
||||
state := &modeState{
|
||||
id: key,
|
||||
width: int32(1920 + j),
|
||||
height: int32(1080 + j),
|
||||
refresh: 60000,
|
||||
preferred: j == 0,
|
||||
}
|
||||
m.modes.Store(key, state)
|
||||
|
||||
if loaded, ok := m.modes.Load(key); ok {
|
||||
assert.Equal(t, key, loaded.id)
|
||||
}
|
||||
|
||||
m.modes.Range(func(k uint32, v *modeState) bool {
|
||||
_ = v.width
|
||||
_ = v.height
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
m.modes.Delete(key)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
|
||||
m := &Manager{
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
m.notifySubscribers()
|
||||
}
|
||||
|
||||
assert.Len(t, m.dirty, 1)
|
||||
}
|
||||
|
||||
func TestManager_PostQueueFull(t *testing.T) {
|
||||
m := &Manager{
|
||||
cmdq: make(chan cmd, 2),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
m.post(func() {})
|
||||
m.post(func() {})
|
||||
m.post(func() {})
|
||||
m.post(func() {})
|
||||
|
||||
assert.Len(t, m.cmdq, 2)
|
||||
}
|
||||
|
||||
func TestManager_GetStateNilState(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
s := m.GetState()
|
||||
assert.NotNil(t, s.Outputs)
|
||||
assert.Equal(t, uint32(0), s.Serial)
|
||||
}
|
||||
|
||||
func TestManager_FatalErrorChannel(t *testing.T) {
|
||||
m := &Manager{
|
||||
fatalError: make(chan error, 1),
|
||||
}
|
||||
|
||||
ch := m.FatalError()
|
||||
assert.NotNil(t, ch)
|
||||
|
||||
m.fatalError <- assert.AnError
|
||||
err := <-ch
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestOutputMode_Fields(t *testing.T) {
|
||||
mode := OutputMode{
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
Refresh: 60000,
|
||||
Preferred: true,
|
||||
ID: 42,
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(1920), mode.Width)
|
||||
assert.Equal(t, int32(1080), mode.Height)
|
||||
assert.Equal(t, int32(60000), mode.Refresh)
|
||||
assert.True(t, mode.Preferred)
|
||||
assert.Equal(t, uint32(42), mode.ID)
|
||||
}
|
||||
|
||||
func TestOutput_Fields(t *testing.T) {
|
||||
out := Output{
|
||||
Name: "eDP-1",
|
||||
Description: "Built-in display",
|
||||
Make: "BOE",
|
||||
Model: "0x0ABC",
|
||||
SerialNumber: "12345",
|
||||
PhysicalWidth: 309,
|
||||
PhysicalHeight: 174,
|
||||
Enabled: true,
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Transform: 0,
|
||||
Scale: 1.5,
|
||||
AdaptiveSync: 1,
|
||||
ID: 1,
|
||||
}
|
||||
|
||||
assert.Equal(t, "eDP-1", out.Name)
|
||||
assert.Equal(t, "Built-in display", out.Description)
|
||||
assert.True(t, out.Enabled)
|
||||
assert.Equal(t, float64(1.5), out.Scale)
|
||||
assert.Equal(t, uint32(1), out.AdaptiveSync)
|
||||
}
|
||||
|
||||
func TestHeadState_ModeIDsSlice(t *testing.T) {
|
||||
head := &headState{
|
||||
id: 1,
|
||||
modeIDs: make([]uint32, 0),
|
||||
}
|
||||
|
||||
head.modeIDs = append(head.modeIDs, 1, 2, 3)
|
||||
assert.Len(t, head.modeIDs, 3)
|
||||
assert.Equal(t, uint32(1), head.modeIDs[0])
|
||||
}
|
||||
|
||||
func TestStateChanged_BothCurrentModeNil(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}}
|
||||
assert.False(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestStateChanged_IndexOutOfBounds(t *testing.T) {
|
||||
a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}}}
|
||||
b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}, {Name: "HDMI-A-1"}}}
|
||||
assert.True(t, stateChanged(a, b))
|
||||
}
|
||||
|
||||
func TestNewManager_GetRegistryError(t *testing.T) {
|
||||
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
|
||||
|
||||
mockDisplay.EXPECT().Context().Return(nil)
|
||||
mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
|
||||
|
||||
_, err := NewManager(mockDisplay)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to get registry")
|
||||
}
|
||||
@@ -45,7 +45,7 @@ type cmd struct {
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
display *wlclient.Display
|
||||
display wlclient.WaylandDisplay
|
||||
ctx *wlclient.Context
|
||||
registry *wlclient.Registry
|
||||
manager *wlr_output_management.ZwlrOutputManagerV1
|
||||
|
||||
@@ -20,33 +20,3 @@ func ExpandPath(path string) (string, error) {
|
||||
|
||||
return expanded, nil
|
||||
}
|
||||
|
||||
func XDGConfigHome() string {
|
||||
if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
|
||||
return configHome
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".config")
|
||||
}
|
||||
return filepath.Join(os.TempDir(), ".config")
|
||||
}
|
||||
|
||||
func XDGCacheHome() string {
|
||||
if cacheHome := os.Getenv("XDG_CACHE_HOME"); cacheHome != "" {
|
||||
return cacheHome
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".cache")
|
||||
}
|
||||
return filepath.Join(os.TempDir(), ".cache")
|
||||
}
|
||||
|
||||
func XDGDataHome() string {
|
||||
if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" {
|
||||
return dataHome
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".local", "share")
|
||||
}
|
||||
return filepath.Join(os.TempDir(), ".local", "share")
|
||||
}
|
||||
|
||||
@@ -41,66 +41,3 @@ func TestExpandPathAbsolute(t *testing.T) {
|
||||
t.Errorf("expected /absolute/path, got %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXDGConfigHomeDefault(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home directory")
|
||||
}
|
||||
result := XDGConfigHome()
|
||||
expected := filepath.Join(home, ".config")
|
||||
if result != expected {
|
||||
t.Errorf("expected %s, got %s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXDGConfigHomeCustom(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", "/custom/config")
|
||||
result := XDGConfigHome()
|
||||
if result != "/custom/config" {
|
||||
t.Errorf("expected /custom/config, got %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXDGCacheHomeDefault(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", "")
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home directory")
|
||||
}
|
||||
result := XDGCacheHome()
|
||||
expected := filepath.Join(home, ".cache")
|
||||
if result != expected {
|
||||
t.Errorf("expected %s, got %s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXDGCacheHomeCustom(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", "/custom/cache")
|
||||
result := XDGCacheHome()
|
||||
if result != "/custom/cache" {
|
||||
t.Errorf("expected /custom/cache, got %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXDGDataHomeDefault(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", "")
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home directory")
|
||||
}
|
||||
result := XDGDataHome()
|
||||
expected := filepath.Join(home, ".local", "share")
|
||||
if result != expected {
|
||||
t.Errorf("expected %s, got %s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXDGDataHomeCustom(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", "/custom/data")
|
||||
result := XDGDataHome()
|
||||
if result != "/custom/data" {
|
||||
t.Errorf("expected /custom/data, got %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/bin/sh
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Runs go generate for each directory, but in parallel. Any arguments are appended to the
|
||||
# go generate command.
|
||||
# Usage: $ ./generatep [go generate arguments]
|
||||
# Print all generate commands: $ ./generatep -x
|
||||
|
||||
cd ./wayland
|
||||
find . -type f -name '*.go' -exec dirname {} \; | sort -u | parallel -j 0 go generate $1 {}/.
|
||||
cd ./wayland || exit 1
|
||||
find . -type f -name '*.go' -exec dirname {} \; | sort -u | parallel -j 0 go generate "$1" {}/.
|
||||
|
||||
@@ -15,6 +15,15 @@ type Proxy interface {
|
||||
MarkZombie()
|
||||
}
|
||||
|
||||
type WaylandDisplay interface {
|
||||
Context() *Context
|
||||
GetRegistry() (*Registry, error)
|
||||
Roundtrip() error
|
||||
Destroy() error
|
||||
}
|
||||
|
||||
var _ WaylandDisplay = (*Display)(nil)
|
||||
|
||||
type BaseProxy struct {
|
||||
ctx *Context
|
||||
id uint32
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
)
|
||||
@@ -28,6 +29,12 @@ func (ctx *Context) Register(p Proxy) {
|
||||
ctx.objects.Store(id, p)
|
||||
}
|
||||
|
||||
func (ctx *Context) RegisterWithID(p Proxy, id uint32) {
|
||||
p.SetID(id)
|
||||
p.SetContext(ctx)
|
||||
ctx.objects.Store(id, p)
|
||||
}
|
||||
|
||||
func (ctx *Context) Unregister(p Proxy) {
|
||||
ctx.objects.Delete(p.ID())
|
||||
}
|
||||
@@ -47,6 +54,10 @@ func (ctx *Context) Close() error {
|
||||
return ctx.conn.Close()
|
||||
}
|
||||
|
||||
func (ctx *Context) SetReadDeadline(t time.Time) error {
|
||||
return ctx.conn.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
// Dispatch reads and processes incoming messages and calls [client.Dispatcher.Dispatch] on the
|
||||
// respective wayland protocol.
|
||||
// Dispatch must be called on the same goroutine as other interactions with the Context.
|
||||
|
||||
@@ -44,28 +44,6 @@ in
|
||||
description = "The default session are only read if the session.json file don't exist";
|
||||
};
|
||||
};
|
||||
|
||||
plugins = lib.mkOption {
|
||||
type = attrsOf (
|
||||
types.submodule (
|
||||
{
|
||||
options = {
|
||||
enable = lib.mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Whether to link this plugin";
|
||||
};
|
||||
src = lib.mkOption {
|
||||
type = types.path;
|
||||
description = "Source to link to DMS plugins directory";
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
default = { };
|
||||
description = "DMS Plugins to install";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
||||
@@ -187,7 +187,7 @@ echo "Building SRPM..."
|
||||
cd ~/rpmbuild/SPECS
|
||||
rpmbuild -bs dms.spec
|
||||
|
||||
SRPM=$(ls ~/rpmbuild/SRPMS/dms-${VERSION}-*.src.rpm | tail -n 1)
|
||||
SRPM=$(ls ~/rpmbuild/SRPMS/dms-"${VERSION}"-*.src.rpm | tail -n 1)
|
||||
if [ ! -f "$SRPM" ]; then
|
||||
echo "Error: SRPM not found!"
|
||||
exit 1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Unified OBS status checker for dms packages
|
||||
# Checks all platforms (Debian, OpenSUSE) and architectures (x86_64, aarch64)
|
||||
# Only pulls logs if build failed
|
||||
@@ -46,6 +46,7 @@ for pkg in "${PACKAGES[@]}"; do
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
(
|
||||
|
||||
echo "=========================================="
|
||||
echo "=== $pkg ==="
|
||||
@@ -107,9 +108,8 @@ for pkg in "${PACKAGES[@]}"; do
|
||||
fi
|
||||
|
||||
echo ""
|
||||
cd - > /dev/null
|
||||
)
|
||||
done
|
||||
|
||||
echo "=========================================="
|
||||
echo "Status check complete!"
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ if [[ -z "$PACKAGE" ]]; then
|
||||
echo " 2. dms-git - Nightly DMS"
|
||||
echo " a. all"
|
||||
echo ""
|
||||
read -p "Select package (1-${#AVAILABLE_PACKAGES[@]}, a): " selection
|
||||
read -r -p "Select package (1-${#AVAILABLE_PACKAGES[@]}, a): " selection
|
||||
|
||||
if [[ "$selection" == "a" ]] || [[ "$selection" == "all" ]]; then
|
||||
PACKAGE="all"
|
||||
@@ -235,7 +235,7 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
|
||||
echo " - OpenSUSE-only upload: creating source tarball"
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap "rm -rf $TEMP_DIR" EXIT
|
||||
trap 'rm -rf $TEMP_DIR' EXIT
|
||||
|
||||
if [[ -f "distro/debian/$PACKAGE/_service" ]] && grep -q "tar_scm" "distro/debian/$PACKAGE/_service"; then
|
||||
GIT_URL=$(grep -A 5 'name="tar_scm"' "distro/debian/$PACKAGE/_service" | grep "url" | sed 's/.*<param name="url">\(.*\)<\/param>.*/\1/')
|
||||
@@ -244,7 +244,7 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
|
||||
if [[ -n "$GIT_URL" ]]; then
|
||||
echo " Cloning git source from: $GIT_URL (revision: ${GIT_REVISION:-master})"
|
||||
SOURCE_DIR="$TEMP_DIR/dms-git-source"
|
||||
if git clone --depth 1 --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null || \
|
||||
if git clone --depth 1 --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null ||
|
||||
git clone --depth 1 "$GIT_URL" "$SOURCE_DIR" 2>/dev/null; then
|
||||
cd "$SOURCE_DIR"
|
||||
if [[ -n "$GIT_REVISION" ]]; then
|
||||
@@ -295,7 +295,7 @@ fi
|
||||
if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; then
|
||||
# Use CHANGELOG_VERSION already set above, or get it if not set
|
||||
if [[ -z "$CHANGELOG_VERSION" ]]; then
|
||||
CHANGELOG_VERSION=$(grep -m1 "^$PACKAGE" distro/debian/$PACKAGE/debian/changelog 2>/dev/null | sed 's/.*(\([^)]*\)).*/\1/' || echo "0.1.11")
|
||||
CHANGELOG_VERSION=$(grep -m1 "^$PACKAGE" distro/debian/"$PACKAGE"/debian/changelog 2>/dev/null | sed 's/.*(\([^)]*\)).*/\1/' || echo "0.1.11")
|
||||
fi
|
||||
|
||||
# Determine source format
|
||||
@@ -314,7 +314,7 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
||||
|
||||
VERSION="$CHANGELOG_VERSION"
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap "rm -rf $TEMP_DIR" EXIT
|
||||
trap 'rm -rf $TEMP_DIR' EXIT
|
||||
COMBINED_TARBALL="${PACKAGE}_${VERSION}.tar.gz"
|
||||
SOURCE_DIR=""
|
||||
|
||||
@@ -326,7 +326,7 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
||||
if [[ -n "$GIT_URL" ]]; then
|
||||
echo " Cloning git source from: $GIT_URL (revision: ${GIT_REVISION:-master})"
|
||||
SOURCE_DIR="$TEMP_DIR/dms-git-source"
|
||||
if git clone --depth 1 --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null || \
|
||||
if git clone --depth 1 --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null ||
|
||||
git clone --depth 1 "$GIT_URL" "$SOURCE_DIR" 2>/dev/null; then
|
||||
cd "$SOURCE_DIR"
|
||||
if [[ -n "$GIT_REVISION" ]]; then
|
||||
@@ -341,13 +341,13 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
||||
fi
|
||||
fi
|
||||
elif grep -q "download_url" "distro/debian/$PACKAGE/_service" && [[ "$PACKAGE" != "dms-git" ]]; then
|
||||
ALL_PATHS=$(grep -A 5 '<service name="download_url">' "distro/debian/$PACKAGE/_service" | \
|
||||
grep '<param name="path">' | \
|
||||
ALL_PATHS=$(grep -A 5 '<service name="download_url">' "distro/debian/$PACKAGE/_service" |
|
||||
grep '<param name="path">' |
|
||||
sed 's/.*<param name="path">\(.*\)<\/param>.*/\1/')
|
||||
|
||||
SOURCE_PATH=""
|
||||
for path in $ALL_PATHS; do
|
||||
if echo "$path" | grep -qE "(source|archive|\.tar\.(gz|xz|bz2))" && \
|
||||
if echo "$path" | grep -qE "(source|archive|\.tar\.(gz|xz|bz2))" &&
|
||||
! echo "$path" | grep -qE "(distropkg|binary)"; then
|
||||
SOURCE_PATH="$path"
|
||||
break
|
||||
@@ -385,7 +385,7 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
||||
SOURCE_URL="${URL_PROTOCOL}://${URL_HOST}${URL_PATH}"
|
||||
echo " Downloading source from: $SOURCE_URL"
|
||||
|
||||
if wget -q -O "$TEMP_DIR/source-archive" "$SOURCE_URL" 2>/dev/null || \
|
||||
if wget -q -O "$TEMP_DIR/source-archive" "$SOURCE_URL" 2>/dev/null ||
|
||||
curl -L -f -s -o "$TEMP_DIR/source-archive" "$SOURCE_URL" 2>/dev/null; then
|
||||
cd "$TEMP_DIR"
|
||||
if [[ "$SOURCE_URL" == *.tar.xz ]]; then
|
||||
@@ -763,7 +763,7 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ "$SOURCE_FORMAT" == *"native"* ]] && [[
|
||||
if [[ -f "$REPO_CHANGELOG" ]]; then
|
||||
OLD_ENTRY_START=$(grep -n "^$PACKAGE (" "$REPO_CHANGELOG" | sed -n '2p' | cut -d: -f1)
|
||||
if [[ -n "$OLD_ENTRY_START" ]]; then
|
||||
tail -n +$OLD_ENTRY_START "$REPO_CHANGELOG"
|
||||
tail -n +"$OLD_ENTRY_START" "$REPO_CHANGELOG"
|
||||
fi
|
||||
fi
|
||||
} >"$TEMP_CHANGELOG"
|
||||
@@ -848,7 +848,7 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ "$SOURCE_FORMAT" == *"native"* ]] && [[
|
||||
if [[ -f "$REPO_CHANGELOG" ]]; then
|
||||
OLD_ENTRY_START=$(grep -n "^$PACKAGE (" "$REPO_CHANGELOG" | sed -n '2p' | cut -d: -f1)
|
||||
if [[ -n "$OLD_ENTRY_START" ]]; then
|
||||
tail -n +$OLD_ENTRY_START "$REPO_CHANGELOG"
|
||||
tail -n +"$OLD_ENTRY_START" "$REPO_CHANGELOG"
|
||||
fi
|
||||
fi
|
||||
} >"$TEMP_CHANGELOG"
|
||||
@@ -954,11 +954,11 @@ ls -la 2>&1 | head -20
|
||||
echo "==> Staging changes"
|
||||
echo "Files to upload:"
|
||||
if [[ "$UPLOAD_DEBIAN" == true ]] && [[ "$UPLOAD_OPENSUSE" == true ]]; then
|
||||
ls -lh *.tar.gz *.tar.xz *.tar *.spec *.dsc _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
||||
ls -lh ./*.tar.gz ./*.tar.xz ./*.tar ./*.spec ./*.dsc _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
||||
elif [[ "$UPLOAD_DEBIAN" == true ]]; then
|
||||
ls -lh *.tar.gz *.dsc _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
||||
ls -lh ./*.tar.gz ./*.dsc _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
||||
elif [[ "$UPLOAD_OPENSUSE" == true ]]; then
|
||||
ls -lh *.tar.gz *.tar.xz *.tar *.spec _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
||||
ls -lh ./*.tar.gz ./*.tar.xz ./*.tar ./*.spec _service 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
|
||||
fi
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Generic source package builder for DMS PPA packages
|
||||
# Usage: ./create-source.sh <package-dir> [ubuntu-series]
|
||||
#
|
||||
@@ -54,7 +54,7 @@ PACKAGE_PARENT=$(dirname "$PACKAGE_DIR")
|
||||
|
||||
# Create temporary working directory (like OBS)
|
||||
TEMP_WORK_DIR=$(mktemp -d -t ppa_build_work_XXXXXX)
|
||||
trap "rm -rf '$TEMP_WORK_DIR'" EXIT
|
||||
trap 'rm -rf "$TEMP_WORK_DIR"' EXIT
|
||||
|
||||
info "Building source package for: $PACKAGE_NAME"
|
||||
info "Package directory: $PACKAGE_DIR"
|
||||
@@ -327,7 +327,7 @@ if [ "$IS_GIT_PACKAGE" = true ] && [ -n "$GIT_REPO" ]; then
|
||||
OLD_ENTRY_START=$(grep -n "^${SOURCE_NAME} (" debian/changelog | sed -n '2p' | cut -d: -f1)
|
||||
if [ -n "$OLD_ENTRY_START" ]; then
|
||||
# Found second entry, use everything from there
|
||||
CHANGELOG_CONTENT=$(tail -n +$OLD_ENTRY_START debian/changelog)
|
||||
CHANGELOG_CONTENT=$(tail -n +"$OLD_ENTRY_START" debian/changelog)
|
||||
else
|
||||
# No second entry found, changelog will only have new entry
|
||||
CHANGELOG_CONTENT=""
|
||||
@@ -428,7 +428,6 @@ if [ "$IS_GIT_PACKAGE" = true ] && [ -n "$GIT_REPO" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
success "Source prepared for packaging"
|
||||
else
|
||||
error "Failed to clone $GIT_REPO"
|
||||
@@ -443,7 +442,7 @@ elif [ -n "$GIT_REPO" ]; then
|
||||
LATEST_TAG=$(get_latest_tag "$GIT_REPO")
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
# Check source format - native packages can't use dashes
|
||||
SOURCE_FORMAT=$(cat debian/source/format 2>/dev/null | head -1 || echo "3.0 (quilt)")
|
||||
SOURCE_FORMAT=$(head -1 debian/source/format 2>/dev/null || echo "3.0 (quilt)")
|
||||
|
||||
# Get current version to check if we need to increment PPA number
|
||||
CURRENT_VERSION=$(dpkg-parsechangelog -S Version 2>/dev/null || echo "")
|
||||
@@ -498,7 +497,7 @@ elif [ -n "$GIT_REPO" ]; then
|
||||
# Get current changelog content - find the next package header line
|
||||
OLD_ENTRY_START=$(grep -n "^${SOURCE_NAME} (" debian/changelog | sed -n '2p' | cut -d: -f1)
|
||||
if [ -n "$OLD_ENTRY_START" ]; then
|
||||
CHANGELOG_CONTENT=$(tail -n +$OLD_ENTRY_START debian/changelog)
|
||||
CHANGELOG_CONTENT=$(tail -n +"$OLD_ENTRY_START" debian/changelog)
|
||||
else
|
||||
CHANGELOG_CONTENT=""
|
||||
fi
|
||||
@@ -621,11 +620,11 @@ if yes | DEBIAN_FRONTEND=noninteractive debuild -S $DEBUILD_SOURCE_FLAG -d; then
|
||||
|
||||
# Copy build artifacts back to parent directory
|
||||
info "Copying build artifacts to $PACKAGE_PARENT..."
|
||||
cp -v "$TEMP_WORK_DIR"/${SOURCE_NAME}_${CHANGELOG_VERSION}* "$PACKAGE_PARENT/" 2>/dev/null || true
|
||||
cp -v "$TEMP_WORK_DIR"/"${SOURCE_NAME}"_"${CHANGELOG_VERSION}"* "$PACKAGE_PARENT/" 2>/dev/null || true
|
||||
|
||||
# List generated files
|
||||
info "Generated files in $PACKAGE_PARENT:"
|
||||
ls -lh "$PACKAGE_PARENT"/${SOURCE_NAME}_${CHANGELOG_VERSION}* 2>/dev/null || true
|
||||
ls -lh "$PACKAGE_PARENT"/"${SOURCE_NAME}"_"${CHANGELOG_VERSION}"* 2>/dev/null || true
|
||||
|
||||
# Show what to do next
|
||||
echo
|
||||
|
||||
@@ -58,23 +58,18 @@ CHANGES_FILE=$(realpath "$CHANGES_FILE")
|
||||
info "Uploading to PPA: ppa:avengemedia/$PPA_NAME"
|
||||
info "Changes file: $CHANGES_FILE"
|
||||
|
||||
# Check if dput or lftp is installed
|
||||
UPLOAD_METHOD=""
|
||||
# Check if dput is installed
|
||||
if command -v dput &>/dev/null; then
|
||||
UPLOAD_METHOD="dput"
|
||||
elif command -v lftp &> /dev/null; then
|
||||
UPLOAD_METHOD="lftp"
|
||||
warn "dput not found, using lftp as fallback"
|
||||
info "dput found"
|
||||
else
|
||||
error "Neither dput nor lftp found. Install one with:"
|
||||
error " sudo dnf install dput-ng # Preferred but broken on Fedora"
|
||||
error " sudo dnf install lftp # Alternative upload method"
|
||||
error "dput not found. Install with:"
|
||||
error " sudo dnf install dput-ng"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if ~/.dput.cf exists
|
||||
if [ ! -f "$HOME/.dput.cf" ]; then
|
||||
error "~/.dput.cf not found!"
|
||||
error "$HOME/.dput.cf not found!"
|
||||
echo
|
||||
info "Create it from template:"
|
||||
echo " cp $(dirname "$0")/../dput.cf.template ~/.dput.cf"
|
||||
|
||||
@@ -99,10 +99,6 @@ info "Step 2: Uploading to PPA..."
|
||||
if [ "$PPA_NAME" = "danklinux" ] || [ "$PPA_NAME" = "dms" ] || [ "$PPA_NAME" = "dms-git" ]; then
|
||||
warn "Using lftp for upload"
|
||||
|
||||
# Extract version from changes file
|
||||
VERSION=$(grep "^Version:" "$CHANGES_FILE" | awk '{print $2}')
|
||||
SOURCE_NAME=$(grep "^Source:" "$CHANGES_FILE" | awk '{print $2}')
|
||||
|
||||
# Find all files to upload
|
||||
BUILD_DIR=$(dirname "$CHANGES_FILE")
|
||||
CHANGES_BASENAME=$(basename "$CHANGES_FILE")
|
||||
@@ -246,4 +242,3 @@ fi
|
||||
|
||||
echo
|
||||
success "Done!"
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
inherit version;
|
||||
pname = "dms-shell";
|
||||
src = ./core;
|
||||
vendorHash = "sha256-2PCqiW4frxME8IlmwWH5ktznhd/G1bah5Ae4dp0HPTQ=";
|
||||
vendorHash = "sha256-yqV12LssYV0zuUPLjTzJE0e49uUER95dRH4LTcRJeGc=";
|
||||
|
||||
subPackages = [ "cmd/dms" ];
|
||||
|
||||
@@ -171,11 +171,15 @@
|
||||
delve
|
||||
go-tools
|
||||
gnumake
|
||||
|
||||
prek
|
||||
uv # for prek
|
||||
]
|
||||
++ devQmlPkgs;
|
||||
|
||||
shellHook = ''
|
||||
touch quickshell/.qmlls.ini 2>/dev/null
|
||||
if [ ! -f .git/hooks/pre-commit ]; then prek install; fi
|
||||
'';
|
||||
|
||||
QML2_IMPORT_PATH = mkQmlImportPath pkgs devQmlPkgs;
|
||||
|
||||
@@ -169,6 +169,7 @@ Singleton {
|
||||
property var workspaceNameIcons: ({})
|
||||
property bool waveProgressEnabled: true
|
||||
property bool scrollTitleEnabled: true
|
||||
property bool audioVisualizerEnabled: true
|
||||
property bool clockCompactMode: false
|
||||
property bool focusedWindowCompactMode: false
|
||||
property bool runningAppsCompactMode: true
|
||||
@@ -270,6 +271,23 @@ Singleton {
|
||||
property bool syncModeWithPortal: true
|
||||
property bool terminalsAlwaysDark: false
|
||||
|
||||
property bool runDmsMatugenTemplates: true
|
||||
property bool matugenTemplateGtk: true
|
||||
property bool matugenTemplateNiri: true
|
||||
property bool matugenTemplateQt5ct: true
|
||||
property bool matugenTemplateQt6ct: true
|
||||
property bool matugenTemplateFirefox: true
|
||||
property bool matugenTemplatePywalfox: true
|
||||
property bool matugenTemplateVesktop: true
|
||||
property bool matugenTemplateGhostty: true
|
||||
property bool matugenTemplateKitty: true
|
||||
property bool matugenTemplateFoot: true
|
||||
property bool matugenTemplateAlacritty: true
|
||||
property bool matugenTemplateWezterm: true
|
||||
property bool matugenTemplateDgop: true
|
||||
property bool matugenTemplateKcolorscheme: true
|
||||
property bool matugenTemplateVscode: true
|
||||
|
||||
property bool showDock: false
|
||||
property bool dockAutoHide: false
|
||||
property bool dockGroupByApp: false
|
||||
|
||||
@@ -815,17 +815,7 @@ Singleton {
|
||||
console.log("Theme: Starting matugen worker");
|
||||
workerRunning = true;
|
||||
|
||||
const args = [
|
||||
"dms", "matugen", "queue",
|
||||
"--state-dir", stateDir,
|
||||
"--shell-dir", shellDir,
|
||||
"--config-dir", configDir,
|
||||
"--kind", desired.kind,
|
||||
"--value", desired.value,
|
||||
"--mode", desired.mode,
|
||||
"--icon-theme", desired.iconTheme,
|
||||
"--matugen-type", desired.matugenType,
|
||||
];
|
||||
const args = ["dms", "matugen", "queue", "--state-dir", stateDir, "--shell-dir", shellDir, "--config-dir", configDir, "--kind", desired.kind, "--value", desired.value, "--mode", desired.mode, "--icon-theme", desired.iconTheme, "--matugen-type", desired.matugenType,];
|
||||
|
||||
if (!desired.runUserTemplates) {
|
||||
args.push("--run-user-templates=false");
|
||||
@@ -840,6 +830,47 @@ Singleton {
|
||||
args.push("--terminals-always-dark");
|
||||
}
|
||||
|
||||
if (typeof SettingsData !== "undefined") {
|
||||
const skipTemplates = [];
|
||||
if (!SettingsData.runDmsMatugenTemplates) {
|
||||
skipTemplates.push("gtk", "niri", "qt5ct", "qt6ct", "firefox", "pywalfox", "vesktop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode");
|
||||
} else {
|
||||
if (!SettingsData.matugenTemplateGtk)
|
||||
skipTemplates.push("gtk");
|
||||
if (!SettingsData.matugenTemplateNiri)
|
||||
skipTemplates.push("niri");
|
||||
if (!SettingsData.matugenTemplateQt5ct)
|
||||
skipTemplates.push("qt5ct");
|
||||
if (!SettingsData.matugenTemplateQt6ct)
|
||||
skipTemplates.push("qt6ct");
|
||||
if (!SettingsData.matugenTemplateFirefox)
|
||||
skipTemplates.push("firefox");
|
||||
if (!SettingsData.matugenTemplatePywalfox)
|
||||
skipTemplates.push("pywalfox");
|
||||
if (!SettingsData.matugenTemplateVesktop)
|
||||
skipTemplates.push("vesktop");
|
||||
if (!SettingsData.matugenTemplateGhostty)
|
||||
skipTemplates.push("ghostty");
|
||||
if (!SettingsData.matugenTemplateKitty)
|
||||
skipTemplates.push("kitty");
|
||||
if (!SettingsData.matugenTemplateFoot)
|
||||
skipTemplates.push("foot");
|
||||
if (!SettingsData.matugenTemplateAlacritty)
|
||||
skipTemplates.push("alacritty");
|
||||
if (!SettingsData.matugenTemplateWezterm)
|
||||
skipTemplates.push("wezterm");
|
||||
if (!SettingsData.matugenTemplateDgop)
|
||||
skipTemplates.push("dgop");
|
||||
if (!SettingsData.matugenTemplateKcolorscheme)
|
||||
skipTemplates.push("kcolorscheme");
|
||||
if (!SettingsData.matugenTemplateVscode)
|
||||
skipTemplates.push("vscode");
|
||||
}
|
||||
if (skipTemplates.length > 0) {
|
||||
args.push("--skip-templates", skipTemplates.join(","));
|
||||
}
|
||||
}
|
||||
|
||||
systemThemeGenerator.command = args;
|
||||
systemThemeGenerator.running = true;
|
||||
}
|
||||
@@ -1018,12 +1049,7 @@ Singleton {
|
||||
}
|
||||
|
||||
function blend(c1, c2, r) {
|
||||
return Qt.rgba(
|
||||
c1.r * (1-r) + c2.r * r,
|
||||
c1.g * (1-r) + c2.g * r,
|
||||
c1.b * (1-r) + c2.b * r,
|
||||
c1.a * (1-r) + c2.a * r,
|
||||
);
|
||||
return Qt.rgba(c1.r * (1 - r) + c2.r * r, c1.g * (1 - r) + c2.g * r, c1.b * (1 - r) + c2.b * r, c1.a * (1 - r) + c2.a * r);
|
||||
}
|
||||
|
||||
function getFillMode(modeName) {
|
||||
|
||||
@@ -84,6 +84,7 @@ var SPEC = {
|
||||
workspaceNameIcons: { def: {} },
|
||||
waveProgressEnabled: { def: true },
|
||||
scrollTitleEnabled: { def: true },
|
||||
audioVisualizerEnabled: { def: true },
|
||||
clockCompactMode: { def: false },
|
||||
focusedWindowCompactMode: { def: false },
|
||||
runningAppsCompactMode: { def: true },
|
||||
@@ -169,6 +170,23 @@ var SPEC = {
|
||||
syncModeWithPortal: { def: true },
|
||||
terminalsAlwaysDark: { def: false, onChange: "regenSystemThemes" },
|
||||
|
||||
runDmsMatugenTemplates: { def: true },
|
||||
matugenTemplateGtk: { def: true },
|
||||
matugenTemplateNiri: { def: true },
|
||||
matugenTemplateQt5ct: { def: true },
|
||||
matugenTemplateQt6ct: { def: true },
|
||||
matugenTemplateFirefox: { def: true },
|
||||
matugenTemplatePywalfox: { def: true },
|
||||
matugenTemplateVesktop: { def: true },
|
||||
matugenTemplateGhostty: { def: true },
|
||||
matugenTemplateKitty: { def: true },
|
||||
matugenTemplateFoot: { def: true },
|
||||
matugenTemplateAlacritty: { def: true },
|
||||
matugenTemplateWezterm: { def: true },
|
||||
matugenTemplateDgop: { def: true },
|
||||
matugenTemplateKcolorscheme: { def: true },
|
||||
matugenTemplateVscode: { def: true },
|
||||
|
||||
showDock: { def: false },
|
||||
dockAutoHide: { def: false },
|
||||
dockGroupByApp: { def: false },
|
||||
|
||||
@@ -865,4 +865,32 @@ Item {
|
||||
|
||||
target: "plugins"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function open(): string {
|
||||
if (!PopoutService.clipboardHistoryModal) {
|
||||
return "CLIPBOARD_NOT_AVAILABLE";
|
||||
}
|
||||
PopoutService.clipboardHistoryModal.show();
|
||||
return "CLIPBOARD_OPEN_SUCCESS";
|
||||
}
|
||||
|
||||
function close(): string {
|
||||
if (!PopoutService.clipboardHistoryModal) {
|
||||
return "CLIPBOARD_NOT_AVAILABLE";
|
||||
}
|
||||
PopoutService.clipboardHistoryModal.hide();
|
||||
return "CLIPBOARD_CLOSE_SUCCESS";
|
||||
}
|
||||
|
||||
function toggle(): string {
|
||||
if (!PopoutService.clipboardHistoryModal) {
|
||||
return "CLIPBOARD_NOT_AVAILABLE";
|
||||
}
|
||||
PopoutService.clipboardHistoryModal.toggle();
|
||||
return "CLIPBOARD_TOGGLE_SUCCESS";
|
||||
}
|
||||
|
||||
target: "clipboard"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modals.Clipboard
|
||||
|
||||
Item {
|
||||
id: clipboardContent
|
||||
|
||||
required property var modal
|
||||
required property var filteredModel
|
||||
required property var clearConfirmDialog
|
||||
|
||||
property alias searchField: searchField
|
||||
@@ -22,7 +20,6 @@ Item {
|
||||
spacing: Theme.spacingL
|
||||
focus: false
|
||||
|
||||
// Header
|
||||
ClipboardHeader {
|
||||
id: header
|
||||
width: parent.width
|
||||
@@ -31,14 +28,13 @@ Item {
|
||||
onKeyboardHintsToggled: modal.showKeyboardHints = !modal.showKeyboardHints
|
||||
onClearAllClicked: {
|
||||
clearConfirmDialog.show(I18n.tr("Clear All History?"), I18n.tr("This will permanently delete all clipboard history."), function () {
|
||||
modal.clearAll()
|
||||
modal.hide()
|
||||
}, function () {})
|
||||
modal.clearAll();
|
||||
modal.hide();
|
||||
}, function () {});
|
||||
}
|
||||
onCloseClicked: modal.hide()
|
||||
}
|
||||
|
||||
// Search Field
|
||||
DankTextField {
|
||||
id: searchField
|
||||
width: parent.width
|
||||
@@ -49,30 +45,29 @@ Item {
|
||||
ignoreTabKeys: true
|
||||
keyForwardTargets: [modal.modalFocusScope]
|
||||
onTextChanged: {
|
||||
modal.searchText = text
|
||||
modal.updateFilteredModel()
|
||||
modal.searchText = text;
|
||||
modal.updateFilteredModel();
|
||||
}
|
||||
Keys.onEscapePressed: function (event) {
|
||||
modal.hide()
|
||||
event.accepted = true
|
||||
modal.hide();
|
||||
event.accepted = true;
|
||||
}
|
||||
Component.onCompleted: {
|
||||
Qt.callLater(function () {
|
||||
forceActiveFocus()
|
||||
})
|
||||
forceActiveFocus();
|
||||
});
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: modal
|
||||
function onOpened() {
|
||||
Qt.callLater(function () {
|
||||
searchField.forceActiveFocus()
|
||||
})
|
||||
searchField.forceActiveFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List Container
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: parent.height - ClipboardConstants.headerHeight - 70
|
||||
@@ -83,7 +78,10 @@ Item {
|
||||
DankListView {
|
||||
id: clipboardListView
|
||||
anchors.fill: parent
|
||||
model: filteredModel
|
||||
model: ScriptModel {
|
||||
values: clipboardContent.modal.clipboardEntries
|
||||
objectProp: "id"
|
||||
}
|
||||
|
||||
currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0
|
||||
spacing: Theme.spacingXS
|
||||
@@ -97,21 +95,21 @@ Item {
|
||||
|
||||
function ensureVisible(index) {
|
||||
if (index < 0 || index >= count) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const itemHeight = ClipboardConstants.itemHeight + spacing
|
||||
const itemY = index * itemHeight
|
||||
const itemBottom = itemY + itemHeight
|
||||
const itemHeight = ClipboardConstants.itemHeight + spacing;
|
||||
const itemY = index * itemHeight;
|
||||
const itemBottom = itemY + itemHeight;
|
||||
if (itemY < contentY) {
|
||||
contentY = itemY
|
||||
contentY = itemY;
|
||||
} else if (itemBottom > contentY + height) {
|
||||
contentY = itemBottom - height
|
||||
contentY = itemBottom - height;
|
||||
}
|
||||
}
|
||||
|
||||
onCurrentIndexChanged: {
|
||||
if (clipboardContent.modal && clipboardContent.modal.keyboardNavigationActive && currentIndex >= 0) {
|
||||
ensureVisible(currentIndex)
|
||||
if (clipboardContent.modal?.keyboardNavigationActive && currentIndex >= 0) {
|
||||
ensureVisible(currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,28 +118,27 @@ Item {
|
||||
anchors.centerIn: parent
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
visible: filteredModel.count === 0
|
||||
visible: clipboardContent.modal.clipboardEntries.length === 0
|
||||
}
|
||||
|
||||
delegate: ClipboardEntry {
|
||||
required property int index
|
||||
required property var model
|
||||
required property var modelData
|
||||
|
||||
width: clipboardListView.width
|
||||
height: ClipboardConstants.itemHeight
|
||||
entryData: model.entry
|
||||
entry: modelData
|
||||
entryIndex: index + 1
|
||||
itemIndex: index
|
||||
isSelected: clipboardContent.modal && clipboardContent.modal.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex
|
||||
isSelected: clipboardContent.modal?.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex
|
||||
modal: clipboardContent.modal
|
||||
listView: clipboardListView
|
||||
onCopyRequested: clipboardContent.modal.copyEntry(model.entry)
|
||||
onDeleteRequested: clipboardContent.modal.deleteEntry(model.entry)
|
||||
onCopyRequested: clipboardContent.modal.copyEntry(modelData)
|
||||
onDeleteRequested: clipboardContent.modal.deleteEntry(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spacer for keyboard hints
|
||||
Item {
|
||||
width: parent.width
|
||||
height: modal.showKeyboardHints ? ClipboardConstants.keyboardHintsHeight + Theme.spacingL : 0
|
||||
@@ -155,7 +152,6 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard Hints Overlay
|
||||
ClipboardKeyboardHints {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modals.Clipboard
|
||||
|
||||
Rectangle {
|
||||
id: entry
|
||||
id: root
|
||||
|
||||
required property string entryData
|
||||
required property var entry
|
||||
required property int entryIndex
|
||||
required property int itemIndex
|
||||
required property bool isSelected
|
||||
@@ -18,15 +15,15 @@ Rectangle {
|
||||
signal copyRequested
|
||||
signal deleteRequested
|
||||
|
||||
readonly property string entryType: modal ? modal.getEntryType(entryData) : "text"
|
||||
readonly property string entryPreview: modal ? modal.getEntryPreview(entryData) : entryData
|
||||
readonly property string entryType: modal ? modal.getEntryType(entry) : "text"
|
||||
readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : ""
|
||||
|
||||
radius: Theme.cornerRadius
|
||||
color: {
|
||||
if (isSelected) {
|
||||
return Theme.primaryPressed
|
||||
return Theme.primaryPressed;
|
||||
}
|
||||
return mouseArea.containsMouse ? Theme.primaryHoverLight : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
return mouseArea.containsMouse ? Theme.primaryHoverLight : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency);
|
||||
}
|
||||
|
||||
Row {
|
||||
@@ -35,7 +32,6 @@ Rectangle {
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
spacing: Theme.spacingL
|
||||
|
||||
// Index indicator
|
||||
Rectangle {
|
||||
width: 24
|
||||
height: 24
|
||||
@@ -52,25 +48,22 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Content area
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 68
|
||||
spacing: Theme.spacingM
|
||||
|
||||
// Thumbnail/Icon
|
||||
ClipboardThumbnail {
|
||||
width: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
|
||||
height: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
entryData: entry.entryData
|
||||
entryType: entry.entryType
|
||||
modal: entry.modal
|
||||
listView: entry.listView
|
||||
itemIndex: entry.itemIndex
|
||||
entry: root.entry
|
||||
entryType: root.entryType
|
||||
modal: root.modal
|
||||
listView: root.listView
|
||||
itemIndex: root.itemIndex
|
||||
}
|
||||
|
||||
// Text content
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - (entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize) - Theme.spacingM
|
||||
@@ -80,11 +73,11 @@ Rectangle {
|
||||
text: {
|
||||
switch (entryType) {
|
||||
case "image":
|
||||
return I18n.tr("Image") + " • " + entryPreview
|
||||
return I18n.tr("Image") + " • " + entryPreview;
|
||||
case "long_text":
|
||||
return I18n.tr("Long Text")
|
||||
return I18n.tr("Long Text");
|
||||
default:
|
||||
return I18n.tr("Text")
|
||||
return I18n.tr("Text");
|
||||
}
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
@@ -107,7 +100,6 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete button
|
||||
DankActionButton {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
@@ -118,7 +110,6 @@ Rectangle {
|
||||
onClicked: deleteRequested()
|
||||
}
|
||||
|
||||
// Click area
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Modals.Common
|
||||
import qs.Services
|
||||
@@ -27,33 +25,27 @@ DankModal {
|
||||
property Component clipboardContent
|
||||
property int activeImageLoads: 0
|
||||
readonly property int maxConcurrentLoads: 3
|
||||
readonly property bool clipboardAvailable: DMSService.isConnected && DMSService.capabilities.includes("clipboard")
|
||||
|
||||
function updateFilteredModel() {
|
||||
filteredClipboardModel.clear();
|
||||
for (var i = 0; i < clipboardModel.count; i++) {
|
||||
const entry = clipboardModel.get(i).entry;
|
||||
if (searchText.trim().length === 0) {
|
||||
filteredClipboardModel.append({
|
||||
"entry": entry
|
||||
});
|
||||
const query = searchText.trim();
|
||||
if (query.length === 0) {
|
||||
clipboardEntries = internalEntries;
|
||||
} else {
|
||||
const content = getEntryPreview(entry).toLowerCase();
|
||||
if (content.includes(searchText.toLowerCase())) {
|
||||
filteredClipboardModel.append({
|
||||
"entry": entry
|
||||
});
|
||||
const lowerQuery = query.toLowerCase();
|
||||
clipboardEntries = internalEntries.filter(entry => entry.preview.toLowerCase().includes(lowerQuery));
|
||||
}
|
||||
}
|
||||
}
|
||||
clipboardHistoryModal.totalCount = filteredClipboardModel.count;
|
||||
if (filteredClipboardModel.count === 0) {
|
||||
totalCount = clipboardEntries.length;
|
||||
if (clipboardEntries.length === 0) {
|
||||
keyboardNavigationActive = false;
|
||||
selectedIndex = 0;
|
||||
} else if (selectedIndex >= filteredClipboardModel.count) {
|
||||
selectedIndex = filteredClipboardModel.count - 1;
|
||||
} else if (selectedIndex >= clipboardEntries.length) {
|
||||
selectedIndex = clipboardEntries.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
property var internalEntries: []
|
||||
|
||||
function toggle() {
|
||||
if (shouldBeVisible) {
|
||||
hide();
|
||||
@@ -63,15 +55,19 @@ DankModal {
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (!clipboardAvailable) {
|
||||
ToastService.showError(I18n.tr("Clipboard service not available"));
|
||||
return;
|
||||
}
|
||||
open();
|
||||
clipboardHistoryModal.searchText = "";
|
||||
clipboardHistoryModal.activeImageLoads = 0;
|
||||
clipboardHistoryModal.shouldHaveFocus = true;
|
||||
searchText = "";
|
||||
activeImageLoads = 0;
|
||||
shouldHaveFocus = true;
|
||||
refreshClipboard();
|
||||
keyboardController.reset();
|
||||
|
||||
Qt.callLater(function () {
|
||||
if (contentLoader.item && contentLoader.item.searchField) {
|
||||
if (contentLoader.item?.searchField) {
|
||||
contentLoader.item.searchField.text = "";
|
||||
contentLoader.item.searchField.forceActiveFocus();
|
||||
}
|
||||
@@ -80,60 +76,77 @@ DankModal {
|
||||
|
||||
function hide() {
|
||||
close();
|
||||
clipboardHistoryModal.searchText = "";
|
||||
clipboardHistoryModal.activeImageLoads = 0;
|
||||
updateFilteredModel();
|
||||
searchText = "";
|
||||
activeImageLoads = 0;
|
||||
internalEntries = [];
|
||||
clipboardEntries = [];
|
||||
keyboardController.reset();
|
||||
cleanupTempFiles();
|
||||
}
|
||||
|
||||
function cleanupTempFiles() {
|
||||
Quickshell.execDetached(["sh", "-c", "rm -f /tmp/clipboard_*.png"]);
|
||||
}
|
||||
|
||||
function refreshClipboard() {
|
||||
clipboardProcesses.refresh();
|
||||
DMSService.sendRequest("clipboard.getHistory", null, function (response) {
|
||||
if (response.error) {
|
||||
console.warn("ClipboardHistoryModal: Failed to get history:", response.error);
|
||||
return;
|
||||
}
|
||||
internalEntries = response.result || [];
|
||||
updateFilteredModel();
|
||||
});
|
||||
}
|
||||
|
||||
function copyEntry(entry) {
|
||||
const entryId = entry.split('\t')[0];
|
||||
Quickshell.execDetached(["sh", "-c", `cliphist decode ${entryId} | wl-copy`]);
|
||||
ToastService.showInfo(I18n.tr("Copied to clipboard"));
|
||||
DMSService.sendRequest("clipboard.copyEntry", {
|
||||
"id": entry.id
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
ToastService.showError(I18n.tr("Failed to copy entry"));
|
||||
return;
|
||||
}
|
||||
ToastService.showInfo(entry.isImage ? I18n.tr("Image copied to clipboard") : I18n.tr("Copied to clipboard"));
|
||||
hide();
|
||||
});
|
||||
}
|
||||
|
||||
function deleteEntry(entry) {
|
||||
clipboardProcesses.deleteEntry(entry);
|
||||
DMSService.sendRequest("clipboard.deleteEntry", {
|
||||
"id": entry.id
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
console.warn("ClipboardHistoryModal: Failed to delete entry:", response.error);
|
||||
return;
|
||||
}
|
||||
internalEntries = internalEntries.filter(e => e.id !== entry.id);
|
||||
updateFilteredModel();
|
||||
if (clipboardEntries.length === 0) {
|
||||
keyboardNavigationActive = false;
|
||||
selectedIndex = 0;
|
||||
} else if (selectedIndex >= clipboardEntries.length) {
|
||||
selectedIndex = clipboardEntries.length - 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
clipboardProcesses.clearAll();
|
||||
DMSService.sendRequest("clipboard.clearHistory", null, function (response) {
|
||||
if (response.error) {
|
||||
console.warn("ClipboardHistoryModal: Failed to clear history:", response.error);
|
||||
return;
|
||||
}
|
||||
internalEntries = [];
|
||||
clipboardEntries = [];
|
||||
totalCount = 0;
|
||||
});
|
||||
}
|
||||
|
||||
function getEntryPreview(entry) {
|
||||
let content = entry.replace(/^\s*\d+\s+/, "");
|
||||
if (content.includes("image/") || content.includes("binary data") || /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(content)) {
|
||||
const dimensionMatch = content.match(/(\d+)x(\d+)/);
|
||||
if (dimensionMatch) {
|
||||
return `Image ${dimensionMatch[1]}×${dimensionMatch[2]}`;
|
||||
}
|
||||
const typeMatch = content.match(/\b(png|jpg|jpeg|gif|bmp|webp)\b/i);
|
||||
if (typeMatch) {
|
||||
return `Image (${typeMatch[1].toUpperCase()})`;
|
||||
}
|
||||
return "Image";
|
||||
}
|
||||
if (content.length > ClipboardConstants.previewLength) {
|
||||
return content.substring(0, ClipboardConstants.previewLength) + "...";
|
||||
}
|
||||
return content;
|
||||
return entry.preview || "";
|
||||
}
|
||||
|
||||
function getEntryType(entry) {
|
||||
if (entry.includes("image/") || entry.includes("binary data") || /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(entry) || /\b(png|jpg|jpeg|gif|bmp|webp)\b/i.test(entry)) {
|
||||
if (entry.isImage) {
|
||||
return "image";
|
||||
}
|
||||
if (entry.length > ClipboardConstants.longTextThreshold) {
|
||||
if (entry.size > ClipboardConstants.longTextThreshold) {
|
||||
return "long_text";
|
||||
}
|
||||
return "text";
|
||||
@@ -168,55 +181,18 @@ DankModal {
|
||||
} else if (clipboardHistoryModal.shouldBeVisible) {
|
||||
clipboardHistoryModal.shouldHaveFocus = true;
|
||||
clipboardHistoryModal.modalFocusScope.forceActiveFocus();
|
||||
if (clipboardHistoryModal.contentLoader.item && clipboardHistoryModal.contentLoader.item.searchField) {
|
||||
if (clipboardHistoryModal.contentLoader.item?.searchField) {
|
||||
clipboardHistoryModal.contentLoader.item.searchField.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
property alias filteredClipboardModel: filteredClipboardModel
|
||||
property alias clipboardModel: clipboardModel
|
||||
property var confirmDialog: clearConfirmDialog
|
||||
|
||||
ListModel {
|
||||
id: clipboardModel
|
||||
}
|
||||
|
||||
ListModel {
|
||||
id: filteredClipboardModel
|
||||
}
|
||||
|
||||
ClipboardProcesses {
|
||||
id: clipboardProcesses
|
||||
modal: clipboardHistoryModal
|
||||
clipboardModel: clipboardModel
|
||||
filteredClipboardModel: filteredClipboardModel
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function open(): string {
|
||||
clipboardHistoryModal.show();
|
||||
return "CLIPBOARD_OPEN_SUCCESS";
|
||||
}
|
||||
|
||||
function close(): string {
|
||||
clipboardHistoryModal.hide();
|
||||
return "CLIPBOARD_CLOSE_SUCCESS";
|
||||
}
|
||||
|
||||
function toggle(): string {
|
||||
clipboardHistoryModal.toggle();
|
||||
return "CLIPBOARD_TOGGLE_SUCCESS";
|
||||
}
|
||||
|
||||
target: "clipboard"
|
||||
}
|
||||
|
||||
clipboardContent: Component {
|
||||
ClipboardContent {
|
||||
modal: clipboardHistoryModal
|
||||
filteredModel: filteredClipboardModel
|
||||
clearConfirmDialog: clipboardHistoryModal.confirmDialog
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
QtObject {
|
||||
id: keyboardController
|
||||
@@ -7,125 +6,133 @@ QtObject {
|
||||
required property var modal
|
||||
|
||||
function reset() {
|
||||
modal.selectedIndex = 0
|
||||
modal.keyboardNavigationActive = false
|
||||
modal.showKeyboardHints = false
|
||||
modal.selectedIndex = 0;
|
||||
modal.keyboardNavigationActive = false;
|
||||
modal.showKeyboardHints = false;
|
||||
}
|
||||
|
||||
function selectNext() {
|
||||
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) {
|
||||
return
|
||||
if (!modal.clipboardEntries || modal.clipboardEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
modal.keyboardNavigationActive = true
|
||||
modal.selectedIndex = Math.min(modal.selectedIndex + 1, modal.filteredClipboardModel.count - 1)
|
||||
modal.keyboardNavigationActive = true;
|
||||
modal.selectedIndex = Math.min(modal.selectedIndex + 1, modal.clipboardEntries.length - 1);
|
||||
}
|
||||
|
||||
function selectPrevious() {
|
||||
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) {
|
||||
return
|
||||
if (!modal.clipboardEntries || modal.clipboardEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
modal.keyboardNavigationActive = true
|
||||
modal.selectedIndex = Math.max(modal.selectedIndex - 1, 0)
|
||||
modal.keyboardNavigationActive = true;
|
||||
modal.selectedIndex = Math.max(modal.selectedIndex - 1, 0);
|
||||
}
|
||||
|
||||
function copySelected() {
|
||||
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.filteredClipboardModel.count) {
|
||||
return
|
||||
if (!modal.clipboardEntries || modal.clipboardEntries.length === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.clipboardEntries.length) {
|
||||
return;
|
||||
}
|
||||
const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry
|
||||
modal.copyEntry(selectedEntry)
|
||||
const selectedEntry = modal.clipboardEntries[modal.selectedIndex];
|
||||
modal.copyEntry(selectedEntry);
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.filteredClipboardModel.count) {
|
||||
return
|
||||
if (!modal.clipboardEntries || modal.clipboardEntries.length === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.clipboardEntries.length) {
|
||||
return;
|
||||
}
|
||||
const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry
|
||||
modal.deleteEntry(selectedEntry)
|
||||
const selectedEntry = modal.clipboardEntries[modal.selectedIndex];
|
||||
modal.deleteEntry(selectedEntry);
|
||||
}
|
||||
|
||||
function handleKey(event) {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
switch (event.key) {
|
||||
case Qt.Key_Escape:
|
||||
if (modal.keyboardNavigationActive) {
|
||||
modal.keyboardNavigationActive = false
|
||||
event.accepted = true
|
||||
modal.keyboardNavigationActive = false;
|
||||
} else {
|
||||
modal.hide()
|
||||
event.accepted = true
|
||||
modal.hide();
|
||||
}
|
||||
} else if (event.key === Qt.Key_Down || event.key === Qt.Key_Tab) {
|
||||
event.accepted = true;
|
||||
return;
|
||||
case Qt.Key_Down:
|
||||
case Qt.Key_Tab:
|
||||
if (!modal.keyboardNavigationActive) {
|
||||
modal.keyboardNavigationActive = true
|
||||
modal.selectedIndex = 0
|
||||
event.accepted = true
|
||||
modal.keyboardNavigationActive = true;
|
||||
modal.selectedIndex = 0;
|
||||
} else {
|
||||
selectNext()
|
||||
event.accepted = true
|
||||
selectNext();
|
||||
}
|
||||
} else if (event.key === Qt.Key_Up || event.key === Qt.Key_Backtab) {
|
||||
event.accepted = true;
|
||||
return;
|
||||
case Qt.Key_Up:
|
||||
case Qt.Key_Backtab:
|
||||
if (!modal.keyboardNavigationActive) {
|
||||
modal.keyboardNavigationActive = true
|
||||
modal.selectedIndex = 0
|
||||
event.accepted = true
|
||||
modal.keyboardNavigationActive = true;
|
||||
modal.selectedIndex = 0;
|
||||
} else if (modal.selectedIndex === 0) {
|
||||
modal.keyboardNavigationActive = false
|
||||
event.accepted = true
|
||||
modal.keyboardNavigationActive = false;
|
||||
} else {
|
||||
selectPrevious()
|
||||
event.accepted = true
|
||||
selectPrevious();
|
||||
}
|
||||
} else if (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier) {
|
||||
event.accepted = true;
|
||||
return;
|
||||
case Qt.Key_F10:
|
||||
modal.showKeyboardHints = !modal.showKeyboardHints;
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.modifiers & Qt.ControlModifier) {
|
||||
switch (event.key) {
|
||||
case Qt.Key_N:
|
||||
case Qt.Key_J:
|
||||
if (!modal.keyboardNavigationActive) {
|
||||
modal.keyboardNavigationActive = true
|
||||
modal.selectedIndex = 0
|
||||
modal.keyboardNavigationActive = true;
|
||||
modal.selectedIndex = 0;
|
||||
} else {
|
||||
selectNext()
|
||||
selectNext();
|
||||
}
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_P && event.modifiers & Qt.ControlModifier) {
|
||||
event.accepted = true;
|
||||
return;
|
||||
case Qt.Key_P:
|
||||
case Qt.Key_K:
|
||||
if (!modal.keyboardNavigationActive) {
|
||||
modal.keyboardNavigationActive = true
|
||||
modal.selectedIndex = 0
|
||||
modal.keyboardNavigationActive = true;
|
||||
modal.selectedIndex = 0;
|
||||
} else if (modal.selectedIndex === 0) {
|
||||
modal.keyboardNavigationActive = false
|
||||
modal.keyboardNavigationActive = false;
|
||||
} else {
|
||||
selectPrevious()
|
||||
selectPrevious();
|
||||
}
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) {
|
||||
if (!modal.keyboardNavigationActive) {
|
||||
modal.keyboardNavigationActive = true
|
||||
modal.selectedIndex = 0
|
||||
} else {
|
||||
selectNext()
|
||||
event.accepted = true;
|
||||
return;
|
||||
case Qt.Key_C:
|
||||
if (modal.keyboardNavigationActive) {
|
||||
copySelected();
|
||||
event.accepted = true;
|
||||
}
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_K && event.modifiers & Qt.ControlModifier) {
|
||||
if (!modal.keyboardNavigationActive) {
|
||||
modal.keyboardNavigationActive = true
|
||||
modal.selectedIndex = 0
|
||||
} else if (modal.selectedIndex === 0) {
|
||||
modal.keyboardNavigationActive = false
|
||||
} else {
|
||||
selectPrevious()
|
||||
}
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Delete && (event.modifiers & Qt.ShiftModifier)) {
|
||||
modal.clearAll()
|
||||
modal.hide()
|
||||
event.accepted = true
|
||||
} else if (modal.keyboardNavigationActive) {
|
||||
if ((event.key === Qt.Key_C && (event.modifiers & Qt.ControlModifier)) || event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
copySelected()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Delete) {
|
||||
deleteSelected()
|
||||
event.accepted = true
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === Qt.Key_F10) {
|
||||
modal.showKeyboardHints = !modal.showKeyboardHints
|
||||
event.accepted = true
|
||||
|
||||
if (event.modifiers & Qt.ShiftModifier && event.key === Qt.Key_Delete) {
|
||||
modal.clearAll();
|
||||
modal.hide();
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (modal.keyboardNavigationActive) {
|
||||
switch (event.key) {
|
||||
case Qt.Key_Return:
|
||||
case Qt.Key_Enter:
|
||||
copySelected();
|
||||
event.accepted = true;
|
||||
return;
|
||||
case Qt.Key_Delete:
|
||||
deleteSelected();
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modals.Clipboard
|
||||
|
||||
Rectangle {
|
||||
id: keyboardHints
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
QtObject {
|
||||
id: clipboardProcesses
|
||||
|
||||
required property var modal
|
||||
required property var clipboardModel
|
||||
required property var filteredClipboardModel
|
||||
|
||||
// Load clipboard entries
|
||||
property var loadProcess: Process {
|
||||
id: loadProcess
|
||||
command: ["cliphist", "list"]
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
clipboardModel.clear()
|
||||
const lines = text.trim().split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.trim().length > 0) {
|
||||
clipboardModel.append({
|
||||
"entry": line
|
||||
})
|
||||
}
|
||||
}
|
||||
modal.updateFilteredModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete single entry
|
||||
property var deleteProcess: Process {
|
||||
id: deleteProcess
|
||||
property string deletedEntry: ""
|
||||
running: false
|
||||
|
||||
onExited: exitCode => {
|
||||
if (exitCode === 0) {
|
||||
for (var i = 0; i < clipboardModel.count; i++) {
|
||||
if (clipboardModel.get(i).entry === deleteProcess.deletedEntry) {
|
||||
clipboardModel.remove(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
for (var j = 0; j < filteredClipboardModel.count; j++) {
|
||||
if (filteredClipboardModel.get(j).entry === deleteProcess.deletedEntry) {
|
||||
filteredClipboardModel.remove(j)
|
||||
break
|
||||
}
|
||||
}
|
||||
modal.totalCount = filteredClipboardModel.count
|
||||
if (filteredClipboardModel.count === 0) {
|
||||
modal.keyboardNavigationActive = false
|
||||
modal.selectedIndex = 0
|
||||
} else if (modal.selectedIndex >= filteredClipboardModel.count) {
|
||||
modal.selectedIndex = filteredClipboardModel.count - 1
|
||||
}
|
||||
} else {
|
||||
console.warn("Failed to delete clipboard entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all entries
|
||||
property var clearProcess: Process {
|
||||
id: clearProcess
|
||||
command: ["cliphist", "wipe"]
|
||||
running: false
|
||||
|
||||
onExited: exitCode => {
|
||||
if (exitCode === 0) {
|
||||
clipboardModel.clear()
|
||||
filteredClipboardModel.clear()
|
||||
modal.totalCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
loadProcess.running = true
|
||||
}
|
||||
|
||||
function deleteEntry(entry) {
|
||||
deleteProcess.deletedEntry = entry
|
||||
deleteProcess.command = ["sh", "-c", `echo '${entry.replace(/'/g, "'\\''")}' | cliphist delete`]
|
||||
deleteProcess.running = true
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
clearProcess.running = true
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modals.Clipboard
|
||||
|
||||
Item {
|
||||
id: thumbnail
|
||||
|
||||
required property string entryData
|
||||
required property var entry
|
||||
required property string entryType
|
||||
required property var modal
|
||||
required property var listView
|
||||
@@ -17,13 +16,12 @@ Item {
|
||||
Image {
|
||||
id: thumbnailImage
|
||||
|
||||
property string entryId: entryData.split('\t')[0]
|
||||
property bool isVisible: false
|
||||
property string cachedImageData: ""
|
||||
property bool loadQueued: false
|
||||
|
||||
anchors.fill: parent
|
||||
source: ""
|
||||
source: cachedImageData ? `data:image/png;base64,${cachedImageData}` : ""
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
smooth: true
|
||||
cache: false
|
||||
@@ -32,53 +30,66 @@ Item {
|
||||
sourceSize.width: 128
|
||||
sourceSize.height: 128
|
||||
|
||||
onCachedImageDataChanged: {
|
||||
if (cachedImageData) {
|
||||
source = ""
|
||||
source = `data:image/png;base64,${cachedImageData}`
|
||||
function tryLoadImage() {
|
||||
if (loadQueued || entryType !== "image" || cachedImageData) {
|
||||
return;
|
||||
}
|
||||
loadQueued = true;
|
||||
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
|
||||
modal.activeImageLoads++;
|
||||
loadImage();
|
||||
} else {
|
||||
retryTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
function tryLoadImage() {
|
||||
if (!loadQueued && entryType === "image" && !cachedImageData) {
|
||||
loadQueued = true
|
||||
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
|
||||
modal.activeImageLoads++
|
||||
imageLoader.running = true
|
||||
} else {
|
||||
retryTimer.restart()
|
||||
function loadImage() {
|
||||
DMSService.sendRequest("clipboard.getEntry", {
|
||||
"id": entry.id
|
||||
}, function (response) {
|
||||
loadQueued = false;
|
||||
if (modal.activeImageLoads > 0) {
|
||||
modal.activeImageLoads--;
|
||||
}
|
||||
if (response.error) {
|
||||
console.warn("ClipboardThumbnail: Failed to load image:", entry.id);
|
||||
return;
|
||||
}
|
||||
const data = response.result?.data;
|
||||
if (data) {
|
||||
cachedImageData = data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: retryTimer
|
||||
interval: ClipboardConstants.retryInterval
|
||||
onTriggered: {
|
||||
if (thumbnailImage.loadQueued && !imageLoader.running) {
|
||||
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
|
||||
modal.activeImageLoads++
|
||||
imageLoader.running = true
|
||||
} else {
|
||||
retryTimer.restart()
|
||||
if (!thumbnailImage.loadQueued) {
|
||||
return;
|
||||
}
|
||||
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
|
||||
modal.activeImageLoads++;
|
||||
thumbnailImage.loadImage();
|
||||
} else {
|
||||
retryTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (entryType !== "image") {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if item is visible on screen initially
|
||||
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing)
|
||||
const viewTop = listView.contentY
|
||||
const viewBottom = viewTop + listView.height
|
||||
isVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom)
|
||||
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
|
||||
const viewTop = listView.contentY;
|
||||
const viewBottom = viewTop + listView.height;
|
||||
isVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom);
|
||||
|
||||
if (isVisible) {
|
||||
tryLoadImage()
|
||||
tryLoadImage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,48 +97,22 @@ Item {
|
||||
target: listView
|
||||
function onContentYChanged() {
|
||||
if (entryType !== "image") {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing)
|
||||
const viewTop = listView.contentY - ClipboardConstants.viewportBuffer
|
||||
const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer
|
||||
const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom)
|
||||
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
|
||||
const viewTop = listView.contentY - ClipboardConstants.viewportBuffer;
|
||||
const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer;
|
||||
const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom);
|
||||
|
||||
if (nowVisible && !thumbnailImage.isVisible) {
|
||||
thumbnailImage.isVisible = true
|
||||
thumbnailImage.tryLoadImage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: imageLoader
|
||||
running: false
|
||||
command: ["sh", "-c", `cliphist decode ${thumbnailImage.entryId} | base64 -w 0`]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const imageData = text.trim()
|
||||
if (imageData && imageData.length > 0) {
|
||||
thumbnailImage.cachedImageData = imageData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
thumbnailImage.loadQueued = false
|
||||
if (modal.activeImageLoads > 0) {
|
||||
modal.activeImageLoads--
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
console.warn("Failed to load clipboard image:", thumbnailImage.entryId)
|
||||
thumbnailImage.isVisible = true;
|
||||
thumbnailImage.tryLoadImage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rounded mask effect for images
|
||||
MultiEffect {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 2
|
||||
@@ -155,17 +140,17 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback icon
|
||||
DankIcon {
|
||||
visible: !(entryType === "image" && thumbnailImage.status === Image.Ready && thumbnailImage.source != "")
|
||||
name: {
|
||||
if (entryType === "image") {
|
||||
return "image"
|
||||
switch (entryType) {
|
||||
case "image":
|
||||
return "image";
|
||||
case "long_text":
|
||||
return "subject";
|
||||
default:
|
||||
return "content_copy";
|
||||
}
|
||||
if (entryType === "long_text") {
|
||||
return "subject"
|
||||
}
|
||||
return "content_copy"
|
||||
}
|
||||
size: Theme.iconSize
|
||||
color: Theme.primary
|
||||
|
||||
@@ -54,7 +54,7 @@ DankModal {
|
||||
}
|
||||
|
||||
function copyColorToClipboard(colorValue) {
|
||||
Quickshell.execDetached(["sh", "-c", `echo -n "${colorValue}" | wl-copy`]);
|
||||
Quickshell.execDetached(["dms", "cl", "copy", colorValue]);
|
||||
ToastService.showInfo(`Color ${colorValue} copied`);
|
||||
SessionData.addRecentColor(currentColor);
|
||||
}
|
||||
@@ -614,7 +614,7 @@ DankModal {
|
||||
} else {
|
||||
rgbString = `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
Quickshell.execDetached(["sh", "-c", `echo -n "${rgbString}" | wl-copy`]);
|
||||
Quickshell.execDetached(["dms", "cl", "copy", rgbString]);
|
||||
ToastService.showInfo(`${rgbString} copied`);
|
||||
}
|
||||
}
|
||||
@@ -678,7 +678,7 @@ DankModal {
|
||||
} else {
|
||||
hsvString = `${h}, ${s}, ${v}`;
|
||||
}
|
||||
Quickshell.execDetached(["sh", "-c", `echo -n "${hsvString}" | wl-copy`]);
|
||||
Quickshell.execDetached(["dms", "cl", "copy", hsvString]);
|
||||
ToastService.showInfo(`HSV ${hsvString} copied`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,5 +399,21 @@ FocusScope {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: clipboardLoader
|
||||
anchors.fill: parent
|
||||
active: root.currentIndex === 23
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: ClipboardTab {}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item) {
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ FloatingWindow {
|
||||
title: I18n.tr("Settings", "settings window title")
|
||||
minimumSize: Qt.size(500, 400)
|
||||
implicitWidth: 800
|
||||
implicitHeight: 940
|
||||
implicitHeight: screen ? Math.min(940, screen.height - 100) : 940
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
|
||||
@@ -144,12 +144,6 @@ Rectangle {
|
||||
"tabIndex": 2,
|
||||
"shortcutsOnly": true
|
||||
},
|
||||
{
|
||||
"id": "displays",
|
||||
"text": I18n.tr("Displays"),
|
||||
"icon": "monitor",
|
||||
"tabIndex": 6
|
||||
},
|
||||
{
|
||||
"id": "network",
|
||||
"text": I18n.tr("Network"),
|
||||
@@ -157,6 +151,18 @@ Rectangle {
|
||||
"tabIndex": 7,
|
||||
"dmsOnly": true
|
||||
},
|
||||
{
|
||||
"id": "system",
|
||||
"text": I18n.tr("System"),
|
||||
"icon": "computer",
|
||||
"collapsedByDefault": true,
|
||||
"children": [
|
||||
{
|
||||
"id": "displays",
|
||||
"text": I18n.tr("Displays"),
|
||||
"icon": "monitor",
|
||||
"tabIndex": 6
|
||||
},
|
||||
{
|
||||
"id": "printers",
|
||||
"text": I18n.tr("Printers"),
|
||||
@@ -164,6 +170,15 @@ Rectangle {
|
||||
"tabIndex": 8,
|
||||
"cupsOnly": true
|
||||
},
|
||||
{
|
||||
"id": "clipboard",
|
||||
"text": I18n.tr("Clipboard"),
|
||||
"icon": "content_paste",
|
||||
"tabIndex": 23,
|
||||
"clipboardOnly": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "power_security",
|
||||
"text": I18n.tr("Power & Security"),
|
||||
@@ -213,6 +228,8 @@ Rectangle {
|
||||
return false;
|
||||
if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland)
|
||||
return false;
|
||||
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1088,6 +1088,7 @@ Item {
|
||||
id: layoutComponent
|
||||
|
||||
DWLLayout {
|
||||
id: layoutWidget
|
||||
layoutPopupVisible: layoutPopoutLoader.item ? layoutPopoutLoader.item.shouldBeVisible : false
|
||||
widgetThickness: barWindow.widgetThickness
|
||||
barThickness: barWindow.effectiveBarThickness
|
||||
@@ -1100,17 +1101,22 @@ Item {
|
||||
parentScreen: barWindow.screen
|
||||
onToggleLayoutPopup: {
|
||||
layoutPopoutLoader.active = true;
|
||||
if (!layoutPopoutLoader.item)
|
||||
return;
|
||||
const effectiveBarConfig = topBarContent.barConfig;
|
||||
const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1));
|
||||
if (layoutPopoutLoader.item && layoutPopoutLoader.item.setBarContext) {
|
||||
layoutPopoutLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0);
|
||||
|
||||
if (layoutPopoutLoader.item.setTriggerPosition) {
|
||||
const globalPos = layoutWidget.mapToGlobal(0, 0);
|
||||
const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, layoutWidget.width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig);
|
||||
const widgetSection = topBarContent.getWidgetSection(parent) || "center";
|
||||
layoutPopoutLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig);
|
||||
}
|
||||
if (layoutPopoutLoader.item) {
|
||||
|
||||
PopoutManager.requestPopout(layoutPopoutLoader.item, undefined, "layout");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: vpnComponent
|
||||
|
||||
@@ -106,7 +106,7 @@ BasePill {
|
||||
|
||||
AudioVisualization {
|
||||
anchors.fill: parent
|
||||
visible: CavaService.cavaAvailable
|
||||
visible: CavaService.cavaAvailable && SettingsData.audioVisualizerEnabled
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
@@ -114,7 +114,7 @@ BasePill {
|
||||
name: "music_note"
|
||||
size: 20
|
||||
color: Theme.primary
|
||||
visible: !CavaService.cavaAvailable
|
||||
visible: !CavaService.cavaAvailable || !SettingsData.audioVisualizerEnabled
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
@@ -185,7 +185,7 @@ BasePill {
|
||||
|
||||
AudioVisualization {
|
||||
anchors.fill: parent
|
||||
visible: CavaService.cavaAvailable
|
||||
visible: CavaService.cavaAvailable && SettingsData.audioVisualizerEnabled
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
@@ -193,7 +193,7 @@ BasePill {
|
||||
name: "music_note"
|
||||
size: 20
|
||||
color: Theme.primary
|
||||
visible: !CavaService.cavaAvailable
|
||||
visible: !CavaService.cavaAvailable || !SettingsData.audioVisualizerEnabled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Popup {
|
||||
@@ -99,13 +97,11 @@ Popup {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (processContextMenu.processData) {
|
||||
Quickshell.execDetached(["wl-copy", processContextMenu.processData.pid.toString()]);
|
||||
Quickshell.execDetached(["dms", "cl", "copy", processContextMenu.processData.pid.toString()]);
|
||||
}
|
||||
|
||||
processContextMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -133,12 +129,11 @@ Popup {
|
||||
onClicked: {
|
||||
if (processContextMenu.processData) {
|
||||
const processName = processContextMenu.processData.displayName || processContextMenu.processData.command;
|
||||
Quickshell.execDetached(["wl-copy", processName]);
|
||||
Quickshell.execDetached(["dms", "cl", "copy", processName]);
|
||||
}
|
||||
processContextMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -153,7 +148,6 @@ Popup {
|
||||
height: 1
|
||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -189,7 +183,6 @@ Popup {
|
||||
processContextMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -225,11 +218,7 @@ Popup {
|
||||
processContextMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
273
quickshell/Modules/Settings/ClipboardTab.qml
Normal file
273
quickshell/Modules/Settings/ClipboardTab.qml
Normal file
@@ -0,0 +1,273 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Settings.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var config: ({})
|
||||
property bool configLoaded: false
|
||||
property bool configError: false
|
||||
property bool saving: false
|
||||
|
||||
readonly property var maxHistoryOptions: [
|
||||
{ text: "25", value: 25 },
|
||||
{ text: "50", value: 50 },
|
||||
{ text: "100", value: 100 },
|
||||
{ text: "200", value: 200 },
|
||||
{ text: "500", value: 500 },
|
||||
{ text: "1000", value: 1000 }
|
||||
]
|
||||
|
||||
readonly property var maxEntrySizeOptions: [
|
||||
{ text: "1 MB", value: 1048576 },
|
||||
{ text: "2 MB", value: 2097152 },
|
||||
{ text: "5 MB", value: 5242880 },
|
||||
{ text: "10 MB", value: 10485760 },
|
||||
{ text: "20 MB", value: 20971520 },
|
||||
{ text: "50 MB", value: 52428800 }
|
||||
]
|
||||
|
||||
readonly property var autoClearOptions: [
|
||||
{ text: I18n.tr("Never"), value: 0 },
|
||||
{ text: I18n.tr("1 day"), value: 1 },
|
||||
{ text: I18n.tr("3 days"), value: 3 },
|
||||
{ text: I18n.tr("7 days"), value: 7 },
|
||||
{ text: I18n.tr("14 days"), value: 14 },
|
||||
{ text: I18n.tr("30 days"), value: 30 },
|
||||
{ text: I18n.tr("90 days"), value: 90 }
|
||||
]
|
||||
|
||||
function getMaxHistoryText(value) {
|
||||
for (let opt of maxHistoryOptions) {
|
||||
if (opt.value === value)
|
||||
return opt.text;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function getMaxEntrySizeText(value) {
|
||||
for (let opt of maxEntrySizeOptions) {
|
||||
if (opt.value === value)
|
||||
return opt.text;
|
||||
}
|
||||
const mb = Math.round(value / 1048576);
|
||||
return mb + " MB";
|
||||
}
|
||||
|
||||
function getAutoClearText(value) {
|
||||
for (let opt of autoClearOptions) {
|
||||
if (opt.value === value)
|
||||
return opt.text;
|
||||
}
|
||||
return value + " " + I18n.tr("days");
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
configLoaded = false;
|
||||
configError = false;
|
||||
DMSService.sendRequest("clipboard.getConfig", null, response => {
|
||||
if (response.error) {
|
||||
configError = true;
|
||||
return;
|
||||
}
|
||||
config = response.result || {};
|
||||
configLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
function saveConfig(key, value) {
|
||||
const params = {};
|
||||
params[key] = value;
|
||||
saving = true;
|
||||
DMSService.sendRequest("clipboard.setConfig", params, response => {
|
||||
saving = false;
|
||||
if (response.error) {
|
||||
ToastService.showError(I18n.tr("Failed to save clipboard setting"));
|
||||
return;
|
||||
}
|
||||
loadConfig();
|
||||
});
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (DMSService.isConnected)
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: DMSService
|
||||
function onIsConnectedChanged() {
|
||||
if (DMSService.isConnected)
|
||||
loadConfig();
|
||||
}
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentHeight: mainColumn.height + Theme.spacingXL
|
||||
contentWidth: width
|
||||
|
||||
Column {
|
||||
id: mainColumn
|
||||
width: Math.min(550, parent.width - Theme.spacingL * 2)
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Theme.spacingXL
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: warningContent.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12)
|
||||
visible: !DMSService.isConnected || configError
|
||||
|
||||
Row {
|
||||
id: warningContent
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankIcon {
|
||||
name: "info"
|
||||
size: Theme.iconSizeSmall
|
||||
color: Theme.warning
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
text: !DMSService.isConnected
|
||||
? I18n.tr("DMS service is not connected. Clipboard settings are unavailable.")
|
||||
: I18n.tr("Failed to load clipboard configuration.")
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width - Theme.iconSizeSmall - Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "history", "limit"]
|
||||
title: I18n.tr("History Settings")
|
||||
iconName: "history"
|
||||
visible: configLoaded
|
||||
|
||||
SettingsDropdownRow {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "history", "max", "limit"]
|
||||
settingKey: "maxHistory"
|
||||
text: I18n.tr("Maximum History")
|
||||
description: I18n.tr("Maximum number of clipboard entries to keep")
|
||||
currentValue: root.getMaxHistoryText(root.config.maxHistory ?? 100)
|
||||
options: root.maxHistoryOptions.map(opt => opt.text)
|
||||
onValueChanged: value => {
|
||||
for (let opt of root.maxHistoryOptions) {
|
||||
if (opt.text === value) {
|
||||
root.saveConfig("maxHistory", opt.value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "entry", "size", "limit"]
|
||||
settingKey: "maxEntrySize"
|
||||
text: I18n.tr("Maximum Entry Size")
|
||||
description: I18n.tr("Maximum size per clipboard entry")
|
||||
currentValue: root.getMaxEntrySizeText(root.config.maxEntrySize ?? 5242880)
|
||||
options: root.maxEntrySizeOptions.map(opt => opt.text)
|
||||
onValueChanged: value => {
|
||||
for (let opt of root.maxEntrySizeOptions) {
|
||||
if (opt.text === value) {
|
||||
root.saveConfig("maxEntrySize", opt.value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "auto", "clear", "days"]
|
||||
settingKey: "autoClearDays"
|
||||
text: I18n.tr("Auto-Clear After")
|
||||
description: I18n.tr("Automatically delete entries older than this")
|
||||
currentValue: root.getAutoClearText(root.config.autoClearDays ?? 0)
|
||||
options: root.autoClearOptions.map(opt => opt.text)
|
||||
onValueChanged: value => {
|
||||
for (let opt of root.autoClearOptions) {
|
||||
if (opt.text === value) {
|
||||
root.saveConfig("autoClearDays", opt.value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "behavior"]
|
||||
title: I18n.tr("Behavior")
|
||||
iconName: "settings"
|
||||
visible: configLoaded
|
||||
|
||||
SettingsToggleRow {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "clear", "startup"]
|
||||
settingKey: "clearAtStartup"
|
||||
text: I18n.tr("Clear at Startup")
|
||||
description: I18n.tr("Clear all history when server starts")
|
||||
checked: root.config.clearAtStartup ?? false
|
||||
onToggled: checked => root.saveConfig("clearAtStartup", checked)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "advanced", "disable"]
|
||||
title: I18n.tr("Advanced")
|
||||
iconName: "tune"
|
||||
collapsible: true
|
||||
expanded: false
|
||||
visible: configLoaded
|
||||
|
||||
SettingsToggleRow {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "disable", "manager"]
|
||||
settingKey: "disabled"
|
||||
text: I18n.tr("Disable Clipboard Manager")
|
||||
description: I18n.tr("Disable clipboard manager entirely (requires restart)")
|
||||
checked: root.config.disabled ?? false
|
||||
onToggled: checked => root.saveConfig("disabled", checked)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "disable", "history"]
|
||||
settingKey: "disableHistory"
|
||||
text: I18n.tr("Disable History Persistence")
|
||||
description: I18n.tr("Clipboard works but nothing saved to disk")
|
||||
checked: root.config.disableHistory ?? false
|
||||
onToggled: checked => root.saveConfig("disableHistory", checked)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "disable", "persist", "ownership"]
|
||||
settingKey: "disablePersist"
|
||||
text: I18n.tr("Disable Clipboard Ownership")
|
||||
description: I18n.tr("Don't preserve clipboard when apps close")
|
||||
checked: root.config.disablePersist ?? false
|
||||
onToggled: checked => root.saveConfig("disablePersist", checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user