mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-01 19:18:28 -04:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3420d2be93 | |||
| 405ea708b3 | |||
| cc0bec2682 | |||
| 240adfa28f | |||
| c175f4823a | |||
| 35613ebba8 | |||
| 583e46f2f0 | |||
| 590555dfc6 | |||
| c11169eb4c | |||
| bddcdb3f99 | |||
| 6ec67cefd0 | |||
| 8d9d7ff0ef | |||
| acc39ceb16 | |||
| 43863a86fb | |||
| 36ad34a555 | |||
| 78e823e23a | |||
| b6a27dc713 | |||
| 79dfd34ca2 | |||
| eedba0e8c9 | |||
| 2b12292895 | |||
| dd7f4f3ac5 | |||
| 3df801e3b2 | |||
| 74d220767d | |||
| a3fba8fcb6 | |||
| 33384e4d5d | |||
| 78f2ea4642 | |||
| 7dffe85e94 | |||
| c5c3105469 | |||
| 8f100960cf | |||
| 8a5e7c78fb | |||
| 8a85ab06a1 | |||
| 30ceda97a0 | |||
| fefc0afa6f | |||
| b01042d530 | |||
| 7355cc54b2 | |||
| 187b7ba8ff | |||
| c0e7c1b2b4 | |||
| ea0c235d6e | |||
| 57a04cf409 | |||
| fd99558ce5 | |||
| 81a4d3b4e0 | |||
| bfda5f2d66 | |||
| 65c4167ab2 | |||
| e7bd8e4002 | |||
| 25a1b125b7 | |||
| d766c0773c | |||
| 5d12962fd6 | |||
| 9911698237 | |||
| 7e095415a7 | |||
| 68d5d7c5ed | |||
| 7b1c542585 | |||
| 52ed719489 | |||
| 6e508d416d | |||
| 518cc93276 | |||
| 8cb4e21716 | |||
| 19b44b1782 | |||
| 0509694d78 | |||
| da44e07cf7 | |||
| b34941e3b8 | |||
| c554d973ef | |||
| 2861cc89c6 | |||
| 1c5965f1e4 | |||
| 66d552b3d5 | |||
| ebad8e2dda | |||
| 8dadf73d52 | |||
| 9f77b3127c | |||
| 46f8457777 | |||
| 8465ed4311 | |||
| 6e3e178721 | |||
| 27230227ef | |||
| 7178563c2c | |||
| dfe309a543 | |||
| 04962ffed1 | |||
| 5008406be8 | |||
| 930a36bcfc | |||
| 3253adb6dd | |||
| b8fd52e00f | |||
| 8e7126472e | |||
| 61108b7668 | |||
| 09ca37c9fc | |||
| a2513e939a | |||
| a73380b310 | |||
| 69d7c1fb13 | |||
| d1eb655e46 | |||
| a3ecef7627 | |||
| 186621ab81 | |||
| 31efe9b385 | |||
| 6cdb891551 | |||
| 3bb3da228c | |||
| 0c99e4b8d1 | |||
| 8b79d1dad3 | |||
| 85fa3326c1 | |||
| 2fade1a617 | |||
| 597ba597e5 | |||
| 957020c9ea | |||
| ae520620ac | |||
| 4c43bed2f8 |
@@ -28,6 +28,14 @@ repos:
|
||||
language: system
|
||||
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: i18n-term-freeze
|
||||
name: i18n term freeze (no new I18n.tr/qsTr terms)
|
||||
entry: python3 quickshell/translations/check_term_freeze.py
|
||||
language: system
|
||||
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: no-console-in-qml
|
||||
|
||||
@@ -77,6 +77,7 @@ install-desktop:
|
||||
@echo "Installing desktop entries..."
|
||||
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
|
||||
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
|
||||
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.notepad.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
|
||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||
@echo "Desktop entries installed"
|
||||
|
||||
@@ -120,6 +121,7 @@ uninstall-desktop:
|
||||
@echo "Removing desktop entries..."
|
||||
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop
|
||||
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
|
||||
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
|
||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||
@echo "Desktop entries removed"
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=DMS Notepad
|
||||
GenericName=Text Editor
|
||||
Comment=Quick notes and text files in the dank desktop shell
|
||||
Exec=dms ipc call notepad openFile %f
|
||||
Icon=danklogo
|
||||
Terminal=false
|
||||
NoDisplay=true
|
||||
Categories=Utility;TextEditor;
|
||||
MimeType=text/plain;text/markdown;
|
||||
StartupNotify=false
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=DMS Status Notifier Watcher (early tray)
|
||||
# The tray watcher starts in parallel with the compositor and owns org.kde.StatusNotifierWatcher before
|
||||
# graphical-session.target, i.e. before any XDG autostart app launches.
|
||||
PartOf=graphical-session.target
|
||||
Before=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=dbus
|
||||
BusName=org.kde.StatusNotifierWatcher
|
||||
ExecStart=/usr/bin/dms tray-watcher
|
||||
Restart=on-failure
|
||||
RestartSec=1
|
||||
Slice=session.slice
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@@ -63,6 +63,15 @@ var clipPasteCmd = &cobra.Command{
|
||||
Run: runClipPaste,
|
||||
}
|
||||
|
||||
var clipSendPasteCmd = &cobra.Command{
|
||||
Use: "send-paste",
|
||||
Short: "Send a paste keystroke to the focused window",
|
||||
Long: "Emulate ctrl+v (or ctrl+shift+v with --shift) via a virtual keyboard. Works without server.",
|
||||
Run: runClipSendPaste,
|
||||
}
|
||||
|
||||
var clipSendPasteShift bool
|
||||
|
||||
var clipWatchCmd = &cobra.Command{
|
||||
Use: "watch [command]",
|
||||
Short: "Watch clipboard for changes",
|
||||
@@ -216,8 +225,10 @@ func init() {
|
||||
|
||||
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
|
||||
|
||||
clipSendPasteCmd.Flags().BoolVarP(&clipSendPasteShift, "shift", "s", false, "Send ctrl+shift+v (terminal paste)")
|
||||
|
||||
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
|
||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
|
||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipSendPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
|
||||
}
|
||||
|
||||
func runClipCopy(cmd *cobra.Command, args []string) {
|
||||
@@ -314,6 +325,12 @@ func runClipPaste(cmd *cobra.Command, args []string) {
|
||||
os.Stdout.Write(data)
|
||||
}
|
||||
|
||||
func runClipSendPaste(cmd *cobra.Command, args []string) {
|
||||
if err := clipboard.SendPasteKeystroke(clipSendPasteShift); err != nil {
|
||||
log.Fatalf("send-paste: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runClipWatch(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
@@ -106,6 +106,8 @@ func init() {
|
||||
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
printIPCHelp()
|
||||
})
|
||||
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
|
||||
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
|
||||
}
|
||||
|
||||
var debugSrvCmd = &cobra.Command{
|
||||
@@ -184,10 +186,22 @@ var pluginsUninstallCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var pluginsUpdateCmd = &cobra.Command{
|
||||
Use: "update <plugin-id>",
|
||||
Short: "Update a plugin by ID",
|
||||
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "update [plugin-id]",
|
||||
Short: "Update a plugin by ID, or all plugins",
|
||||
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). If --all or -a is specified, all installed plugins will be updated.",
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
updateAll, _ := cmd.Flags().GetBool("all")
|
||||
if updateAll {
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("cannot specify plugin ID when using --all/-a")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("requires exactly 1 arg (plugin ID) or use --all/-a")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) != 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -195,6 +209,26 @@ var pluginsUpdateCmd = &cobra.Command{
|
||||
return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
checkOnly, _ := cmd.Flags().GetBool("check")
|
||||
updateAll, _ := cmd.Flags().GetBool("all")
|
||||
if checkOnly {
|
||||
if updateAll {
|
||||
if err := checkAllPluginsCLI(); err != nil {
|
||||
log.Fatalf("Error checking updates: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := checkPluginCLI(args[0]); err != nil {
|
||||
log.Fatalf("Error checking update: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if updateAll {
|
||||
if err := updateAllPluginsCLI(); err != nil {
|
||||
log.Fatalf("Error updating plugins: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := updatePluginCLI(args[0]); err != nil {
|
||||
log.Fatalf("Error updating plugin: %v", err)
|
||||
}
|
||||
@@ -370,7 +404,11 @@ func listInstalledPlugins() error {
|
||||
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
|
||||
for _, id := range installedNames {
|
||||
if plugin, ok := pluginMap[id]; ok {
|
||||
fmt.Printf(" %s\n", plugin.Name)
|
||||
hasUpdateStr := ""
|
||||
if hasUpdates, _, err := manager.HasUpdates(id, plugin); err == nil && hasUpdates {
|
||||
hasUpdateStr = " (update available)"
|
||||
}
|
||||
fmt.Printf(" %s%s\n", plugin.Name, hasUpdateStr)
|
||||
fmt.Printf(" ID: %s\n", plugin.ID)
|
||||
fmt.Printf(" Category: %s\n", plugin.Category)
|
||||
fmt.Printf(" Author: %s\n", plugin.Author)
|
||||
@@ -550,6 +588,160 @@ func updatePluginCLI(idOrName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAllPluginsCLI() error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry: %w", err)
|
||||
}
|
||||
|
||||
installed, err := manager.ListInstalled()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list installed plugins: %w", err)
|
||||
}
|
||||
|
||||
pluginList, _ := registry.List()
|
||||
|
||||
var errs []error
|
||||
for _, pluginID := range installed {
|
||||
plugin := plugins.FindByIDOrName(pluginID, pluginList)
|
||||
if plugin != nil {
|
||||
fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
|
||||
if err := manager.Update(*plugin); err != nil {
|
||||
if strings.Contains(err.Error(), "cannot update system plugin") {
|
||||
fmt.Printf("Skipping system plugin: %s\n", plugin.Name)
|
||||
} else {
|
||||
errs = append(errs, fmt.Errorf("failed to update %s: %w", plugin.Name, err))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Plugin updated successfully: %s\n", plugin.Name)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Updating plugin: %s\n", pluginID)
|
||||
if err := manager.UpdateByIDOrName(pluginID); err != nil {
|
||||
if strings.Contains(err.Error(), "cannot update system plugin") {
|
||||
fmt.Printf("Skipping system plugin: %s\n", pluginID)
|
||||
} else {
|
||||
errs = append(errs, fmt.Errorf("failed to update %s: %w", pluginID, err))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Plugin updated successfully: %s\n", pluginID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
for _, err := range errs {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
}
|
||||
return fmt.Errorf("failed to update some plugins")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkPluginCLI(idOrName string) error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry: %w", err)
|
||||
}
|
||||
|
||||
pluginList, _ := registry.List()
|
||||
plugin := plugins.FindByIDOrName(idOrName, pluginList)
|
||||
|
||||
if plugin != nil {
|
||||
installed, err := manager.IsInstalled(*plugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check install status: %w", err)
|
||||
}
|
||||
if !installed {
|
||||
return fmt.Errorf("plugin not installed: %s", plugin.Name)
|
||||
}
|
||||
|
||||
hasUpdates, _, err := manager.HasUpdates(plugin.ID, *plugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check updates: %w", err)
|
||||
}
|
||||
|
||||
if hasUpdates {
|
||||
fmt.Printf("Update available for plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
|
||||
} else {
|
||||
fmt.Printf("Plugin is up to date: %s\n", plugin.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
dummyPlugin := plugins.Plugin{ID: idOrName}
|
||||
hasUpdates, _, err := manager.HasUpdates(idOrName, dummyPlugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check updates: %w", err)
|
||||
}
|
||||
|
||||
if hasUpdates {
|
||||
fmt.Printf("Update available for plugin: %s\n", idOrName)
|
||||
} else {
|
||||
fmt.Printf("Plugin is up to date: %s\n", idOrName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkAllPluginsCLI() error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry: %w", err)
|
||||
}
|
||||
|
||||
installed, err := manager.ListInstalled()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list installed plugins: %w", err)
|
||||
}
|
||||
|
||||
pluginList, _ := registry.List()
|
||||
|
||||
var count int
|
||||
for _, pluginID := range installed {
|
||||
plugin := plugins.FindByIDOrName(pluginID, pluginList)
|
||||
var hasUpdates bool
|
||||
var name string
|
||||
|
||||
if plugin != nil {
|
||||
name = plugin.Name
|
||||
hasUpdates, _, _ = manager.HasUpdates(pluginID, *plugin)
|
||||
} else {
|
||||
name = pluginID
|
||||
dummyPlugin := plugins.Plugin{ID: pluginID}
|
||||
hasUpdates, _, _ = manager.HasUpdates(pluginID, dummyPlugin)
|
||||
}
|
||||
|
||||
if hasUpdates {
|
||||
fmt.Printf("Update available for plugin: %s (ID: %s)\n", name, pluginID)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
fmt.Printf("\nFound %d plugin(s) with available updates.\n", count)
|
||||
} else {
|
||||
fmt.Println("All plugins are up to date.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCommonCommands() []*cobra.Command {
|
||||
return []*cobra.Command{
|
||||
versionCmd,
|
||||
@@ -582,5 +774,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
trashCmd,
|
||||
systemCmd,
|
||||
switchUserCmd,
|
||||
trayWatcherCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +38,17 @@ var matugenCheckCmd = &cobra.Command{
|
||||
Run: runMatugenCheck,
|
||||
}
|
||||
|
||||
var matugenPreviewCmd = &cobra.Command{
|
||||
Use: "preview",
|
||||
Short: "Preview Matugen scheme colors without applying them",
|
||||
Run: runMatugenPreview,
|
||||
}
|
||||
|
||||
func init() {
|
||||
matugenCmd.AddCommand(matugenGenerateCmd)
|
||||
matugenCmd.AddCommand(matugenQueueCmd)
|
||||
matugenCmd.AddCommand(matugenCheckCmd)
|
||||
matugenCmd.AddCommand(matugenPreviewCmd)
|
||||
|
||||
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
|
||||
cmd.Flags().String("state-dir", "", "State directory for cache files")
|
||||
@@ -62,6 +69,8 @@ func init() {
|
||||
|
||||
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
|
||||
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
|
||||
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
|
||||
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
|
||||
}
|
||||
|
||||
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
@@ -200,3 +209,17 @@ func runMatugenCheck(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
func runMatugenPreview(cmd *cobra.Command, args []string) {
|
||||
sourceColor, _ := cmd.Flags().GetString("source-color")
|
||||
contrast, _ := cmd.Flags().GetFloat64("contrast")
|
||||
previews, err := matugen.PreviewSchemes(sourceColor, contrast)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate Matugen previews: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(previews)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal Matugen previews: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/traywatcher"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var trayWatcherCmd = &cobra.Command{
|
||||
Use: "tray-watcher",
|
||||
Short: "Run a minimal StatusNotifierWatcher for early tray registration",
|
||||
Long: `Run a minimal org.kde.StatusNotifierWatcher daemon.
|
||||
|
||||
Started early in the session (Before=graphical-session.target via
|
||||
dms-tray-watcher.service), it lets tray apps launched by XDG autostart
|
||||
register their items before the shell finishes loading, and keeps them
|
||||
registered across shell restarts. The shell's tray host picks items up from
|
||||
this watcher; if it is not running, the shell's built-in watcher takes over.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := traywatcher.Run(); err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -582,7 +582,11 @@ func runShellDaemon(session bool) {
|
||||
}
|
||||
|
||||
var qsHasAnyDisplay = sync.OnceValue(func() bool {
|
||||
out, err := exec.Command("qs", "ipc", "--help").Output()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "qs", "ipc", "--help")
|
||||
cmd.WaitDelay = 500 * time.Millisecond
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -642,7 +646,10 @@ func getShellIPCCompletions(args []string, _ string) []string {
|
||||
return nil
|
||||
}
|
||||
cmdArgs := append(baseArgs, "show")
|
||||
cmd := exec.Command("qs", cmdArgs...)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "qs", cmdArgs...)
|
||||
cmd.WaitDelay = 500 * time.Millisecond
|
||||
var targets ipcTargets
|
||||
|
||||
if output, err := cmd.Output(); err == nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
const envServe = "_DMS_CLIPBOARD_SERVE"
|
||||
@@ -17,6 +16,37 @@ const envMime = "_DMS_CLIPBOARD_MIME"
|
||||
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
|
||||
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
|
||||
|
||||
type Offer struct {
|
||||
MimeType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// textMimeAliases are offered alongside plain-text content so legacy X11
|
||||
// clients bridged through XWayland find a target they can convert.
|
||||
var textMimeAliases = []string{
|
||||
"text/plain",
|
||||
"text/plain;charset=utf-8",
|
||||
"UTF8_STRING",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
}
|
||||
|
||||
// ExpandOffers turns raw clipboard data into the full offer list to serve,
|
||||
// adding the standard alias set for text content.
|
||||
func ExpandOffers(data []byte, mimeType string) []Offer {
|
||||
offers := []Offer{{MimeType: mimeType, Data: data}}
|
||||
if mimeType != "text/plain" && mimeType != "text/plain;charset=utf-8" {
|
||||
return offers
|
||||
}
|
||||
for _, alias := range textMimeAliases {
|
||||
if alias == mimeType {
|
||||
continue
|
||||
}
|
||||
offers = append(offers, Offer{MimeType: alias, Data: data})
|
||||
}
|
||||
return offers
|
||||
}
|
||||
|
||||
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
|
||||
// child. Reads source data into memory, deletes any cache file, then serves.
|
||||
func MaybeServeAndExit() {
|
||||
@@ -44,7 +74,7 @@ func MaybeServeAndExit() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
|
||||
if err := serveOffers(ExpandOffers(data, mimeType), pasteOnce); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -55,22 +85,33 @@ func Copy(data []byte, mimeType string) error {
|
||||
return copyForkCached(data, mimeType, false)
|
||||
}
|
||||
|
||||
func CopyText(text string) error {
|
||||
return Copy([]byte(text), "text/plain;charset=utf-8")
|
||||
}
|
||||
|
||||
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
return serveClipboard(data, mimeType, pasteOnce)
|
||||
return serveOffers(ExpandOffers(data, mimeType), pasteOnce)
|
||||
}
|
||||
return copyForkCached(data, mimeType, pasteOnce)
|
||||
}
|
||||
|
||||
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
buf, err := io.ReadAll(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
return serveClipboard(buf, mimeType, pasteOnce)
|
||||
if !foreground {
|
||||
return copyFork(data, mimeType, pasteOnce)
|
||||
}
|
||||
return copyFork(data, mimeType, pasteOnce)
|
||||
buf, err := io.ReadAll(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
return serveOffers(ExpandOffers(buf, mimeType), pasteOnce)
|
||||
}
|
||||
|
||||
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
return serveOffers(offers, pasteOnce)
|
||||
}
|
||||
return copyMultiFork(offers, pasteOnce)
|
||||
}
|
||||
|
||||
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
|
||||
@@ -132,39 +173,70 @@ func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
|
||||
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||
cmd := newForkCmd(mimeType, pasteOnce)
|
||||
|
||||
switch src := data.(type) {
|
||||
case *os.File:
|
||||
if src, ok := data.(*os.File); ok {
|
||||
cmd.Stdin = src
|
||||
return waitReady(cmd)
|
||||
|
||||
default:
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(stdin, data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
if err := stdin.Close(); err != nil {
|
||||
return fmt.Errorf("close stdin: %w", err)
|
||||
}
|
||||
|
||||
var buf [1]byte
|
||||
if _, err := stdout.Read(buf[:]); err != nil {
|
||||
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(stdin, data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
if err := stdin.Close(); err != nil {
|
||||
return fmt.Errorf("close stdin: %w", err)
|
||||
}
|
||||
|
||||
var buf [1]byte
|
||||
if _, err := stdout.Read(buf[:]); err != nil {
|
||||
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMultiFork(offers []Offer, pasteOnce bool) error {
|
||||
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
|
||||
if pasteOnce {
|
||||
args = append(args, "--paste-once")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
for _, offer := range offers {
|
||||
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
|
||||
if _, err := stdin.Write(offer.Data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write offer data: %w", err)
|
||||
}
|
||||
}
|
||||
stdin.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func signalReady() {
|
||||
@@ -194,57 +266,25 @@ func createClipboardCacheFile() (*os.File, error) {
|
||||
return os.CreateTemp("", "dms-clipboard-*")
|
||||
}
|
||||
|
||||
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
display, err := wlclient.Connect("")
|
||||
// serveOffers owns the Wayland selection until cancelled (or first paste when
|
||||
// pasteOnce is set), answering every offered mime type with its data.
|
||||
func serveOffers(offers []Offer, pasteOnce bool) error {
|
||||
if len(offers) == 0 {
|
||||
return fmt.Errorf("no offers to serve")
|
||||
}
|
||||
|
||||
s, err := connectSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wayland connect: %w", err)
|
||||
return err
|
||||
}
|
||||
defer display.Destroy()
|
||||
defer s.Close()
|
||||
|
||||
ctx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
dataControlMgr, err := s.requireDataControl()
|
||||
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")
|
||||
return err
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(seat)
|
||||
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
@@ -255,25 +295,12 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
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)
|
||||
offerData := make(map[string][]byte, len(offers))
|
||||
for _, offer := range offers {
|
||||
if err := source.Offer(offer.MimeType); err != nil {
|
||||
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
|
||||
}
|
||||
offerData[offer.MimeType] = offer.Data
|
||||
}
|
||||
|
||||
cancelled := make(chan struct{})
|
||||
@@ -283,7 +310,11 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
_ = syscall.SetNonblock(e.Fd, false)
|
||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||
defer file.Close()
|
||||
_, _ = file.Write(data)
|
||||
|
||||
if data, ok := offerData[e.MimeType]; ok {
|
||||
_, _ = file.Write(data)
|
||||
}
|
||||
|
||||
select {
|
||||
case pasted <- struct{}{}:
|
||||
default:
|
||||
@@ -298,7 +329,7 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
return fmt.Errorf("set selection: %w", err)
|
||||
}
|
||||
|
||||
display.Roundtrip()
|
||||
s.display.Roundtrip()
|
||||
signalReady()
|
||||
|
||||
for {
|
||||
@@ -310,70 +341,26 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
if err := ctx.Dispatch(); err != nil {
|
||||
if err := s.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("")
|
||||
s, err := connectSession()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("wayland connect: %w", err)
|
||||
return nil, "", err
|
||||
}
|
||||
defer display.Destroy()
|
||||
defer s.Close()
|
||||
|
||||
ctx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
dataControlMgr, err := s.requireDataControl()
|
||||
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)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
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)
|
||||
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
@@ -399,15 +386,14 @@ func Paste() ([]byte, string, error) {
|
||||
gotSelection = true
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
s.display.Roundtrip()
|
||||
s.display.Roundtrip()
|
||||
|
||||
if !gotSelection || selectionOffer == nil {
|
||||
return nil, "", fmt.Errorf("no clipboard data")
|
||||
}
|
||||
|
||||
mimeTypes := offerMimeTypes[selectionOffer]
|
||||
selectedMime := selectPreferredMimeType(mimeTypes)
|
||||
selectedMime := selectPreferredMimeType(offerMimeTypes[selectionOffer])
|
||||
if selectedMime == "" {
|
||||
return nil, "", fmt.Errorf("no supported mime type")
|
||||
}
|
||||
@@ -424,7 +410,7 @@ func Paste() ([]byte, string, error) {
|
||||
}
|
||||
w.Close()
|
||||
|
||||
display.Roundtrip()
|
||||
s.display.Roundtrip()
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
@@ -470,161 +456,3 @@ func selectPreferredMimeType(mimes []string) string {
|
||||
func IsImageMimeType(mime string) bool {
|
||||
return len(mime) > 6 && mime[:6] == "image/"
|
||||
}
|
||||
|
||||
type Offer struct {
|
||||
MimeType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
|
||||
if !foreground {
|
||||
return copyMultiFork(offers, pasteOnce)
|
||||
}
|
||||
return copyMultiServe(offers, pasteOnce)
|
||||
}
|
||||
|
||||
func copyMultiFork(offers []Offer, pasteOnce bool) error {
|
||||
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
|
||||
if pasteOnce {
|
||||
args = append(args, "--paste-once")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
for _, offer := range offers {
|
||||
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
|
||||
if _, err := stdin.Write(offer.Data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write offer data: %w", err)
|
||||
}
|
||||
}
|
||||
stdin.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMultiServe(offers []Offer, 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)
|
||||
}
|
||||
|
||||
offerMap := make(map[string][]byte)
|
||||
for _, offer := range offers {
|
||||
if err := source.Offer(offer.MimeType); err != nil {
|
||||
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
|
||||
}
|
||||
offerMap[offer.MimeType] = offer.Data
|
||||
}
|
||||
|
||||
cancelled := make(chan struct{})
|
||||
pasted := make(chan struct{}, 1)
|
||||
|
||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||
_ = syscall.SetNonblock(e.Fd, false)
|
||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||
defer file.Close()
|
||||
|
||||
if data, ok := offerMap[e.MimeType]; ok {
|
||||
_, _ = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
xkbKeymapFormatV1 = 1
|
||||
|
||||
keyStateReleased = 0
|
||||
keyStatePressed = 1
|
||||
|
||||
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
|
||||
shiftModMask = 1 << 0
|
||||
ctrlModMask = 1 << 2
|
||||
|
||||
// evdev fallbacks for a standard pc105 map
|
||||
fallbackCtrlKey = 29 // KEY_LEFTCTRL
|
||||
fallbackShiftKey = 42 // KEY_LEFTSHIFT
|
||||
fallbackVKey = 47 // KEY_V
|
||||
)
|
||||
|
||||
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
|
||||
// using the seat's own keymap, so keycodes stay valid for XWayland clients
|
||||
// (a synthetic wtype-style keymap breaks X11 apps like Steam). withShift
|
||||
// selects ctrl+shift+v for terminal targets.
|
||||
func SendPasteKeystroke(withShift bool) error {
|
||||
s, err := connectSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if s.virtualKeyboardMgr == nil {
|
||||
return fmt.Errorf("compositor does not support zwp_virtual_keyboard_manager_v1")
|
||||
}
|
||||
if s.seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
keyboard, err := s.seat.GetKeyboard()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get keyboard: %w", err)
|
||||
}
|
||||
defer keyboard.Release()
|
||||
|
||||
var keymap *wlclient.KeyboardKeymapEvent
|
||||
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||
if keymap == nil {
|
||||
keymap = &e
|
||||
}
|
||||
})
|
||||
|
||||
s.display.Roundtrip()
|
||||
|
||||
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
|
||||
return fmt.Errorf("no xkb keymap from seat")
|
||||
}
|
||||
defer unix.Close(keymap.Fd)
|
||||
|
||||
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read keymap: %w", err)
|
||||
}
|
||||
keys := resolveKeycodes(keymapText)
|
||||
|
||||
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create virtual keyboard: %w", err)
|
||||
}
|
||||
defer vk.Destroy()
|
||||
|
||||
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
|
||||
return fmt.Errorf("set keymap: %w", err)
|
||||
}
|
||||
|
||||
mods := uint32(ctrlModMask)
|
||||
held := []uint32{keys.ctrl}
|
||||
if withShift {
|
||||
mods |= shiftModMask
|
||||
held = append(held, keys.shift)
|
||||
}
|
||||
|
||||
t := uint32(0)
|
||||
press := func(key, state uint32) error {
|
||||
t++
|
||||
return vk.Key(t, key, state)
|
||||
}
|
||||
|
||||
for _, key := range held {
|
||||
if err := press(key, keyStatePressed); err != nil {
|
||||
return fmt.Errorf("key press: %w", err)
|
||||
}
|
||||
}
|
||||
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
|
||||
return fmt.Errorf("set modifiers: %w", err)
|
||||
}
|
||||
if err := press(keys.v, keyStatePressed); err != nil {
|
||||
return fmt.Errorf("key press: %w", err)
|
||||
}
|
||||
if err := press(keys.v, keyStateReleased); err != nil {
|
||||
return fmt.Errorf("key release: %w", err)
|
||||
}
|
||||
for i := len(held) - 1; i >= 0; i-- {
|
||||
if err := press(held[i], keyStateReleased); err != nil {
|
||||
return fmt.Errorf("key release: %w", err)
|
||||
}
|
||||
}
|
||||
if err := vk.Modifiers(0, 0, 0, 0); err != nil {
|
||||
return fmt.Errorf("clear modifiers: %w", err)
|
||||
}
|
||||
|
||||
s.display.Roundtrip()
|
||||
return nil
|
||||
}
|
||||
|
||||
func readKeymap(fd int, size uint32) (string, error) {
|
||||
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := strings.TrimRight(string(data), "\x00")
|
||||
return text, unix.Munmap(data)
|
||||
}
|
||||
|
||||
type pasteKeycodes struct {
|
||||
ctrl uint32
|
||||
shift uint32
|
||||
v uint32
|
||||
}
|
||||
|
||||
var (
|
||||
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
|
||||
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
|
||||
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
|
||||
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
|
||||
)
|
||||
|
||||
// xkbcommon may serialize keysyms as hex escapes instead of names
|
||||
// (e.g. "0x76" for v, "0xffe3" for Control_L).
|
||||
var keysymNames = map[uint32]string{
|
||||
0x76: "v",
|
||||
0xffe3: "Control_L",
|
||||
0xffe1: "Shift_L",
|
||||
}
|
||||
|
||||
func canonicalKeysym(sym string) string {
|
||||
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
|
||||
return sym
|
||||
}
|
||||
value, err := strconv.ParseUint(sym[2:], 16, 32)
|
||||
if err != nil {
|
||||
return sym
|
||||
}
|
||||
if name, ok := keysymNames[uint32(value)]; ok {
|
||||
return name
|
||||
}
|
||||
return sym
|
||||
}
|
||||
|
||||
// resolveKeycodes finds the evdev keycodes producing the keysyms we need in
|
||||
// the seat keymap's first group, falling back to pc105 positions.
|
||||
func resolveKeycodes(keymap string) pasteKeycodes {
|
||||
codes := map[string]uint32{}
|
||||
for _, m := range keycodeDefRe.FindAllStringSubmatch(keymap, -1) {
|
||||
if code, err := strconv.Atoi(m[2]); err == nil {
|
||||
codes[m[1]] = uint32(code)
|
||||
}
|
||||
}
|
||||
|
||||
keys := pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}
|
||||
want := map[string]*uint32{
|
||||
"Control_L": &keys.ctrl,
|
||||
"Shift_L": &keys.shift,
|
||||
"v": &keys.v,
|
||||
}
|
||||
|
||||
for _, m := range keySymbolsRe.FindAllStringSubmatch(keymap, -1) {
|
||||
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
|
||||
if group == nil {
|
||||
continue
|
||||
}
|
||||
xkbCode, ok := codes[m[1]]
|
||||
if !ok || xkbCode < 8 {
|
||||
continue
|
||||
}
|
||||
level1 := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
|
||||
target, wanted := want[level1]
|
||||
if !wanted {
|
||||
continue
|
||||
}
|
||||
*target = xkbCode - 8
|
||||
delete(want, level1)
|
||||
if len(want) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestLiveSeatKeymapResolution(t *testing.T) {
|
||||
if os.Getenv("DMS_LIVE_TEST") == "" {
|
||||
t.Skip("set DMS_LIVE_TEST=1 to run against the live compositor")
|
||||
}
|
||||
|
||||
s, err := connectSession()
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if s.virtualKeyboardMgr == nil {
|
||||
t.Fatal("compositor does not advertise zwp_virtual_keyboard_manager_v1")
|
||||
}
|
||||
if s.seat == nil {
|
||||
t.Fatal("no seat")
|
||||
}
|
||||
|
||||
keyboard, err := s.seat.GetKeyboard()
|
||||
if err != nil {
|
||||
t.Fatalf("get keyboard: %v", err)
|
||||
}
|
||||
defer keyboard.Release()
|
||||
|
||||
var keymap *wlclient.KeyboardKeymapEvent
|
||||
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||
if keymap == nil {
|
||||
keymap = &e
|
||||
}
|
||||
})
|
||||
s.display.Roundtrip()
|
||||
|
||||
if keymap == nil {
|
||||
t.Fatal("no keymap event")
|
||||
}
|
||||
defer unix.Close(keymap.Fd)
|
||||
|
||||
text, err := readKeymap(keymap.Fd, keymap.Size)
|
||||
if err != nil {
|
||||
t.Fatalf("read keymap: %v", err)
|
||||
}
|
||||
|
||||
if dump := os.Getenv("DMS_LIVE_DUMP"); dump != "" {
|
||||
if err := os.WriteFile(dump, []byte(text), 0o644); err != nil {
|
||||
t.Fatalf("dump keymap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
keys := resolveKeycodes(text)
|
||||
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
|
||||
|
||||
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
|
||||
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package clipboard
|
||||
|
||||
import "testing"
|
||||
|
||||
const qwertyKeymap = `xkb_keymap {
|
||||
xkb_keycodes "(unnamed)" {
|
||||
minimum = 8;
|
||||
maximum = 708;
|
||||
<ESC> = 9;
|
||||
<AB04> = 55;
|
||||
<LCTL> = 37;
|
||||
<LFSH> = 50;
|
||||
alias <AL01> = <AC01>;
|
||||
indicator 1 = "Caps Lock";
|
||||
};
|
||||
xkb_types "(unnamed)" {
|
||||
type "ALPHABETIC" {
|
||||
modifiers = Shift+Lock;
|
||||
map[Shift] = Level2;
|
||||
level_name[Level1] = "Base";
|
||||
};
|
||||
};
|
||||
xkb_symbols "(unnamed)" {
|
||||
key <ESC> { [ Escape ] };
|
||||
key <AB04> { type= "ALPHABETIC", [ v, V ] };
|
||||
key <LCTL> { [ Control_L ] };
|
||||
key <LFSH> { [ Shift_L ] };
|
||||
};
|
||||
};`
|
||||
|
||||
const hexKeymap = `xkb_keymap {
|
||||
xkb_keycodes "(unnamed)" {
|
||||
<AB04> = 56;
|
||||
<LCTL> = 38;
|
||||
<LFSH> = 51;
|
||||
};
|
||||
xkb_symbols "(unnamed)" {
|
||||
key <LCTL> { [ 0xffe3 ] };
|
||||
key <LFSH> {
|
||||
type= "PC_ALT_LEVEL2",
|
||||
symbols[1]= [ 0xffe1, 0xfe08 ]
|
||||
};
|
||||
key <AB04> { [ 0x76, 0x56 ] };
|
||||
};
|
||||
};`
|
||||
|
||||
const dvorakKeymap = `xkb_keymap {
|
||||
xkb_keycodes "(unnamed)" {
|
||||
<AB09> = 60;
|
||||
<LCTL> = 37;
|
||||
<LFSH> = 50;
|
||||
};
|
||||
xkb_symbols "(unnamed)" {
|
||||
key <AB09> { [ v, V ] };
|
||||
key <LCTL> { [ Control_L ] };
|
||||
key <LFSH> { [ Shift_L ] };
|
||||
};
|
||||
};`
|
||||
|
||||
func TestResolveKeycodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keymap string
|
||||
want pasteKeycodes
|
||||
}{
|
||||
{"qwerty", qwertyKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 47}},
|
||||
{"dvorak", dvorakKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 52}},
|
||||
{"hex keysyms", hexKeymap, pasteKeycodes{ctrl: 30, shift: 43, v: 48}},
|
||||
{"empty falls back", "", pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveKeycodes(tt.keymap)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveKeycodes() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandOffers(t *testing.T) {
|
||||
text := ExpandOffers([]byte("hi"), "text/plain;charset=utf-8")
|
||||
if len(text) != 5 {
|
||||
t.Fatalf("expected 5 text offers, got %d", len(text))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, o := range text {
|
||||
if string(o.Data) != "hi" {
|
||||
t.Errorf("offer %s has wrong data", o.MimeType)
|
||||
}
|
||||
if seen[o.MimeType] {
|
||||
t.Errorf("duplicate offer %s", o.MimeType)
|
||||
}
|
||||
seen[o.MimeType] = true
|
||||
}
|
||||
if !seen["UTF8_STRING"] || !seen["STRING"] || !seen["TEXT"] || !seen["text/plain"] {
|
||||
t.Errorf("missing X11 alias offers: %v", seen)
|
||||
}
|
||||
|
||||
img := ExpandOffers([]byte{1}, "image/png")
|
||||
if len(img) != 1 || img[0].MimeType != "image/png" {
|
||||
t.Errorf("non-text mime should not expand, got %+v", img)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type session struct {
|
||||
display *wlclient.Display
|
||||
ctx *wlclient.Context
|
||||
registry *wlclient.Registry
|
||||
seat *wlclient.Seat
|
||||
dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
virtualKeyboardMgr *virtual_keyboard.ZwpVirtualKeyboardManagerV1
|
||||
}
|
||||
|
||||
// connectSession opens a short-lived Wayland connection and binds the seat
|
||||
// plus whichever clipboard-related globals the compositor advertises.
|
||||
func connectSession() (*session, error) {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
|
||||
s := &session{display: display, ctx: display.Context()}
|
||||
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
display.Destroy()
|
||||
return nil, fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
s.registry = registry
|
||||
|
||||
var bindErr error
|
||||
bind := func(name uint32, iface string, version uint32, proxy wlclient.Proxy) {
|
||||
if err := registry.Bind(name, iface, version, proxy); err != nil {
|
||||
bindErr = fmt.Errorf("bind %s: %w", iface, err)
|
||||
}
|
||||
}
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case ext_data_control.ExtDataControlManagerV1InterfaceName:
|
||||
mgr := ext_data_control.NewExtDataControlManagerV1(s.ctx)
|
||||
bind(e.Name, e.Interface, e.Version, mgr)
|
||||
s.dataControlMgr = mgr
|
||||
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
|
||||
mgr := virtual_keyboard.NewZwpVirtualKeyboardManagerV1(s.ctx)
|
||||
bind(e.Name, e.Interface, e.Version, mgr)
|
||||
s.virtualKeyboardMgr = mgr
|
||||
case "wl_seat":
|
||||
if s.seat != nil {
|
||||
return
|
||||
}
|
||||
seat := wlclient.NewSeat(s.ctx)
|
||||
bind(e.Name, e.Interface, e.Version, seat)
|
||||
s.seat = seat
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
s.Close()
|
||||
return nil, bindErr
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *session) requireDataControl() (*ext_data_control.ExtDataControlManagerV1, error) {
|
||||
switch {
|
||||
case s.dataControlMgr == nil:
|
||||
return nil, fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
case s.seat == nil:
|
||||
return nil, fmt.Errorf("no seat available")
|
||||
default:
|
||||
return s.dataControlMgr, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) Close() {
|
||||
if s.dataControlMgr != nil {
|
||||
s.dataControlMgr.Destroy()
|
||||
}
|
||||
if s.registry != nil {
|
||||
s.registry.Destroy()
|
||||
}
|
||||
s.display.Destroy()
|
||||
}
|
||||
@@ -880,8 +880,9 @@ func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalComma
|
||||
|
||||
config = regexp.MustCompile(`environment \{[^}]*\}`).ReplaceAllString(config, envVars)
|
||||
|
||||
spawnDms := `spawn-at-startup "dms" "run"`
|
||||
if !strings.Contains(config, spawnDms) {
|
||||
// Watcher spawns first so it owns the SNI name before dms/tray apps start.
|
||||
spawnDms := "spawn-at-startup \"dms\" \"tray-watcher\"\nspawn-at-startup \"dms\" \"run\""
|
||||
if !strings.Contains(config, `spawn-at-startup "dms" "run"`) {
|
||||
// Insert spawn-at-startup for dms after the environment block
|
||||
envBlockEnd := regexp.MustCompile(`environment \{[^}]*\}`)
|
||||
if loc := envBlockEnd.FindStringIndex(config); loc != nil {
|
||||
|
||||
@@ -520,9 +520,21 @@ func TestHyprlandConfigStructure(t *testing.T) {
|
||||
assert.Contains(t, HyprlandLuaConfig, "input =")
|
||||
}
|
||||
|
||||
// In non-systemd mode dms is launched from the compositor config, so the tray
|
||||
// watcher must be launched there too, before dms, to own the SNI name first.
|
||||
func TestNonSystemdLaunchesTrayWatcherBeforeShell(t *testing.T) {
|
||||
hypr := transformHyprlandLuaForNonSystemd(HyprlandLuaConfig, "ghostty")
|
||||
assert.Contains(t, hypr, "hl.exec_cmd(\"dms tray-watcher\")\n\thl.exec_cmd(\"dms run\")")
|
||||
|
||||
niri := (&ConfigDeployer{}).transformNiriConfigForNonSystemd(NiriConfig, "ghostty")
|
||||
assert.Contains(t, niri, "spawn-at-startup \"dms\" \"tray-watcher\"\nspawn-at-startup \"dms\" \"run\"")
|
||||
}
|
||||
|
||||
func TestMangoConfigStructure(t *testing.T) {
|
||||
assert.Contains(t, MangoConfig, "exec-once=dms run")
|
||||
assert.NotContains(t, MangoConfig, "exec_once=dms run")
|
||||
// Tray watcher must start before the shell so it owns the SNI name first.
|
||||
assert.Contains(t, MangoConfig, "exec-once=dms tray-watcher\nexec-once=dms run")
|
||||
assert.Contains(t, MangoConfig, "source=./dms/binds.conf")
|
||||
assert.Contains(t, MangoBindsConfig, "bind=SUPER,H,focusdir,left")
|
||||
assert.Contains(t, MangoBindsConfig, "bind=SUPER,J,focusdir,down")
|
||||
|
||||
@@ -7,7 +7,9 @@ env=XDG_SESSION_TYPE,wayland
|
||||
|
||||
# exec-once runs only at startup. Do NOT use exec= for the shell: mango re-runs
|
||||
# every exec= on each config reload, and DMS reloads the config, which would
|
||||
# spawn a new shell on every reload.
|
||||
# spawn a new shell on every reload. The tray watcher starts first so it owns
|
||||
# the SNI name before dms and the tray apps come up.
|
||||
exec-once=dms tray-watcher
|
||||
exec-once=dms run
|
||||
|
||||
source=./dms/colors.conf
|
||||
|
||||
@@ -116,6 +116,7 @@ func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
|
||||
`hl.env("QT_QPA_PLATFORMTHEME_QT6", "gtk3")` + "\n" +
|
||||
fmt.Sprintf(`hl.env("TERMINAL", %s)`, strconv.Quote(terminalCommand)) + "\n\n" +
|
||||
`hl.on("hyprland.start", function()` + "\n" +
|
||||
` hl.exec_cmd("dms tray-watcher")` + "\n" +
|
||||
` hl.exec_cmd("dms run")` + "\n" +
|
||||
`end)` + "\n" +
|
||||
hyprlandStartupEnd
|
||||
|
||||
@@ -587,13 +587,15 @@ TERMINAL=%s
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) EnableDMSService(ctx context.Context, wm deps.WindowManager) error {
|
||||
// Pull in the tray watcher alongside dms; its Before=graphical-session.target
|
||||
// ordering makes it claim the SNI name before autostart apps start.
|
||||
switch wm {
|
||||
case deps.WindowManagerNiri:
|
||||
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms").Run(); err != nil {
|
||||
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms", "dms-tray-watcher").Run(); err != nil {
|
||||
b.log("Warning: failed to add dms as a want for niri.service")
|
||||
}
|
||||
case deps.WindowManagerHyprland:
|
||||
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms").Run(); err != nil {
|
||||
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms", "dms-tray-watcher").Run(); err != nil {
|
||||
b.log("Warning: failed to add dms as a want for hyprland-session.target")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,7 +1154,7 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
lines := expandLuaConfigLines(strings.Split(string(data), "\n"))
|
||||
parser := NewHyprlandParser("")
|
||||
pendingUnbinds := make(map[string]string)
|
||||
for _, line := range lines {
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Lua configs can express binds dynamically: variables (mainMod .. " + C"),
|
||||
// tostring() calls, and numeric for loops (workspace binds). This resolves such
|
||||
// expressions to literal key combos so the static bind parser can read them.
|
||||
|
||||
const luaMaxLoopIterations = 1000
|
||||
|
||||
var (
|
||||
luaAssignRE = regexp.MustCompile(`^(?:local\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$`)
|
||||
luaForRE = regexp.MustCompile(`^for\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(-?\d+)\s*,\s*(-?\d+)\s*(?:,\s*(-?\d+)\s*)?do\b(.*)$`)
|
||||
luaTostringRE = regexp.MustCompile(`to(?:string|number)\s*\(\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|-?\d+(?:\.\d+)?)\s*\)`)
|
||||
luaBlockOpenRE = regexp.MustCompile(`\b(?:function|for|while|if)\b`)
|
||||
luaBlockCloseRE = regexp.MustCompile(`\bend\b`)
|
||||
luaNumberRE = regexp.MustCompile(`^-?\d+(?:\.\d+)?$`)
|
||||
)
|
||||
|
||||
type luaVarEnv map[string]string
|
||||
|
||||
type luaForHeader struct {
|
||||
varName string
|
||||
start int
|
||||
stop int
|
||||
step int
|
||||
inline string
|
||||
}
|
||||
|
||||
func expandLuaConfigLines(lines []string) []string {
|
||||
return expandLuaBlock(lines, luaVarEnv{})
|
||||
}
|
||||
|
||||
func expandLuaBlock(lines []string, env luaVarEnv) []string {
|
||||
out := make([]string, 0, len(lines))
|
||||
for i := 0; i < len(lines); i++ {
|
||||
code := strings.TrimSpace(luaStripLineComment(lines[i]))
|
||||
|
||||
if name, value, ok := parseLuaStringAssignment(code, env); ok {
|
||||
env[name] = value
|
||||
out = append(out, lines[i])
|
||||
continue
|
||||
}
|
||||
|
||||
header, ok := parseLuaForHeader(code)
|
||||
if !ok {
|
||||
out = append(out, resolveLuaDynamicLine(lines[i], env))
|
||||
continue
|
||||
}
|
||||
|
||||
body, consumed, complete := collectLuaForBody(header, lines, i)
|
||||
if !complete {
|
||||
out = append(out, resolveLuaDynamicLine(lines[i], env))
|
||||
continue
|
||||
}
|
||||
out = append(out, expandLuaForLoop(header, body, env)...)
|
||||
i = consumed
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseLuaStringAssignment(code string, env luaVarEnv) (name, value string, ok bool) {
|
||||
m := luaAssignRE.FindStringSubmatch(code)
|
||||
if m == nil {
|
||||
return "", "", false
|
||||
}
|
||||
value, ok = evalLuaConcat(m[2], env)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return m[1], value, true
|
||||
}
|
||||
|
||||
func parseLuaForHeader(code string) (luaForHeader, bool) {
|
||||
m := luaForRE.FindStringSubmatch(code)
|
||||
if m == nil {
|
||||
return luaForHeader{}, false
|
||||
}
|
||||
start, _ := strconv.Atoi(m[2])
|
||||
stop, _ := strconv.Atoi(m[3])
|
||||
step := 1
|
||||
if m[4] != "" {
|
||||
step, _ = strconv.Atoi(m[4])
|
||||
}
|
||||
if step == 0 {
|
||||
return luaForHeader{}, false
|
||||
}
|
||||
return luaForHeader{varName: m[1], start: start, stop: stop, step: step, inline: strings.TrimSpace(m[5])}, true
|
||||
}
|
||||
|
||||
func collectLuaForBody(header luaForHeader, lines []string, headerIdx int) (body []string, consumed int, complete bool) {
|
||||
depth := 1
|
||||
if header.inline != "" {
|
||||
delta := luaBlockDelta(header.inline)
|
||||
if depth+delta <= 0 {
|
||||
if stmt := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(header.inline), "end")); stmt != "" {
|
||||
body = append(body, stmt)
|
||||
}
|
||||
return body, headerIdx, true
|
||||
}
|
||||
depth += delta
|
||||
body = append(body, header.inline)
|
||||
}
|
||||
for j := headerIdx + 1; j < len(lines); j++ {
|
||||
delta := luaBlockDelta(lines[j])
|
||||
if depth+delta <= 0 {
|
||||
return body, j, true
|
||||
}
|
||||
depth += delta
|
||||
body = append(body, lines[j])
|
||||
}
|
||||
return nil, headerIdx, false
|
||||
}
|
||||
|
||||
func expandLuaForLoop(header luaForHeader, body []string, env luaVarEnv) []string {
|
||||
var out []string
|
||||
inRange := func(v int) bool {
|
||||
if header.step > 0 {
|
||||
return v <= header.stop
|
||||
}
|
||||
return v >= header.stop
|
||||
}
|
||||
count := 0
|
||||
for v := header.start; inRange(v); v += header.step {
|
||||
if count++; count > luaMaxLoopIterations {
|
||||
break
|
||||
}
|
||||
value := strconv.Itoa(v)
|
||||
iterLines := make([]string, len(body))
|
||||
for k, bl := range body {
|
||||
iterLines[k] = substituteLuaIdent(bl, header.varName, value)
|
||||
}
|
||||
out = append(out, expandLuaBlock(iterLines, cloneLuaEnv(env))...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func luaBlockDelta(line string) int {
|
||||
masked := luaMaskStrings(line)
|
||||
return len(luaBlockOpenRE.FindAllString(masked, -1)) - len(luaBlockCloseRE.FindAllString(masked, -1))
|
||||
}
|
||||
|
||||
func resolveLuaDynamicLine(line string, env luaVarEnv) string {
|
||||
if !strings.Contains(line, "hl.bind") && !strings.Contains(line, "hl.unbind") {
|
||||
return line
|
||||
}
|
||||
line = normalizeLuaToString(line)
|
||||
return rewriteLuaBindKeyArg(line, env)
|
||||
}
|
||||
|
||||
func normalizeLuaToString(line string) string {
|
||||
return luaTostringRE.ReplaceAllStringFunc(line, func(m string) string {
|
||||
inner := luaTostringRE.FindStringSubmatch(m)[1]
|
||||
if inner[0] == '"' || inner[0] == '\'' {
|
||||
return inner
|
||||
}
|
||||
return strconv.Quote(inner)
|
||||
})
|
||||
}
|
||||
|
||||
func rewriteLuaBindKeyArg(line string, env luaVarEnv) string {
|
||||
for _, fn := range []string{"hl.bind", "hl.unbind"} {
|
||||
idx := strings.Index(line, fn)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
open := skipLuaWS(line, idx+len(fn))
|
||||
if open >= len(line) || line[open] != '(' {
|
||||
continue
|
||||
}
|
||||
argStart := skipLuaWS(line, open+1)
|
||||
expr, end, ok := parseLuaFirstArgExpr(line, argStart)
|
||||
if !ok || isLuaPlainStringArg(expr) {
|
||||
continue
|
||||
}
|
||||
value, ok := evalLuaConcat(expr, env)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
return line[:argStart] + strconv.Quote(value) + line[end:]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func evalLuaConcat(expr string, env luaVarEnv) (string, bool) {
|
||||
parts := splitLuaConcat(expr)
|
||||
var sb strings.Builder
|
||||
for _, part := range parts {
|
||||
value, ok := evalLuaOperand(part, env)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
sb.WriteString(value)
|
||||
}
|
||||
return sb.String(), true
|
||||
}
|
||||
|
||||
func evalLuaOperand(op string, env luaVarEnv) (string, bool) {
|
||||
op = strings.TrimSpace(op)
|
||||
if op == "" {
|
||||
return "", false
|
||||
}
|
||||
switch op[0] {
|
||||
case '"', '\'':
|
||||
s, next, ok := parseLuaStringLiteral(op, 0)
|
||||
return s, next == len(op) && ok
|
||||
}
|
||||
if luaNumberRE.MatchString(op) {
|
||||
return op, true
|
||||
}
|
||||
if inner, ok := luaUnwrapCall(op, "tostring"); ok {
|
||||
return evalLuaConcat(inner, env)
|
||||
}
|
||||
if inner, ok := luaUnwrapCall(op, "tonumber"); ok {
|
||||
return evalLuaConcat(inner, env)
|
||||
}
|
||||
if value, ok := env[op]; ok {
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func splitLuaConcat(expr string) []string {
|
||||
var parts []string
|
||||
parenDepth, braceDepth, bracketDepth := 0, 0, 0
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
start := 0
|
||||
for i := 0; i < len(expr); i++ {
|
||||
c := expr[i]
|
||||
if inStr != 0 {
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case c == '\\' && inStr == '"':
|
||||
esc = true
|
||||
case c == inStr:
|
||||
inStr = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"', '\'':
|
||||
inStr = c
|
||||
case '(':
|
||||
parenDepth++
|
||||
case ')':
|
||||
if parenDepth > 0 {
|
||||
parenDepth--
|
||||
}
|
||||
case '{':
|
||||
braceDepth++
|
||||
case '}':
|
||||
if braceDepth > 0 {
|
||||
braceDepth--
|
||||
}
|
||||
case '[':
|
||||
bracketDepth++
|
||||
case ']':
|
||||
if bracketDepth > 0 {
|
||||
bracketDepth--
|
||||
}
|
||||
case '.':
|
||||
if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && i+1 < len(expr) && expr[i+1] == '.' {
|
||||
parts = append(parts, expr[start:i])
|
||||
i++
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(parts, expr[start:])
|
||||
}
|
||||
|
||||
func substituteLuaIdent(line, name, value string) string {
|
||||
if !strings.Contains(line, name) {
|
||||
return line
|
||||
}
|
||||
var sb strings.Builder
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
for i := 0; i < len(line); {
|
||||
c := line[i]
|
||||
if inStr != 0 {
|
||||
sb.WriteByte(c)
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case c == '\\' && inStr == '"':
|
||||
esc = true
|
||||
case c == inStr:
|
||||
inStr = 0
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '"' || c == '\'' {
|
||||
inStr = c
|
||||
sb.WriteByte(c)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if isLuaIdentStart(c) {
|
||||
j := i + 1
|
||||
for j < len(line) && isLuaIdentByte(line[j]) {
|
||||
j++
|
||||
}
|
||||
word := line[i:j]
|
||||
if word == name && (i == 0 || line[i-1] != '.') {
|
||||
sb.WriteString(value)
|
||||
} else {
|
||||
sb.WriteString(word)
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func luaMaskStrings(line string) string {
|
||||
b := []byte(line)
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
for i := 0; i < len(b); i++ {
|
||||
c := b[i]
|
||||
if inStr != 0 {
|
||||
wasEnd := !esc && c == inStr
|
||||
esc = !esc && c == '\\' && inStr == '"'
|
||||
b[i] = ' '
|
||||
if wasEnd {
|
||||
inStr = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"', '\'':
|
||||
inStr = c
|
||||
b[i] = ' '
|
||||
case '-':
|
||||
if i+1 < len(b) && b[i+1] == '-' {
|
||||
for ; i < len(b); i++ {
|
||||
b[i] = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func luaStripLineComment(line string) string {
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
for i := 0; i+1 < len(line); i++ {
|
||||
c := line[i]
|
||||
if inStr != 0 {
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case c == '\\' && inStr == '"':
|
||||
esc = true
|
||||
case c == inStr:
|
||||
inStr = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"', '\'':
|
||||
inStr = c
|
||||
case '-':
|
||||
if line[i+1] == '-' {
|
||||
return line[:i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func luaUnwrapCall(op, fn string) (string, bool) {
|
||||
op = strings.TrimSpace(op)
|
||||
if !strings.HasPrefix(op, fn) {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(op[len(fn):])
|
||||
if !strings.HasPrefix(rest, "(") || !strings.HasSuffix(rest, ")") {
|
||||
return "", false
|
||||
}
|
||||
return rest[1 : len(rest)-1], true
|
||||
}
|
||||
|
||||
func isLuaPlainStringArg(expr string) bool {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" || (expr[0] != '"' && expr[0] != '\'') {
|
||||
return false
|
||||
}
|
||||
_, next, ok := parseLuaStringLiteral(expr, 0)
|
||||
return ok && next == len(expr)
|
||||
}
|
||||
|
||||
func isLuaIdentStart(c byte) bool {
|
||||
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
}
|
||||
|
||||
func cloneLuaEnv(env luaVarEnv) luaVarEnv {
|
||||
clone := make(luaVarEnv, len(env))
|
||||
maps.Copy(clone, env)
|
||||
return clone
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandLuaConfigLinesVariableConcat(t *testing.T) {
|
||||
lines := []string{
|
||||
`local mainMod = "SUPER"`,
|
||||
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
|
||||
`hl.bind(mainMod .. " + H", hl.dsp.focus({direction = "l"}))`,
|
||||
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
|
||||
}
|
||||
got := expandLuaConfigLines(lines)
|
||||
|
||||
want := []string{
|
||||
`hl.bind("SUPER + C",`,
|
||||
`hl.bind("SUPER + H",`,
|
||||
`hl.bind("ALT + TAB",`,
|
||||
}
|
||||
joined := strings.Join(got, "\n")
|
||||
for _, w := range want {
|
||||
if !strings.Contains(joined, w) {
|
||||
t.Errorf("expanded output missing %q\n---\n%s", w, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandLuaConfigLinesForLoop(t *testing.T) {
|
||||
lines := []string{
|
||||
`local mainMod = "SUPER"`,
|
||||
`for i = 1, 3 do`,
|
||||
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
|
||||
` hl.bind(mainMod .. " SHIFT + " .. i, hl.dsp.window.move({workspace = tostring(i)}))`,
|
||||
`end`,
|
||||
}
|
||||
got := strings.Join(expandLuaConfigLines(lines), "\n")
|
||||
|
||||
for _, w := range []string{
|
||||
`hl.bind("SUPER + 1",`,
|
||||
`hl.bind("SUPER + 2",`,
|
||||
`hl.bind("SUPER + 3",`,
|
||||
`hl.bind("SUPER SHIFT + 3",`,
|
||||
`{workspace = "1"}`,
|
||||
} {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("expanded loop missing %q\n---\n%s", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLuaLinesDynamicBinds(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
`local mainMod = "SUPER"`,
|
||||
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
|
||||
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
|
||||
`for i = 1, 2 do`,
|
||||
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
|
||||
`end`,
|
||||
}, "\n")
|
||||
|
||||
parser := NewHyprlandParser("")
|
||||
section, err := parser.parseLuaLines(content, "", "test.lua", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseLuaLines: %v", err)
|
||||
}
|
||||
|
||||
keys := map[string]*HyprlandKeyBinding{}
|
||||
for i := range section.Keybinds {
|
||||
kb := §ion.Keybinds[i]
|
||||
keys[parser.formatBindKey(kb)] = kb
|
||||
}
|
||||
|
||||
for _, want := range []string{"SUPER+C", "ALT+TAB", "SUPER+1", "SUPER+2"} {
|
||||
if _, ok := keys[want]; !ok {
|
||||
t.Errorf("missing bind %q; got %v", want, keysList(keys))
|
||||
}
|
||||
}
|
||||
if kb := keys["SUPER+C"]; kb != nil && kb.Dispatcher != "killactive" {
|
||||
t.Errorf("SUPER+C dispatcher = %q, want killactive", kb.Dispatcher)
|
||||
}
|
||||
if kb := keys["SUPER+1"]; kb != nil {
|
||||
if kb.Dispatcher != "workspace" || kb.Params != "1" {
|
||||
t.Errorf("SUPER+1 = %q %q, want workspace 1", kb.Dispatcher, kb.Params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keysList(m map[string]*HyprlandKeyBinding) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEvalLuaConcat(t *testing.T) {
|
||||
env := luaVarEnv{"mainMod": "SUPER", "i": "5"}
|
||||
tests := []struct {
|
||||
expr string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{`mainMod .. " + C"`, "SUPER + C", true},
|
||||
{`mainMod .. " + " .. i`, "SUPER + 5", true},
|
||||
{`mainMod .. " + " .. tostring(i)`, "SUPER + 5", true},
|
||||
{`"ALT + TAB"`, "ALT + TAB", true},
|
||||
{`mainMod .. someFunc()`, "", false},
|
||||
{`unknownVar .. "x"`, "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, ok := evalLuaConcat(tt.expr, env)
|
||||
if ok != tt.ok || (ok && got != tt.want) {
|
||||
t.Errorf("evalLuaConcat(%q) = %q,%v want %q,%v", tt.expr, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteLuaIdent(t *testing.T) {
|
||||
tests := []struct {
|
||||
line, name, value, want string
|
||||
}{
|
||||
{`hl.bind(m .. " + " .. i, x)`, "i", "3", `hl.bind(m .. " + " .. 3, x)`},
|
||||
{`hl.dsp.exec("light -i")`, "i", "3", `hl.dsp.exec("light -i")`},
|
||||
{`foo.i`, "i", "3", `foo.i`},
|
||||
{`tostring(i)`, "i", "3", `tostring(3)`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := substituteLuaIdent(tt.line, tt.name, tt.value); got != tt.want {
|
||||
t.Errorf("substituteLuaIdent(%q,%q,%q) = %q, want %q", tt.line, tt.name, tt.value, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -623,7 +623,7 @@ func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, section
|
||||
prevSource := p.currentSource
|
||||
p.currentSource = absPath
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
lines := expandLuaConfigLines(strings.Split(content, "\n"))
|
||||
boundInFile := make(map[string]bool)
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
@@ -19,6 +19,20 @@ type NiriProvider struct {
|
||||
parsed bool
|
||||
}
|
||||
|
||||
type niriActionPart struct {
|
||||
value string
|
||||
quoted bool
|
||||
}
|
||||
|
||||
var niriActionPropertyOrder = []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"}
|
||||
var niriActionProperties = map[string]struct{}{
|
||||
"focus": {},
|
||||
"show-pointer": {},
|
||||
"write-to-disk": {},
|
||||
"skip-confirmation": {},
|
||||
"delay-ms": {},
|
||||
}
|
||||
|
||||
func NewNiriProvider(configDir string) *NiriProvider {
|
||||
if configDir == "" {
|
||||
configDir = defaultNiriConfigDir()
|
||||
@@ -55,6 +69,7 @@ func (n *NiriProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
||||
sheet := &keybinds.CheatSheet{
|
||||
Title: "Niri Keybinds",
|
||||
Provider: n.Name(),
|
||||
ModKey: result.ModKey,
|
||||
Binds: categorizedBinds,
|
||||
DMSBindsIncluded: result.DMSBindsIncluded,
|
||||
}
|
||||
@@ -352,7 +367,7 @@ func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
|
||||
}
|
||||
|
||||
if actionNode.Properties != nil {
|
||||
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} {
|
||||
for _, propName := range niriActionPropertyOrder {
|
||||
if val, ok := actionNode.Properties.Get(propName); ok {
|
||||
parts = append(parts, propName+"="+val.String())
|
||||
}
|
||||
@@ -440,10 +455,10 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
|
||||
return node
|
||||
}
|
||||
|
||||
node.SetName(parts[0])
|
||||
node.SetName(parts[0].value)
|
||||
for _, arg := range parts[1:] {
|
||||
if strings.Contains(arg, "=") {
|
||||
kv := strings.SplitN(arg, "=", 2)
|
||||
if n.isNiriActionPropertyToken(arg) {
|
||||
kv := strings.SplitN(arg.value, "=", 2)
|
||||
switch kv[1] {
|
||||
case "true":
|
||||
node.AddProperty(kv[0], true, "")
|
||||
@@ -454,13 +469,25 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
|
||||
}
|
||||
continue
|
||||
}
|
||||
node.AddArgument(arg, "")
|
||||
node.AddArgument(arg.value, "")
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func (n *NiriProvider) parseActionParts(action string) []string {
|
||||
var parts []string
|
||||
func (n *NiriProvider) isNiriActionPropertyToken(part niriActionPart) bool {
|
||||
if part.quoted || !strings.Contains(part.value, "=") {
|
||||
return false
|
||||
}
|
||||
key, _, ok := strings.Cut(part.value, "=")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, ok = niriActionProperties[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (n *NiriProvider) parseActionParts(action string) []niriActionPart {
|
||||
var parts []niriActionPart
|
||||
var current strings.Builder
|
||||
var inQuote, escaped, wasQuoted bool
|
||||
|
||||
@@ -476,7 +503,7 @@ func (n *NiriProvider) parseActionParts(action string) []string {
|
||||
inQuote = !inQuote
|
||||
case r == ' ' && !inQuote:
|
||||
if current.Len() > 0 || wasQuoted {
|
||||
parts = append(parts, current.String())
|
||||
parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
|
||||
current.Reset()
|
||||
wasQuoted = false
|
||||
}
|
||||
@@ -485,7 +512,7 @@ func (n *NiriProvider) parseActionParts(action string) []string {
|
||||
}
|
||||
}
|
||||
if current.Len() > 0 || wasQuoted {
|
||||
parts = append(parts, current.String())
|
||||
parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type NiriSection struct {
|
||||
|
||||
type NiriParser struct {
|
||||
configDir string
|
||||
modKey string
|
||||
processedFiles map[string]bool
|
||||
bindMap map[string]*NiriKeyBinding
|
||||
bindOrder []string
|
||||
@@ -237,6 +238,7 @@ func isBraceAdjacentSpace(b byte) bool {
|
||||
func NewNiriParser(configDir string) *NiriParser {
|
||||
return &NiriParser{
|
||||
configDir: configDir,
|
||||
modKey: "Super",
|
||||
processedFiles: make(map[string]bool),
|
||||
bindMap: make(map[string]*NiriKeyBinding),
|
||||
bindOrder: []string{},
|
||||
@@ -377,6 +379,8 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
|
||||
switch name {
|
||||
case "include":
|
||||
p.handleInclude(node, section, baseDir)
|
||||
case "input":
|
||||
p.handleInput(node)
|
||||
case "binds":
|
||||
p.extractBinds(node, section, "")
|
||||
case "recent-windows":
|
||||
@@ -385,6 +389,19 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *NiriParser) handleInput(node *document.Node) {
|
||||
for _, child := range node.Children {
|
||||
if child.Name.String() != "mod-key" || len(child.Arguments) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
modKey := strings.Trim(strings.TrimSpace(child.Arguments[0].String()), "\"")
|
||||
if modKey != "" {
|
||||
p.modKey = modKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) {
|
||||
if len(node.Arguments) == 0 {
|
||||
return
|
||||
@@ -534,6 +551,7 @@ func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) {
|
||||
|
||||
type NiriParseResult struct {
|
||||
Section *NiriSection
|
||||
ModKey string
|
||||
DMSBindsIncluded bool
|
||||
DMSStatus *DMSBindsStatusInfo
|
||||
ConflictingConfigs map[string]*NiriKeyBinding
|
||||
@@ -586,6 +604,7 @@ func ParseNiriKeys(configDir string) (*NiriParseResult, error) {
|
||||
}
|
||||
return &NiriParseResult{
|
||||
Section: section,
|
||||
ModKey: parser.modKey,
|
||||
DMSBindsIncluded: parser.HasDMSBindsIncluded(),
|
||||
DMSStatus: parser.buildDMSStatus(),
|
||||
ConflictingConfigs: parser.conflictingConfigs,
|
||||
|
||||
@@ -7,6 +7,28 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNiriParseModKey(t *testing.T) {
|
||||
config := `input {
|
||||
mod-key "Alt"
|
||||
}
|
||||
binds {
|
||||
Mod+T { spawn "kitty"; }
|
||||
}
|
||||
`
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
result, err := ParseNiriKeys(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNiriKeys failed: %v", err)
|
||||
}
|
||||
if result.ModKey != "Alt" {
|
||||
t.Errorf("ModKey = %q, want %q", result.ModKey, "Alt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) {
|
||||
config := `recent-windows {
|
||||
binds {
|
||||
|
||||
@@ -17,7 +17,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "config.kdl")
|
||||
|
||||
content := `binds {
|
||||
content := `input {
|
||||
mod-key "Alt"
|
||||
}
|
||||
binds {
|
||||
Mod+Q { close-window; }
|
||||
Mod+F { fullscreen-window; }
|
||||
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
|
||||
@@ -45,6 +48,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
|
||||
t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri")
|
||||
}
|
||||
|
||||
if cheatSheet.ModKey != "Alt" {
|
||||
t.Errorf("ModKey = %q, want %q", cheatSheet.ModKey, "Alt")
|
||||
}
|
||||
|
||||
windowBinds := cheatSheet.Binds["Window"]
|
||||
if len(windowBinds) < 2 {
|
||||
t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds))
|
||||
@@ -227,6 +234,58 @@ func TestNiriGenerateBindsContent(t *testing.T) {
|
||||
expected: `binds {
|
||||
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "spawn with equals arg",
|
||||
binds: map[string]*overrideBind{
|
||||
"Mod+B": {
|
||||
Key: "Mod+B",
|
||||
Action: `spawn /opt/browser --profile-directory=Default`,
|
||||
},
|
||||
},
|
||||
expected: `binds {
|
||||
Mod+B { spawn "/opt/browser" "--profile-directory=Default"; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "spawn shell command with quoted equals args",
|
||||
binds: map[string]*overrideBind{
|
||||
"Mod+C": {
|
||||
Key: "Mod+C",
|
||||
Action: `spawn sh -c "chrome --profile-directory=Default --app=x"`,
|
||||
},
|
||||
},
|
||||
expected: `binds {
|
||||
Mod+C { spawn "sh" "-c" "chrome --profile-directory=Default --app=x"; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "spawn env assignment stays arg",
|
||||
binds: map[string]*overrideBind{
|
||||
"Mod+E": {
|
||||
Key: "Mod+E",
|
||||
Action: `spawn env FOO=bar mycmd`,
|
||||
},
|
||||
},
|
||||
expected: `binds {
|
||||
Mod+E { spawn "env" "FOO=bar" "mycmd"; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "niri action property remains property",
|
||||
binds: map[string]*overrideBind{
|
||||
"Print": {
|
||||
Key: "Print",
|
||||
Action: `screenshot show-pointer=false`,
|
||||
},
|
||||
},
|
||||
expected: `binds {
|
||||
Print { screenshot show-pointer=false; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ type DMSBindsStatus struct {
|
||||
type CheatSheet struct {
|
||||
Title string `json:"title"`
|
||||
Provider string `json:"provider"`
|
||||
ModKey string `json:"modKey,omitempty"`
|
||||
Binds map[string][]Keybind `json:"binds"`
|
||||
DMSBindsIncluded bool `json:"dmsBindsIncluded"`
|
||||
DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"`
|
||||
|
||||
@@ -118,6 +118,50 @@ type ColorsOutput struct {
|
||||
} `json:"colors"`
|
||||
}
|
||||
|
||||
type SchemePreview struct {
|
||||
Dark string `json:"dark"`
|
||||
Light string `json:"light"`
|
||||
}
|
||||
|
||||
var previewSchemeTypes = []string{
|
||||
"scheme-tonal-spot",
|
||||
"scheme-vibrant",
|
||||
"scheme-content",
|
||||
"scheme-expressive",
|
||||
"scheme-fidelity",
|
||||
"scheme-fruit-salad",
|
||||
"scheme-monochrome",
|
||||
"scheme-neutral",
|
||||
"scheme-rainbow",
|
||||
}
|
||||
|
||||
func PreviewSchemes(sourceColor string, contrast float64) (map[string]SchemePreview, error) {
|
||||
if sourceColor == "" {
|
||||
return nil, fmt.Errorf("source color is required")
|
||||
}
|
||||
|
||||
previews := make(map[string]SchemePreview, len(previewSchemeTypes))
|
||||
for _, schemeType := range previewSchemeTypes {
|
||||
output, err := runMatugenDryRun(&Options{
|
||||
Kind: "hex",
|
||||
Value: sourceColor,
|
||||
MatugenType: schemeType,
|
||||
Contrast: contrast,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preview %s: %w", schemeType, err)
|
||||
}
|
||||
|
||||
dark := extractMatugenColor(output, "primary", "dark")
|
||||
light := extractMatugenColor(output, "primary", "light")
|
||||
if dark == "" || light == "" {
|
||||
return nil, fmt.Errorf("preview %s: primary colors missing from matugen output", schemeType)
|
||||
}
|
||||
previews[schemeType] = SchemePreview{Dark: dark, Light: light}
|
||||
}
|
||||
return previews, nil
|
||||
}
|
||||
|
||||
func (o *Options) ColorsOutput() string {
|
||||
return filepath.Join(o.StateDir, "dms-colors.json")
|
||||
}
|
||||
@@ -655,6 +699,7 @@ func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) {
|
||||
|
||||
func detectMatugenVersionLocked() (matugenFlags, error) {
|
||||
cmd := exec.Command("matugen", "--version")
|
||||
cmd.Env = utils.EnvWithUserBinPath(nil)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err)
|
||||
@@ -711,6 +756,7 @@ func runMatugen(baseArgs []string) error {
|
||||
|
||||
args := buildMatugenArgs(baseArgs, flags)
|
||||
cmd := exec.Command("matugen", args...)
|
||||
cmd.Env = utils.EnvWithUserBinPath(nil)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
runErr := cmd.Run()
|
||||
@@ -728,6 +774,7 @@ func runMatugen(baseArgs []string) error {
|
||||
log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4)
|
||||
args = buildMatugenArgs(baseArgs, newFlags)
|
||||
retryCmd := exec.Command("matugen", args...)
|
||||
retryCmd.Env = utils.EnvWithUserBinPath(nil)
|
||||
retryCmd.Stdout = os.Stdout
|
||||
retryCmd.Stderr = os.Stderr
|
||||
return retryCmd.Run()
|
||||
@@ -770,6 +817,7 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
|
||||
}
|
||||
|
||||
cmd := exec.Command("matugen", baseArgs...)
|
||||
cmd.Env = utils.EnvWithUserBinPath(nil)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
output, err := cmd.Output()
|
||||
@@ -868,7 +916,26 @@ func refreshGTK(mode ColorMode) {
|
||||
}
|
||||
}
|
||||
|
||||
var colorSchemeEchoHook func(scheme string)
|
||||
|
||||
func SetColorSchemeEchoHook(hook func(scheme string)) {
|
||||
colorSchemeEchoHook = hook
|
||||
}
|
||||
|
||||
func expectColorSchemeEcho(scheme string) {
|
||||
if colorSchemeEchoHook != nil {
|
||||
colorSchemeEchoHook(scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// The color-scheme round trip is the only mechanism that makes running GTK4
|
||||
// apps reload ~/.config/gtk-4.0 CSS (a gtk-theme flip does not). But apps
|
||||
// following the portal color-scheme (Chromium) can drop the restore signal
|
||||
// mid-repaint and latch the wrong mode, so this is opt-in.
|
||||
func refreshGTK4() {
|
||||
if os.Getenv("DMS_ENABLE_GTK4_REFRESH") != "1" {
|
||||
return
|
||||
}
|
||||
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
|
||||
if err != nil {
|
||||
return
|
||||
@@ -882,11 +949,13 @@ func refreshGTK4() {
|
||||
toggle = "prefer-dark"
|
||||
}
|
||||
|
||||
expectColorSchemeEcho(toggle)
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
|
||||
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
expectColorSchemeEcho(current)
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
|
||||
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
|
||||
}
|
||||
@@ -987,6 +1056,9 @@ func closestAdwaitaAccent(primaryHex string) string {
|
||||
|
||||
func syncAccentColor(primaryHex string) {
|
||||
accent := closestAdwaitaAccent(primaryHex)
|
||||
if cur, err := utils.GsettingsGet("org.gnome.desktop.interface", "accent-color"); err == nil && strings.Trim(cur, "'") == accent {
|
||||
return
|
||||
}
|
||||
log.Infof("Setting GNOME accent color: %s", accent)
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil {
|
||||
log.Warnf("Failed to set accent-color: %v", err)
|
||||
|
||||
@@ -554,35 +554,50 @@ func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (bool, error) {
|
||||
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, diffURL string, err error) {
|
||||
pluginPath, err := m.findInstalledPath(pluginID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to find plugin: %w", err)
|
||||
return false, "", fmt.Errorf("failed to find plugin: %w", err)
|
||||
}
|
||||
|
||||
if pluginPath == "" {
|
||||
return false, fmt.Errorf("plugin not installed: %s", pluginID)
|
||||
return false, "", fmt.Errorf("plugin not installed: %s", pluginID)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return false, nil
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check metadata: %w", err)
|
||||
return false, "", fmt.Errorf("failed to check metadata: %w", err)
|
||||
}
|
||||
|
||||
var hasUp bool
|
||||
var localHash, remoteHash string
|
||||
if metaExists {
|
||||
// Plugin is from a monorepo, check the repo directory
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
return m.gitClient.HasUpdates(repoPath)
|
||||
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(repoPath)
|
||||
} else {
|
||||
// Plugin is a standalone repo
|
||||
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(pluginPath)
|
||||
}
|
||||
|
||||
// Plugin is a standalone repo
|
||||
return m.gitClient.HasUpdates(pluginPath)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
diffURL = plugin.Repo
|
||||
if diffURL != "" {
|
||||
diffURL = strings.TrimSuffix(diffURL, ".git")
|
||||
if hasUp && len(localHash) >= 7 && len(remoteHash) >= 7 {
|
||||
diffURL = fmt.Sprintf("%s/compare/%s...%s", diffURL, localHash[:7], remoteHash[:7])
|
||||
}
|
||||
}
|
||||
return hasUp, diffURL, nil
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ type Plugin struct {
|
||||
type GitClient interface {
|
||||
PlainClone(path string, url string) error
|
||||
Pull(path string) error
|
||||
HasUpdates(path string) (bool, error)
|
||||
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
|
||||
}
|
||||
|
||||
type realGitClient struct{}
|
||||
@@ -65,10 +65,10 @@ func (g *realGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) HasUpdates(path string) (bool, error) {
|
||||
func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, "", "", err
|
||||
}
|
||||
|
||||
// Fetch remote changes
|
||||
@@ -76,24 +76,24 @@ func (g *realGitClient) HasUpdates(path string) (bool, error) {
|
||||
if err != nil && err.Error() != "already up-to-date" {
|
||||
// If fetch fails, we can't determine if there are updates
|
||||
// Return false and the error
|
||||
return false, err
|
||||
return false, "", "", err
|
||||
}
|
||||
|
||||
// Get the HEAD reference
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, "", "", err
|
||||
}
|
||||
|
||||
// Get the remote HEAD reference (typically origin/HEAD or origin/main or origin/master)
|
||||
remote, err := repo.Remote("origin")
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, "", "", err
|
||||
}
|
||||
|
||||
refs, err := remote.List(&git.ListOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, "", "", err
|
||||
}
|
||||
|
||||
// Find the default branch remote ref
|
||||
@@ -108,13 +108,14 @@ func (g *realGitClient) HasUpdates(path string) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
localHash := head.Hash().String()
|
||||
// If we couldn't find a remote HEAD, assume no updates
|
||||
if remoteHead == "" {
|
||||
return false, nil
|
||||
return false, localHash, "", nil
|
||||
}
|
||||
|
||||
// Compare local HEAD with remote HEAD
|
||||
return head.Hash().String() != remoteHead, nil
|
||||
return localHash != remoteHead, localHash, remoteHead, nil
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
type mockGitClient struct {
|
||||
cloneFunc func(path string, url string) error
|
||||
pullFunc func(path string) error
|
||||
hasUpdatesFunc func(path string) (bool, error)
|
||||
hasUpdatesFunc func(path string) (bool, string, string, error)
|
||||
}
|
||||
|
||||
func (m *mockGitClient) PlainClone(path string, url string) error {
|
||||
@@ -30,11 +30,11 @@ func (m *mockGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) HasUpdates(path string) (bool, error) {
|
||||
func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
if m.hasUpdatesFunc != nil {
|
||||
return m.hasUpdatesFunc(path)
|
||||
}
|
||||
return false, nil
|
||||
return false, "", "", nil
|
||||
}
|
||||
|
||||
func TestNewRegistry(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// Generated by go-wayland-scanner
|
||||
// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
|
||||
// XML file : internal/proto/xml/virtual-keyboard-unstable-v1.xml
|
||||
//
|
||||
// virtual_keyboard_unstable_v1 Protocol Copyright:
|
||||
//
|
||||
// Copyright © 2008-2011 Kristian Høgsberg
|
||||
// Copyright © 2010-2013 Intel Corporation
|
||||
// Copyright © 2012-2013 Collabora, Ltd.
|
||||
// Copyright © 2018 Purism SPC
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
// copy of this software and associated documentation files (the "Software"),
|
||||
// to deal in the Software without restriction, including without limitation
|
||||
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
// and/or sell copies of the Software, and to permit persons to whom the
|
||||
// Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice (including the next
|
||||
// paragraph) shall be included in all copies or substantial portions of the
|
||||
// Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
package virtual_keyboard
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ZwpVirtualKeyboardV1InterfaceName 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 ZwpVirtualKeyboardV1InterfaceName = "zwp_virtual_keyboard_v1"
|
||||
|
||||
// ZwpVirtualKeyboardV1 : virtual keyboard
|
||||
//
|
||||
// The virtual keyboard provides an application with requests which emulate
|
||||
// the behaviour of a physical keyboard.
|
||||
//
|
||||
// This interface can be used by clients on its own to provide raw input
|
||||
// events, or it can accompany the input method protocol.
|
||||
type ZwpVirtualKeyboardV1 struct {
|
||||
client.BaseProxy
|
||||
}
|
||||
|
||||
// NewZwpVirtualKeyboardV1 : virtual keyboard
|
||||
//
|
||||
// The virtual keyboard provides an application with requests which emulate
|
||||
// the behaviour of a physical keyboard.
|
||||
//
|
||||
// This interface can be used by clients on its own to provide raw input
|
||||
// events, or it can accompany the input method protocol.
|
||||
func NewZwpVirtualKeyboardV1(ctx *client.Context) *ZwpVirtualKeyboardV1 {
|
||||
zwpVirtualKeyboardV1 := &ZwpVirtualKeyboardV1{}
|
||||
ctx.Register(zwpVirtualKeyboardV1)
|
||||
return zwpVirtualKeyboardV1
|
||||
}
|
||||
|
||||
// Keymap : keyboard mapping
|
||||
//
|
||||
// Provide a file descriptor to the compositor which can be
|
||||
// memory-mapped to provide a keyboard mapping description.
|
||||
//
|
||||
// Format carries a value from the keymap_format enumeration.
|
||||
//
|
||||
// format: keymap format
|
||||
// fd: keymap file descriptor
|
||||
// size: keymap size, in bytes
|
||||
func (i *ZwpVirtualKeyboardV1) Keymap(format uint32, fd int, size uint32) error {
|
||||
const opcode = 0
|
||||
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], uint32(format))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(size))
|
||||
l += 4
|
||||
oob := unix.UnixRights(int(fd))
|
||||
err := i.Context().WriteMsg(_reqBuf[:], oob)
|
||||
return err
|
||||
}
|
||||
|
||||
// Key : key event
|
||||
//
|
||||
// A key was pressed or released.
|
||||
// The time argument is a timestamp with millisecond granularity, with an
|
||||
// undefined base. All requests regarding a single object must share the
|
||||
// same clock.
|
||||
//
|
||||
// Keymap must be set before issuing this request.
|
||||
//
|
||||
// State carries a value from the key_state enumeration.
|
||||
//
|
||||
// time: timestamp with millisecond granularity
|
||||
// key: key that produced the event
|
||||
// state: physical state of the key
|
||||
func (i *ZwpVirtualKeyboardV1) Key(time, key, state uint32) error {
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8 + 4 + 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], uint32(time))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(key))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(state))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Modifiers : modifier and group state
|
||||
//
|
||||
// Notifies the compositor that the modifier and/or group state has
|
||||
// changed, and it should update state.
|
||||
//
|
||||
// The client should use wl_keyboard.modifiers event to synchronize its
|
||||
// internal state with seat state.
|
||||
//
|
||||
// Keymap must be set before issuing this request.
|
||||
//
|
||||
// modsDepressed: depressed modifiers
|
||||
// modsLatched: latched modifiers
|
||||
// modsLocked: locked modifiers
|
||||
// group: keyboard layout
|
||||
func (i *ZwpVirtualKeyboardV1) Modifiers(modsDepressed, modsLatched, modsLocked, group uint32) error {
|
||||
const opcode = 2
|
||||
const _reqBufLen = 8 + 4 + 4 + 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], uint32(modsDepressed))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(modsLatched))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(modsLocked))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(group))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Destroy : destroy the virtual keyboard keyboard object
|
||||
func (i *ZwpVirtualKeyboardV1) Destroy() error {
|
||||
defer i.Context().Unregister(i)
|
||||
const opcode = 3
|
||||
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 ZwpVirtualKeyboardV1Error uint32
|
||||
|
||||
// ZwpVirtualKeyboardV1Error :
|
||||
const (
|
||||
// ZwpVirtualKeyboardV1ErrorNoKeymap : No keymap was set
|
||||
ZwpVirtualKeyboardV1ErrorNoKeymap ZwpVirtualKeyboardV1Error = 0
|
||||
)
|
||||
|
||||
func (e ZwpVirtualKeyboardV1Error) Name() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardV1ErrorNoKeymap:
|
||||
return "no_keymap"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardV1Error) Value() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardV1ErrorNoKeymap:
|
||||
return "0"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardV1Error) String() string {
|
||||
return e.Name() + "=" + e.Value()
|
||||
}
|
||||
|
||||
// ZwpVirtualKeyboardManagerV1InterfaceName 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 ZwpVirtualKeyboardManagerV1InterfaceName = "zwp_virtual_keyboard_manager_v1"
|
||||
|
||||
// ZwpVirtualKeyboardManagerV1 : virtual keyboard manager
|
||||
//
|
||||
// A virtual keyboard manager allows an application to provide keyboard
|
||||
// input events as if they came from a physical keyboard.
|
||||
type ZwpVirtualKeyboardManagerV1 struct {
|
||||
client.BaseProxy
|
||||
}
|
||||
|
||||
// NewZwpVirtualKeyboardManagerV1 : virtual keyboard manager
|
||||
//
|
||||
// A virtual keyboard manager allows an application to provide keyboard
|
||||
// input events as if they came from a physical keyboard.
|
||||
func NewZwpVirtualKeyboardManagerV1(ctx *client.Context) *ZwpVirtualKeyboardManagerV1 {
|
||||
zwpVirtualKeyboardManagerV1 := &ZwpVirtualKeyboardManagerV1{}
|
||||
ctx.Register(zwpVirtualKeyboardManagerV1)
|
||||
return zwpVirtualKeyboardManagerV1
|
||||
}
|
||||
|
||||
// CreateVirtualKeyboard : Create a new virtual keyboard
|
||||
//
|
||||
// Creates a new virtual keyboard associated to a seat.
|
||||
//
|
||||
// If the compositor enables a keyboard to perform arbitrary actions, it
|
||||
// should present an error when an untrusted client requests a new
|
||||
// keyboard.
|
||||
func (i *ZwpVirtualKeyboardManagerV1) CreateVirtualKeyboard(seat *client.Seat) (*ZwpVirtualKeyboardV1, error) {
|
||||
id := NewZwpVirtualKeyboardV1(i.Context())
|
||||
const opcode = 0
|
||||
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], seat.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], id.ID())
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (i *ZwpVirtualKeyboardManagerV1) Destroy() error {
|
||||
i.Context().Unregister(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ZwpVirtualKeyboardManagerV1Error uint32
|
||||
|
||||
// ZwpVirtualKeyboardManagerV1Error :
|
||||
const (
|
||||
// ZwpVirtualKeyboardManagerV1ErrorUnauthorized : client not authorized to use the interface
|
||||
ZwpVirtualKeyboardManagerV1ErrorUnauthorized ZwpVirtualKeyboardManagerV1Error = 0
|
||||
)
|
||||
|
||||
func (e ZwpVirtualKeyboardManagerV1Error) Name() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardManagerV1ErrorUnauthorized:
|
||||
return "unauthorized"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardManagerV1Error) Value() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardManagerV1ErrorUnauthorized:
|
||||
return "0"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardManagerV1Error) String() string {
|
||||
return e.Name() + "=" + e.Value()
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="virtual_keyboard_unstable_v1">
|
||||
<copyright>
|
||||
Copyright © 2008-2011 Kristian Høgsberg
|
||||
Copyright © 2010-2013 Intel Corporation
|
||||
Copyright © 2012-2013 Collabora, Ltd.
|
||||
Copyright © 2018 Purism SPC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<interface name="zwp_virtual_keyboard_v1" version="1">
|
||||
<description summary="virtual keyboard">
|
||||
The virtual keyboard provides an application with requests which emulate
|
||||
the behaviour of a physical keyboard.
|
||||
|
||||
This interface can be used by clients on its own to provide raw input
|
||||
events, or it can accompany the input method protocol.
|
||||
</description>
|
||||
|
||||
<request name="keymap">
|
||||
<description summary="keyboard mapping">
|
||||
Provide a file descriptor to the compositor which can be
|
||||
memory-mapped to provide a keyboard mapping description.
|
||||
|
||||
Format carries a value from the keymap_format enumeration.
|
||||
</description>
|
||||
<arg name="format" type="uint" summary="keymap format"/>
|
||||
<arg name="fd" type="fd" summary="keymap file descriptor"/>
|
||||
<arg name="size" type="uint" summary="keymap size, in bytes"/>
|
||||
</request>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="no_keymap" value="0" summary="No keymap was set"/>
|
||||
</enum>
|
||||
|
||||
<request name="key">
|
||||
<description summary="key event">
|
||||
A key was pressed or released.
|
||||
The time argument is a timestamp with millisecond granularity, with an
|
||||
undefined base. All requests regarding a single object must share the
|
||||
same clock.
|
||||
|
||||
Keymap must be set before issuing this request.
|
||||
|
||||
State carries a value from the key_state enumeration.
|
||||
</description>
|
||||
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
|
||||
<arg name="key" type="uint" summary="key that produced the event"/>
|
||||
<arg name="state" type="uint" summary="physical state of the key"/>
|
||||
</request>
|
||||
|
||||
<request name="modifiers">
|
||||
<description summary="modifier and group state">
|
||||
Notifies the compositor that the modifier and/or group state has
|
||||
changed, and it should update state.
|
||||
|
||||
The client should use wl_keyboard.modifiers event to synchronize its
|
||||
internal state with seat state.
|
||||
|
||||
Keymap must be set before issuing this request.
|
||||
</description>
|
||||
<arg name="mods_depressed" type="uint" summary="depressed modifiers"/>
|
||||
<arg name="mods_latched" type="uint" summary="latched modifiers"/>
|
||||
<arg name="mods_locked" type="uint" summary="locked modifiers"/>
|
||||
<arg name="group" type="uint" summary="keyboard layout"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor" since="1">
|
||||
<description summary="destroy the virtual keyboard keyboard object"/>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="zwp_virtual_keyboard_manager_v1" version="1">
|
||||
<description summary="virtual keyboard manager">
|
||||
A virtual keyboard manager allows an application to provide keyboard
|
||||
input events as if they came from a physical keyboard.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="unauthorized" value="0" summary="client not authorized to use the interface"/>
|
||||
</enum>
|
||||
|
||||
<request name="create_virtual_keyboard">
|
||||
<description summary="Create a new virtual keyboard">
|
||||
Creates a new virtual keyboard associated to a seat.
|
||||
|
||||
If the compositor enables a keyboard to perform arbitrary actions, it
|
||||
should present an error when an untrusted client requests a new
|
||||
keyboard.
|
||||
</description>
|
||||
<arg name="seat" type="object" interface="wl_seat"/>
|
||||
<arg name="id" type="new_id" interface="zwp_virtual_keyboard_v1"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -68,6 +68,12 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error {
|
||||
activeBuses[i] = true
|
||||
id := fmt.Sprintf("ddc:i2c-%d", i)
|
||||
|
||||
// Don't re-probe identified monitors: DDC traffic during a wake
|
||||
// sequence can disturb some monitors' own brightness handling.
|
||||
if _, ok := b.devices.Load(id); ok {
|
||||
continue
|
||||
}
|
||||
|
||||
dev, err := b.probeDDCDevice(i)
|
||||
if err != nil || dev == nil {
|
||||
continue
|
||||
@@ -107,7 +113,8 @@ func (b *DDCBackend) probeDDCDevice(bus int) (*ddcDevice, error) {
|
||||
dummy := make([]byte, 32)
|
||||
syscall.Read(fd, dummy) //nolint:errcheck
|
||||
|
||||
writebuf := []byte{0x00}
|
||||
writebuf := []byte{DDC_SOURCE_ADDR, 0x80}
|
||||
writebuf = append(writebuf, ddcciChecksum(writebuf))
|
||||
n, err := syscall.Write(fd, writebuf)
|
||||
if err == nil && n == len(writebuf) {
|
||||
name := b.getDDCName(bus)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||
)
|
||||
@@ -28,6 +29,10 @@ func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
|
||||
handleCopyEntry(conn, req, m)
|
||||
case "clipboard.paste":
|
||||
handlePaste(conn, req, m)
|
||||
case "clipboard.sendPaste":
|
||||
handleSendPaste(conn, req)
|
||||
case "clipboard.pasteSupported":
|
||||
models.Respond(conn, req.ID, map[string]bool{"supported": m.pasteSupported})
|
||||
case "clipboard.subscribe":
|
||||
handleSubscribe(conn, req, m)
|
||||
case "clipboard.search":
|
||||
@@ -176,6 +181,17 @@ func handlePaste(conn net.Conn, req models.Request, m *Manager) {
|
||||
models.Respond(conn, req.ID, map[string]string{"text": text})
|
||||
}
|
||||
|
||||
func handleSendPaste(conn net.Conn, req models.Request) {
|
||||
shift, _ := models.Get[bool](req, "shift")
|
||||
|
||||
if err := clipboardstore.SendPasteKeystroke(shift); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "paste sent"})
|
||||
}
|
||||
|
||||
func handleSubscribe(conn net.Conn, req models.Request, m *Manager) {
|
||||
clientID := fmt.Sprintf("clipboard-%d", req.ID)
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
@@ -164,6 +165,8 @@ func (m *Manager) setupRegistry() error {
|
||||
m.seat = seat
|
||||
m.seatName = e.Name
|
||||
log.Info("Bound wl_seat")
|
||||
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
|
||||
m.pasteSupported = true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1054,6 +1057,13 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
dataCopy := make([]byte, len(data))
|
||||
copy(dataCopy, data)
|
||||
|
||||
m.takeSelection(clipboardstore.ExpandOffers(dataCopy, mimeType))
|
||||
return nil
|
||||
}
|
||||
|
||||
// takeSelection makes the daemon the selection owner, serving the given
|
||||
// offers until another client claims the clipboard.
|
||||
func (m *Manager) takeSelection(offers []clipboardstore.Offer) {
|
||||
m.post(func() {
|
||||
if m.dataControlMgr == nil || m.dataDevice == nil {
|
||||
log.Error("Data control manager or device not initialized")
|
||||
@@ -1068,9 +1078,13 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
return
|
||||
}
|
||||
|
||||
if err := source.Offer(mimeType); err != nil {
|
||||
log.Errorf("Failed to offer mime type: %v", err)
|
||||
return
|
||||
offerData := make(map[string][]byte, len(offers))
|
||||
for _, offer := range offers {
|
||||
if err := source.Offer(offer.MimeType); err != nil {
|
||||
log.Errorf("Failed to offer %s: %v", offer.MimeType, err)
|
||||
return
|
||||
}
|
||||
offerData[offer.MimeType] = offer.Data
|
||||
}
|
||||
|
||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||
@@ -1080,7 +1094,11 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
file := os.NewFile(uintptr(fd), "clipboard-pipe")
|
||||
defer file.Close()
|
||||
|
||||
if _, err := file.Write(dataCopy); err != nil {
|
||||
data, ok := offerData[e.MimeType]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := file.Write(data); err != nil {
|
||||
log.Errorf("Failed to write clipboard data: %v", err)
|
||||
}
|
||||
})
|
||||
@@ -1093,9 +1111,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
|
||||
m.releaseCurrentSource()
|
||||
m.currentSource = source
|
||||
m.sourceMutex.Lock()
|
||||
m.sourceMimeTypes = []string{mimeType}
|
||||
m.sourceMutex.Unlock()
|
||||
|
||||
m.ownerLock.Lock()
|
||||
m.isOwner = true
|
||||
@@ -1106,8 +1121,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
log.Errorf("Failed to set selection: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) CopyText(text string) error {
|
||||
@@ -1779,74 +1792,16 @@ func (m *Manager) CopyFile(filePath string) error {
|
||||
m.updateState()
|
||||
m.notifySubscribers()
|
||||
|
||||
_, imgMime, imgErr := image.DecodeConfig(bytes.NewReader(fileData))
|
||||
|
||||
m.post(func() {
|
||||
if m.dataControlMgr == nil || m.dataDevice == nil {
|
||||
log.Error("Data control manager or device not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
|
||||
source, err := dataMgr.CreateDataSource()
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create data source: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
type offer struct {
|
||||
mime string
|
||||
data []byte
|
||||
}
|
||||
offers := []offer{
|
||||
{"x-special/gnome-copied-files", []byte("copy\n" + fileURI)},
|
||||
{"text/uri-list", []byte(fileURI + "\r\n")},
|
||||
{"text/plain", []byte(filePath)},
|
||||
}
|
||||
|
||||
if imgErr == nil {
|
||||
imgMimeType := "image/" + imgMime
|
||||
offers = append(offers, offer{imgMimeType, fileData})
|
||||
}
|
||||
|
||||
offerData := make(map[string][]byte)
|
||||
for _, o := range offers {
|
||||
if err := source.Offer(o.mime); err != nil {
|
||||
log.Errorf("Failed to offer %s: %v", o.mime, err)
|
||||
return
|
||||
}
|
||||
offerData[o.mime] = o.data
|
||||
}
|
||||
|
||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||
fd := e.Fd
|
||||
defer syscall.Close(fd)
|
||||
file := os.NewFile(uintptr(fd), "clipboard-pipe")
|
||||
defer file.Close()
|
||||
if data, ok := offerData[e.MimeType]; ok {
|
||||
file.Write(data)
|
||||
}
|
||||
})
|
||||
|
||||
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
|
||||
m.ownerLock.Lock()
|
||||
m.isOwner = false
|
||||
m.ownerLock.Unlock()
|
||||
})
|
||||
|
||||
m.releaseCurrentSource()
|
||||
m.currentSource = source
|
||||
|
||||
m.ownerLock.Lock()
|
||||
m.isOwner = true
|
||||
m.ownerLock.Unlock()
|
||||
|
||||
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
|
||||
if err := device.SetSelection(source); err != nil {
|
||||
log.Errorf("Failed to set selection: %v", err)
|
||||
}
|
||||
})
|
||||
offers := []clipboardstore.Offer{
|
||||
{MimeType: "x-special/gnome-copied-files", Data: []byte("copy\n" + fileURI)},
|
||||
{MimeType: "text/uri-list", Data: []byte(fileURI + "\r\n")},
|
||||
{MimeType: "text/plain", Data: []byte(filePath)},
|
||||
}
|
||||
if _, imgMime, err := image.DecodeConfig(bytes.NewReader(fileData)); err == nil {
|
||||
offers = append(offers, clipboardstore.Offer{MimeType: "image/" + imgMime, Data: fileData})
|
||||
}
|
||||
|
||||
m.takeSelection(offers)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -531,47 +531,6 @@ func TestManager_ConcurrentOfferAccess(t *testing.T) {
|
||||
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{}
|
||||
|
||||
|
||||
@@ -132,15 +132,9 @@ type Manager struct {
|
||||
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
|
||||
isOwner bool
|
||||
ownerLock sync.Mutex
|
||||
pasteSupported bool
|
||||
|
||||
initialized bool
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
|
||||
@@ -100,6 +101,41 @@ func (m *Manager) initializeSettings() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpectColorSchemeEcho registers a self-write so the watcher swallows its SettingChanged echo instead of forwarding it as an external change.
|
||||
func (m *Manager) ExpectColorSchemeEcho(scheme string) {
|
||||
var value uint32
|
||||
switch scheme {
|
||||
case "prefer-dark":
|
||||
value = 1
|
||||
case "prefer-light":
|
||||
value = 2
|
||||
}
|
||||
m.selfEchoMu.Lock()
|
||||
m.selfEchoes = append(m.selfEchoes, colorSchemeEcho{value: value, expires: time.Now().Add(10 * time.Second)})
|
||||
m.selfEchoMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) consumeSelfEcho(value uint32) bool {
|
||||
m.selfEchoMu.Lock()
|
||||
defer m.selfEchoMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
kept := m.selfEchoes[:0]
|
||||
consumed := false
|
||||
for _, echo := range m.selfEchoes {
|
||||
if now.After(echo.expires) {
|
||||
continue
|
||||
}
|
||||
if !consumed && echo.value == value {
|
||||
consumed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, echo)
|
||||
}
|
||||
m.selfEchoes = kept
|
||||
return consumed
|
||||
}
|
||||
|
||||
func (m *Manager) watchSettingsChanges() {
|
||||
conn, err := dbus.ConnectSessionBus()
|
||||
if err != nil {
|
||||
@@ -142,13 +178,15 @@ func (m *Manager) watchSettingsChanges() {
|
||||
continue
|
||||
}
|
||||
|
||||
selfInflicted := m.consumeSelfEcho(colorScheme)
|
||||
|
||||
m.stateMutex.Lock()
|
||||
changed := m.state.Settings.ColorScheme != colorScheme || !m.state.Settings.Available
|
||||
m.state.Settings.ColorScheme = colorScheme
|
||||
m.state.Settings.Available = true
|
||||
m.stateMutex.Unlock()
|
||||
|
||||
if changed {
|
||||
if changed && !selfInflicted {
|
||||
m.NotifySubscribers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,3 +141,45 @@ func TestManager_SettingsState_Modification(t *testing.T) {
|
||||
original := manager.GetState()
|
||||
assert.Equal(t, uint32(0), original.Settings.ColorScheme)
|
||||
}
|
||||
|
||||
func TestManager_SelfEcho_ConsumesRegisteredWrites(t *testing.T) {
|
||||
manager := &Manager{state: &FreedeskState{}}
|
||||
|
||||
manager.ExpectColorSchemeEcho("prefer-dark")
|
||||
manager.ExpectColorSchemeEcho("default")
|
||||
|
||||
assert.True(t, manager.consumeSelfEcho(1))
|
||||
assert.True(t, manager.consumeSelfEcho(0))
|
||||
assert.False(t, manager.consumeSelfEcho(1))
|
||||
assert.False(t, manager.consumeSelfEcho(0))
|
||||
}
|
||||
|
||||
func TestManager_SelfEcho_ExternalChangePassesThrough(t *testing.T) {
|
||||
manager := &Manager{state: &FreedeskState{}}
|
||||
|
||||
manager.ExpectColorSchemeEcho("prefer-dark")
|
||||
|
||||
assert.False(t, manager.consumeSelfEcho(2))
|
||||
assert.True(t, manager.consumeSelfEcho(1))
|
||||
}
|
||||
|
||||
func TestManager_SelfEcho_ConsumesOnePerRegistration(t *testing.T) {
|
||||
manager := &Manager{state: &FreedeskState{}}
|
||||
|
||||
manager.ExpectColorSchemeEcho("prefer-dark")
|
||||
manager.ExpectColorSchemeEcho("prefer-dark")
|
||||
|
||||
assert.True(t, manager.consumeSelfEcho(1))
|
||||
assert.True(t, manager.consumeSelfEcho(1))
|
||||
assert.False(t, manager.consumeSelfEcho(1))
|
||||
}
|
||||
|
||||
func TestManager_SelfEcho_SchemeMapping(t *testing.T) {
|
||||
manager := &Manager{state: &FreedeskState{}}
|
||||
|
||||
manager.ExpectColorSchemeEcho("prefer-light")
|
||||
assert.True(t, manager.consumeSelfEcho(2))
|
||||
|
||||
manager.ExpectColorSchemeEcho("default")
|
||||
assert.True(t, manager.consumeSelfEcho(0))
|
||||
}
|
||||
|
||||
@@ -2,11 +2,17 @@ package freedesktop
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
type colorSchemeEcho struct {
|
||||
value uint32
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type AccountsState struct {
|
||||
Available bool `json:"available"`
|
||||
UserPath string `json:"userPath"`
|
||||
@@ -63,4 +69,6 @@ type Manager struct {
|
||||
screensaverCookieCounter uint32
|
||||
screensaverFreedesktopClaimed bool
|
||||
screensaverGnomeClaimed bool
|
||||
selfEchoMu sync.Mutex
|
||||
selfEchoes []colorSchemeEcho
|
||||
}
|
||||
|
||||
@@ -46,6 +46,19 @@ func (m *Manager) Activate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetLockedHint(locked bool) error {
|
||||
err := m.sessionObj.Call(dbusSessionInterface+".SetLockedHint", 0, locked).Err
|
||||
if err != nil {
|
||||
if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
|
||||
err = m.sessionObj.Call(dbusSessionInterface+".SetLockedHint", 0, locked).Err
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set locked hint: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetIdleHint(idle bool) error {
|
||||
err := m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err
|
||||
if err != nil {
|
||||
|
||||
@@ -21,6 +21,8 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
||||
handleActivate(conn, req, manager)
|
||||
case "loginctl.setIdleHint":
|
||||
handleSetIdleHint(conn, req, manager)
|
||||
case "loginctl.setLockedHint":
|
||||
handleSetLockedHint(conn, req, manager)
|
||||
case "loginctl.setLockBeforeSuspend":
|
||||
handleSetLockBeforeSuspend(conn, req, manager)
|
||||
case "loginctl.setSleepInhibitorEnabled":
|
||||
@@ -78,6 +80,20 @@ func handleSetIdleHint(conn net.Conn, req models.Request, manager *Manager) {
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "idle hint set"})
|
||||
}
|
||||
|
||||
func handleSetLockedHint(conn net.Conn, req models.Request, manager *Manager) {
|
||||
locked, err := params.Bool(req.Params, "locked")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := manager.SetLockedHint(locked); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "locked hint set"})
|
||||
}
|
||||
|
||||
func handleSetLockBeforeSuspend(conn net.Conn, req models.Request, manager *Manager) {
|
||||
enabled, err := params.Bool(req.Params, "enabled")
|
||||
if err != nil {
|
||||
|
||||
@@ -545,12 +545,18 @@ func (a *SecretAgent) GetSecrets(
|
||||
out[settingName] = sec
|
||||
}
|
||||
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) {
|
||||
pw := reply.Secrets["password"]
|
||||
secrets := make(map[string]string)
|
||||
for k, v := range reply.Secrets {
|
||||
if k != "username" {
|
||||
secrets[k] = v
|
||||
}
|
||||
}
|
||||
a.backend.pendingVPNSaveMu.Lock()
|
||||
a.backend.pendingVPNSave = &pendingVPNCredentials{
|
||||
ConnectionPath: string(path),
|
||||
Username: vpnUsername,
|
||||
Password: pw,
|
||||
Password: reply.Secrets["password"],
|
||||
Secrets: secrets,
|
||||
SavePassword: reply.Save,
|
||||
}
|
||||
a.backend.pendingVPNSaveMu.Unlock()
|
||||
@@ -790,7 +796,23 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
|
||||
fields = []string{"username", "password"}
|
||||
}
|
||||
case strings.Contains(vpnService, "openvpn"):
|
||||
if connType == "password" || connType == "password-tls" {
|
||||
switch connType {
|
||||
case "tls":
|
||||
// Cert auth wants the private-key passphrase, not a password.
|
||||
if secretFlagsNotRequired(dataMap["cert-pass-flags"]) {
|
||||
return nil
|
||||
}
|
||||
return []string{"cert-pass"}
|
||||
case "password-tls":
|
||||
fields = []string{}
|
||||
if dataMap["username"] == "" {
|
||||
fields = append(fields, "username")
|
||||
}
|
||||
fields = append(fields, "password")
|
||||
if !secretFlagsNotRequired(dataMap["cert-pass-flags"]) {
|
||||
fields = append(fields, "cert-pass")
|
||||
}
|
||||
case "password":
|
||||
if dataMap["username"] == "" {
|
||||
fields = []string{"username", "password"}
|
||||
}
|
||||
@@ -805,6 +827,19 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
|
||||
return fields
|
||||
}
|
||||
|
||||
// NetworkManager secret flags: bit 1 (value 2) marks a secret as not-saved,
|
||||
// bit 2 (value 4) as not-required. A not-required secret must not be prompted.
|
||||
func secretFlagsNotRequired(flags string) bool {
|
||||
if flags == "" {
|
||||
return false
|
||||
}
|
||||
n, err := strconv.Atoi(flags)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return n&0x4 != 0
|
||||
}
|
||||
|
||||
func needsExternalBrowserAuth(protocol, authType, username string, data map[string]string) bool {
|
||||
if method, ok := data["saml-auth-method"]; ok {
|
||||
if method == "REDIRECT" || method == "POST" {
|
||||
|
||||
@@ -315,6 +315,44 @@ func TestInferVPNFields_GPSaml(t *testing.T) {
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"password"},
|
||||
},
|
||||
{
|
||||
name: "OpenVPN cert auth (tls) - private-key passphrase",
|
||||
vpnService: "org.freedesktop.NetworkManager.openvpn",
|
||||
dataMap: map[string]string{
|
||||
"connection-type": "tls",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"cert-pass"},
|
||||
},
|
||||
{
|
||||
name: "OpenVPN cert auth (tls) with passphrase-less key",
|
||||
vpnService: "org.freedesktop.NetworkManager.openvpn",
|
||||
dataMap: map[string]string{
|
||||
"connection-type": "tls",
|
||||
"cert-pass-flags": "4",
|
||||
},
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "OpenVPN password-tls no username",
|
||||
vpnService: "org.freedesktop.NetworkManager.openvpn",
|
||||
dataMap: map[string]string{
|
||||
"connection-type": "password-tls",
|
||||
},
|
||||
expectedLen: 3,
|
||||
shouldHave: []string{"username", "password", "cert-pass"},
|
||||
},
|
||||
{
|
||||
name: "OpenVPN password-tls with username, passphrase-less key",
|
||||
vpnService: "org.freedesktop.NetworkManager.openvpn",
|
||||
dataMap: map[string]string{
|
||||
"connection-type": "password-tls",
|
||||
"username": "john",
|
||||
"cert-pass-flags": "4",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"password"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -114,6 +114,14 @@ func (b *IWDBackend) discoverDevices() error {
|
||||
return fmt.Errorf("failed to get managed objects: %w", err)
|
||||
}
|
||||
|
||||
return b.applyManagedObjects(objects)
|
||||
}
|
||||
|
||||
func (b *IWDBackend) applyManagedObjects(objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant) error {
|
||||
b.stationPath = ""
|
||||
b.devicePath = ""
|
||||
b.adapterPath = ""
|
||||
|
||||
for path, interfaces := range objects {
|
||||
if _, hasStation := interfaces[iwdStationInterface]; hasStation {
|
||||
b.stationPath = path
|
||||
@@ -129,6 +137,13 @@ func (b *IWDBackend) discoverDevices() error {
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
}
|
||||
if poweredVar, ok := devProps["Powered"]; ok {
|
||||
if powered, ok := poweredVar.Value().(bool); ok {
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiEnabled = powered
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, hasAdapter := interfaces[iwdAdapterInterface]; hasAdapter {
|
||||
@@ -136,9 +151,18 @@ func (b *IWDBackend) discoverDevices() error {
|
||||
}
|
||||
}
|
||||
|
||||
if b.stationPath == "" || b.devicePath == "" {
|
||||
if b.devicePath == "" {
|
||||
return fmt.Errorf("no WiFi device found")
|
||||
}
|
||||
if b.stationPath == "" {
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiEnabled = false
|
||||
b.state.WiFiConnected = false
|
||||
b.state.NetworkStatus = StatusDisconnected
|
||||
b.state.WiFiNetworks = nil
|
||||
b.stateMutex.Unlock()
|
||||
log.Infof("iwd device %s has no station interface; treating WiFi as disabled", b.devicePath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
@@ -35,12 +36,7 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
|
||||
}
|
||||
|
||||
if b.stationPath != "" {
|
||||
err := b.conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(b.stationPath),
|
||||
dbus.WithMatchInterface(dbusPropertiesInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
if err != nil {
|
||||
if err := b.addStationSignalMatch(b.stationPath); err != nil {
|
||||
return fmt.Errorf("failed to add station signal match: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +77,48 @@ func (b *IWDBackend) refreshWiFiNetworkState() bool {
|
||||
return b.updateSavedWiFiNetworks() == nil
|
||||
}
|
||||
|
||||
func (b *IWDBackend) addStationSignalMatch(path dbus.ObjectPath) error {
|
||||
return b.conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(path),
|
||||
dbus.WithMatchInterface(dbusPropertiesInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
}
|
||||
|
||||
func (b *IWDBackend) handleStationAdded(path dbus.ObjectPath) bool {
|
||||
if path == "" || path == b.stationPath {
|
||||
return false
|
||||
}
|
||||
|
||||
b.stationPath = path
|
||||
if err := b.addStationSignalMatch(path); err != nil {
|
||||
log.Warnf("Failed to add iwd station signal match for %s: %v", path, err)
|
||||
}
|
||||
|
||||
if err := b.updateState(); err != nil {
|
||||
log.Warnf("Failed to update iwd state after station appeared: %v", err)
|
||||
}
|
||||
return b.refreshWiFiNetworkState()
|
||||
}
|
||||
|
||||
func (b *IWDBackend) handleStationRemoved(path dbus.ObjectPath) bool {
|
||||
if path == "" || path != b.stationPath {
|
||||
return false
|
||||
}
|
||||
|
||||
b.stationPath = ""
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiEnabled = false
|
||||
b.state.WiFiConnected = false
|
||||
b.state.WiFiSSID = ""
|
||||
b.state.WiFiSignal = 0
|
||||
b.state.NetworkStatus = StatusDisconnected
|
||||
b.state.WiFiNetworks = nil
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
defer b.sigWG.Done()
|
||||
|
||||
@@ -98,7 +136,13 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
|
||||
if sig.Name == dbusObjectManager+".InterfacesAdded" {
|
||||
if len(sig.Body) >= 2 {
|
||||
path, _ := sig.Body[0].(dbus.ObjectPath)
|
||||
if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok {
|
||||
if _, ok := interfaces[iwdStationInterface]; ok {
|
||||
if b.handleStationAdded(path) && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
}
|
||||
if _, ok := interfaces[iwdKnownNetworkInterface]; ok {
|
||||
if b.refreshWiFiNetworkState() && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
@@ -111,8 +155,15 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
|
||||
if sig.Name == dbusObjectManager+".InterfacesRemoved" {
|
||||
if len(sig.Body) >= 2 {
|
||||
path, _ := sig.Body[0].(dbus.ObjectPath)
|
||||
if interfaces, ok := sig.Body[1].([]string); ok {
|
||||
for _, iface := range interfaces {
|
||||
if iface == iwdStationInterface {
|
||||
if b.handleStationRemoved(path) && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
break
|
||||
}
|
||||
if iface == iwdKnownNetworkInterface {
|
||||
if b.refreshWiFiNetworkState() && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
|
||||
@@ -169,6 +169,41 @@ func TestIWDBackend_MapIwdDBusError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIWDBackend_ApplyManagedObjects_DeviceWithoutStation(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
|
||||
err := backend.applyManagedObjects(map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
|
||||
"/net/connman/iwd/0/1": {
|
||||
iwdDeviceInterface: {
|
||||
"Name": dbus.MakeVariant("wlan0"),
|
||||
"Powered": dbus.MakeVariant(false),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, dbus.ObjectPath("/net/connman/iwd/0/1"), backend.devicePath)
|
||||
assert.Empty(t, backend.stationPath)
|
||||
|
||||
state, err := backend.GetCurrentState()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "wlan0", state.WiFiDevice)
|
||||
assert.False(t, state.WiFiEnabled)
|
||||
assert.False(t, state.WiFiConnected)
|
||||
}
|
||||
|
||||
func TestIWDBackend_ApplyManagedObjects_NoDevice(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
|
||||
err := backend.applyManagedObjects(map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
|
||||
"/net/connman/iwd/0": {
|
||||
iwdAdapterInterface: {},
|
||||
},
|
||||
})
|
||||
|
||||
assert.ErrorContains(t, err, "no WiFi device found")
|
||||
}
|
||||
|
||||
func TestIWDSavedWiFiProfilesFromManagedObjects(t *testing.T) {
|
||||
objects := map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
|
||||
"/net/connman/iwd/known_network/1": {
|
||||
|
||||
@@ -90,7 +90,10 @@ type pendingVPNCredentials struct {
|
||||
ConnectionPath string
|
||||
Username string
|
||||
Password string
|
||||
SavePassword bool
|
||||
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
|
||||
// falls back to Password under the "password" key when empty.
|
||||
Secrets map[string]string
|
||||
SavePassword bool
|
||||
}
|
||||
|
||||
type cachedVPNCredentials struct {
|
||||
|
||||
@@ -863,13 +863,17 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
|
||||
log.Infof("[saveVPNCredentials] Saving username")
|
||||
}
|
||||
|
||||
// Save password if requested
|
||||
// Save secrets if requested
|
||||
if creds.SavePassword {
|
||||
data["password-flags"] = "0"
|
||||
secs := make(map[string]string)
|
||||
secs["password"] = creds.Password
|
||||
secs := creds.Secrets
|
||||
if len(secs) == 0 {
|
||||
secs = map[string]string{"password": creds.Password}
|
||||
}
|
||||
for field := range secs {
|
||||
data[field+"-flags"] = "0"
|
||||
}
|
||||
vpn["secrets"] = dbus.MakeVariant(secs)
|
||||
log.Infof("[saveVPNCredentials] Saving password with password-flags=0")
|
||||
log.Infof("[saveVPNCredentials] Saving %d secret field(s) with flags=0", len(secs))
|
||||
}
|
||||
|
||||
vpn["data"] = dbus.MakeVariant(data)
|
||||
|
||||
@@ -42,12 +42,15 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
|
||||
for _, id := range installedNames {
|
||||
if plugin, ok := pluginMap[id]; ok {
|
||||
hasUpdate := false
|
||||
if hasUpdates, err := manager.HasUpdates(id, plugin); err == nil {
|
||||
diffURL := plugin.Repo
|
||||
if hasUpdates, dURL, err := manager.HasUpdates(id, plugin); err == nil {
|
||||
hasUpdate = hasUpdates
|
||||
diffURL = dURL
|
||||
}
|
||||
|
||||
info := pluginInfoFromPlugin(plugin)
|
||||
info.HasUpdate = hasUpdate
|
||||
info.DiffURL = diffURL
|
||||
result = append(result, info)
|
||||
} else {
|
||||
result = append(result, PluginInfo{
|
||||
|
||||
@@ -18,6 +18,7 @@ type PluginInfo struct {
|
||||
Note string `json:"note,omitempty"`
|
||||
HasUpdate bool `json:"hasUpdate,omitempty"`
|
||||
RequiresDMS string `json:"requires_dms,omitempty"`
|
||||
DiffURL string `json:"diffUrl,omitempty"`
|
||||
Upvotes int `json:"upvotes,omitempty"`
|
||||
Status []string `json:"status,omitempty"`
|
||||
IssueURL string `json:"issueUrl,omitempty"`
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
@@ -190,6 +191,7 @@ func InitializeFreedeskManager() error {
|
||||
}
|
||||
|
||||
freedesktopManager = manager
|
||||
matugen.SetColorSchemeEchoHook(manager.ExpectColorSchemeEcho)
|
||||
|
||||
log.Info("Freedesktop manager initialized")
|
||||
return nil
|
||||
|
||||
@@ -16,6 +16,10 @@ type activeSchedule struct {
|
||||
nextFire time.Time
|
||||
}
|
||||
|
||||
// Grace period before a missed-while-off schedule fires, so a freshly
|
||||
// connected frontend has its cycle-seq baseline before the bump arrives.
|
||||
var catchUpDelay = 3 * time.Second
|
||||
|
||||
type Manager struct {
|
||||
config Config
|
||||
configMutex sync.RWMutex
|
||||
@@ -29,9 +33,20 @@ type Manager struct {
|
||||
updateTrigger chan struct{}
|
||||
resetReq chan string
|
||||
wg sync.WaitGroup
|
||||
|
||||
// Owned by schedulerLoop after start; keyed like schedules ("" = global).
|
||||
lastFires map[string]time.Time
|
||||
persistFires func(map[string]time.Time)
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return newManager(loadLastFires(), saveLastFires)
|
||||
}
|
||||
|
||||
func newManager(lastFires map[string]time.Time, persistFires func(map[string]time.Time)) *Manager {
|
||||
if lastFires == nil {
|
||||
lastFires = map[string]time.Time{}
|
||||
}
|
||||
m := &Manager{
|
||||
config: Config{
|
||||
Global: ScheduleConfig{Mode: "interval", IntervalSec: 300, Time: "06:00"},
|
||||
@@ -40,6 +55,8 @@ func NewManager() *Manager {
|
||||
stopChan: make(chan struct{}),
|
||||
updateTrigger: make(chan struct{}, 1),
|
||||
resetReq: make(chan string, 8),
|
||||
lastFires: lastFires,
|
||||
persistFires: persistFires,
|
||||
}
|
||||
m.state = &State{Config: m.getConfig()}
|
||||
|
||||
@@ -115,9 +132,7 @@ func (m *Manager) Close() {
|
||||
|
||||
func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
|
||||
ch := lm.Subscribe("wallpaper")
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
m.wg.Go(func() {
|
||||
defer lm.Unsubscribe("wallpaper")
|
||||
for {
|
||||
select {
|
||||
@@ -133,7 +148,7 @@ func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
|
||||
m.TriggerUpdate()
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) schedulerLoop() {
|
||||
@@ -154,11 +169,26 @@ func (m *Manager) schedulerLoop() {
|
||||
delete(schedules, key)
|
||||
}
|
||||
}
|
||||
firesDirty := false
|
||||
for key, cfg := range active {
|
||||
s, ok := schedules[key]
|
||||
switch {
|
||||
case !ok:
|
||||
schedules[key] = &activeSchedule{cfg: cfg, nextFire: computeNext(now, cfg)}
|
||||
s = &activeSchedule{cfg: cfg, nextFire: computeNext(now, cfg)}
|
||||
schedules[key] = s
|
||||
if cfg.Mode == "time" {
|
||||
last := m.lastFires[key]
|
||||
prev, valid := prevDailyTime(now, cfg.Time)
|
||||
switch {
|
||||
case last.IsZero():
|
||||
// First enable (or no history): seed instead of firing.
|
||||
m.lastFires[key] = now
|
||||
firesDirty = true
|
||||
case valid && last.Before(prev):
|
||||
// Scheduled time passed while dms wasn't running.
|
||||
s.nextFire = now.Add(catchUpDelay)
|
||||
}
|
||||
}
|
||||
case s.cfg != cfg || resets[key]:
|
||||
s.cfg = cfg
|
||||
s.nextFire = computeNext(now, cfg)
|
||||
@@ -171,9 +201,22 @@ func (m *Manager) schedulerLoop() {
|
||||
if !s.nextFire.After(now) {
|
||||
dueKeys = append(dueKeys, key)
|
||||
s.nextFire = computeNext(now, s.cfg)
|
||||
if s.cfg.Mode == "time" {
|
||||
m.lastFires[key] = now
|
||||
firesDirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if firesDirty {
|
||||
for key := range m.lastFires {
|
||||
if _, ok := schedules[key]; !ok {
|
||||
delete(m.lastFires, key)
|
||||
}
|
||||
}
|
||||
m.persistFires(m.lastFires)
|
||||
}
|
||||
|
||||
next, hasNext := soonest(schedules)
|
||||
if len(dueKeys) == 0 {
|
||||
m.setState(config, next, seq, "")
|
||||
@@ -185,10 +228,7 @@ func (m *Manager) schedulerLoop() {
|
||||
|
||||
waitDur := 24 * time.Hour
|
||||
if hasNext {
|
||||
waitDur = time.Until(next)
|
||||
if waitDur < time.Second {
|
||||
waitDur = time.Second
|
||||
}
|
||||
waitDur = max(time.Until(next), time.Second)
|
||||
}
|
||||
|
||||
if timer != nil {
|
||||
@@ -262,10 +302,7 @@ func computeNext(now time.Time, cfg ScheduleConfig) time.Time {
|
||||
case "time":
|
||||
return nextDailyTime(now, cfg.Time)
|
||||
default:
|
||||
sec := cfg.IntervalSec
|
||||
if sec < 1 {
|
||||
sec = 1
|
||||
}
|
||||
sec := max(cfg.IntervalSec, 1)
|
||||
return now.Add(time.Duration(sec) * time.Second)
|
||||
}
|
||||
}
|
||||
@@ -282,6 +319,18 @@ func nextDailyTime(now time.Time, hhmm string) time.Time {
|
||||
return next
|
||||
}
|
||||
|
||||
func prevDailyTime(now time.Time, hhmm string) (time.Time, bool) {
|
||||
hour, minute, ok := parseHHMM(hhmm)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
prev := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
||||
if prev.After(now) {
|
||||
prev = prev.Add(-24 * time.Hour)
|
||||
}
|
||||
return prev, true
|
||||
}
|
||||
|
||||
func parseHHMM(hhmm string) (int, int, bool) {
|
||||
parts := strings.Split(hhmm, ":")
|
||||
if len(parts) != 2 {
|
||||
|
||||
@@ -83,8 +83,81 @@ func TestActiveSchedules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrevDailyTime(t *testing.T) {
|
||||
now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
earlier, ok := prevDailyTime(now, "06:00")
|
||||
if !ok || !earlier.Equal(time.Date(2026, 6, 30, 6, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("prev (today) = %v, %v", earlier, ok)
|
||||
}
|
||||
|
||||
later, ok := prevDailyTime(now, "18:00")
|
||||
if !ok || !later.Equal(time.Date(2026, 6, 29, 18, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("prev (yesterday) = %v, %v", later, ok)
|
||||
}
|
||||
|
||||
if _, ok := prevDailyTime(now, "bad"); ok {
|
||||
t.Error("prev (invalid) should not be ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeScheduleCatchUpAfterRestart(t *testing.T) {
|
||||
origDelay := catchUpDelay
|
||||
catchUpDelay = 100 * time.Millisecond
|
||||
defer func() { catchUpDelay = origDelay }()
|
||||
|
||||
now := time.Now()
|
||||
hhmm := now.Add(-2 * time.Hour).Format("15:04")
|
||||
seed := map[string]time.Time{"": now.Add(-26 * time.Hour)}
|
||||
|
||||
m := newManager(seed, func(map[string]time.Time) {})
|
||||
defer m.Close()
|
||||
|
||||
sub := m.Subscribe("test")
|
||||
defer m.Unsubscribe("test")
|
||||
|
||||
m.SetConfig(Config{Global: ScheduleConfig{Enabled: true, Mode: "time", Time: hhmm}})
|
||||
|
||||
deadline := time.After(3 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case state := <-sub:
|
||||
if state.CycleSeq > 0 && state.Target == "" {
|
||||
return
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("missed time schedule did not catch up within 3s")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeScheduleNoFireOnFirstEnable(t *testing.T) {
|
||||
now := time.Now()
|
||||
hhmm := now.Add(-2 * time.Hour).Format("15:04")
|
||||
|
||||
m := newManager(map[string]time.Time{}, func(map[string]time.Time) {})
|
||||
defer m.Close()
|
||||
|
||||
sub := m.Subscribe("test")
|
||||
defer m.Unsubscribe("test")
|
||||
|
||||
m.SetConfig(Config{Global: ScheduleConfig{Enabled: true, Mode: "time", Time: hhmm}})
|
||||
|
||||
deadline := time.After(1500 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case state := <-sub:
|
||||
if state.CycleSeq > 0 {
|
||||
t.Fatal("first enable must not fire immediately")
|
||||
}
|
||||
case <-deadline:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerEmitsCycle(t *testing.T) {
|
||||
m := NewManager()
|
||||
m := newManager(map[string]time.Time{}, func(map[string]time.Time) {})
|
||||
defer m.Close()
|
||||
|
||||
sub := m.Subscribe("test")
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package wallpaper
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
type persistentState struct {
|
||||
LastFires map[string]time.Time `json:"lastFires"`
|
||||
}
|
||||
|
||||
func stateFilePath() string {
|
||||
return filepath.Join(utils.XDGCacheHome(), "dms", "wallpaper-schedule.json")
|
||||
}
|
||||
|
||||
func loadLastFires() map[string]time.Time {
|
||||
data, err := os.ReadFile(stateFilePath())
|
||||
if err != nil {
|
||||
return map[string]time.Time{}
|
||||
}
|
||||
var state persistentState
|
||||
if err := json.Unmarshal(data, &state); err != nil || state.LastFires == nil {
|
||||
return map[string]time.Time{}
|
||||
}
|
||||
return state.LastFires
|
||||
}
|
||||
|
||||
func saveLastFires(fires map[string]time.Time) {
|
||||
path := stateFilePath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(persistentState{LastFires: fires})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
log.Warnf("wallpaper: failed to persist schedule state: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -108,10 +108,7 @@ func (sc *SharedContext) eventDispatcher() {
|
||||
if r := recover(); r != nil {
|
||||
err := fmt.Errorf("FATAL: Wayland event dispatcher panic: %v", r)
|
||||
log.Error(err)
|
||||
select {
|
||||
case sc.fatalError <- err:
|
||||
default:
|
||||
}
|
||||
sc.signalFatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -141,6 +138,7 @@ func (sc *SharedContext) eventDispatcher() {
|
||||
continue
|
||||
case err != nil:
|
||||
log.Errorf("Poll error: %v", err)
|
||||
sc.signalFatal(fmt.Errorf("wayland poll error: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -161,6 +159,7 @@ func (sc *SharedContext) eventDispatcher() {
|
||||
|
||||
if consecutiveErrors >= maxConsecutiveErrors {
|
||||
log.Errorf("Fatal: Wayland connection unrecoverable after %d attempts. Exiting dispatcher.", maxConsecutiveErrors)
|
||||
sc.signalFatal(fmt.Errorf("wayland connection unrecoverable after %d dispatch failures: %w", maxConsecutiveErrors, err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -172,6 +171,13 @@ func (sc *SharedContext) eventDispatcher() {
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SharedContext) signalFatal(err error) {
|
||||
select {
|
||||
case sc.fatalError <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SharedContext) drainCmdQueue() {
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// Package traywatcher implements a minimal org.kde.StatusNotifierWatcher that
|
||||
// claims the SNI name early in the session so autostart tray apps can register
|
||||
// before the shell finishes loading. See docs/TRAY_WATCHER.md.
|
||||
package traywatcher
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/godbus/dbus/v5/introspect"
|
||||
"github.com/godbus/dbus/v5/prop"
|
||||
)
|
||||
|
||||
const (
|
||||
busName = "org.kde.StatusNotifierWatcher"
|
||||
objPath = dbus.ObjectPath("/StatusNotifierWatcher")
|
||||
iface = "org.kde.StatusNotifierWatcher"
|
||||
)
|
||||
|
||||
const introXML = `<node>
|
||||
<interface name="org.kde.StatusNotifierWatcher">
|
||||
<method name="RegisterStatusNotifierItem">
|
||||
<arg type="s" direction="in" name="service"/>
|
||||
</method>
|
||||
<method name="RegisterStatusNotifierHost">
|
||||
<arg type="s" direction="in" name="service"/>
|
||||
</method>
|
||||
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
|
||||
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
|
||||
<property name="ProtocolVersion" type="i" access="read"/>
|
||||
<signal name="StatusNotifierItemRegistered">
|
||||
<arg type="s" name="service"/>
|
||||
</signal>
|
||||
<signal name="StatusNotifierItemUnregistered">
|
||||
<arg type="s" name="service"/>
|
||||
</signal>
|
||||
<signal name="StatusNotifierHostRegistered"/>
|
||||
<signal name="StatusNotifierHostUnregistered"/>
|
||||
</interface>
|
||||
` + introspect.IntrospectDataString + prop.IntrospectDataString + `</node>`
|
||||
|
||||
type watcher struct {
|
||||
conn *dbus.Conn
|
||||
props *prop.Properties
|
||||
mu sync.Mutex
|
||||
items map[string]string // item id ("name/path") -> bus name to watch
|
||||
hosts map[string]bool
|
||||
}
|
||||
|
||||
func (w *watcher) itemListLocked() []string {
|
||||
list := make([]string, 0, len(w.items))
|
||||
for item := range w.items {
|
||||
list = append(list, item)
|
||||
}
|
||||
sort.Strings(list)
|
||||
return list
|
||||
}
|
||||
|
||||
func (w *watcher) emit(signal string, args ...any) {
|
||||
if err := w.conn.Emit(objPath, iface+"."+signal, args...); err != nil {
|
||||
log.Warnf("failed to emit %s: %v", signal, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clients pass either an object path (item on the caller) or a bus name.
|
||||
func (w *watcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error {
|
||||
var item, watch string
|
||||
if strings.HasPrefix(service, "/") {
|
||||
item, watch = string(sender)+service, string(sender)
|
||||
} else {
|
||||
item, watch = service+"/StatusNotifierItem", service
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
if _, exists := w.items[item]; exists {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
w.items[item] = watch
|
||||
list := w.itemListLocked()
|
||||
w.mu.Unlock()
|
||||
|
||||
log.Infof("item registered: %s", item)
|
||||
w.emit("StatusNotifierItemRegistered", item)
|
||||
w.props.SetMust(iface, "RegisteredStatusNotifierItems", list)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *watcher) RegisterStatusNotifierHost(sender dbus.Sender, service string) *dbus.Error {
|
||||
watch := string(sender)
|
||||
if service != "" && !strings.HasPrefix(service, "/") {
|
||||
watch = service
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
if w.hosts[watch] {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
w.hosts[watch] = true
|
||||
w.mu.Unlock()
|
||||
|
||||
log.Infof("host registered: %s", watch)
|
||||
w.emit("StatusNotifierHostRegistered")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *watcher) pruneOwner(name string) {
|
||||
w.mu.Lock()
|
||||
var removed []string
|
||||
for item, watch := range w.items {
|
||||
if watch == name {
|
||||
delete(w.items, item)
|
||||
removed = append(removed, item)
|
||||
}
|
||||
}
|
||||
list := w.itemListLocked()
|
||||
hostGone := w.hosts[name]
|
||||
delete(w.hosts, name)
|
||||
w.mu.Unlock()
|
||||
|
||||
for _, item := range removed {
|
||||
log.Infof("item gone: %s", item)
|
||||
w.emit("StatusNotifierItemUnregistered", item)
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
w.props.SetMust(iface, "RegisteredStatusNotifierItems", list)
|
||||
}
|
||||
if hostGone {
|
||||
log.Infof("host gone: %s", name)
|
||||
w.emit("StatusNotifierHostUnregistered")
|
||||
}
|
||||
}
|
||||
|
||||
// Run serves the watcher until the session bus connection closes.
|
||||
func Run() error {
|
||||
conn, err := dbus.ConnectSessionBus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to session bus: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
w := &watcher{conn: conn, items: map[string]string{}, hosts: map[string]bool{}}
|
||||
|
||||
if err := conn.Export(w, objPath, iface); err != nil {
|
||||
return fmt.Errorf("export watcher: %w", err)
|
||||
}
|
||||
w.props, err = prop.Export(conn, objPath, map[string]map[string]*prop.Prop{
|
||||
iface: {
|
||||
"RegisteredStatusNotifierItems": {Value: []string{}, Emit: prop.EmitTrue},
|
||||
// Always true: libappindicator clients drop the icon if it is false.
|
||||
"IsStatusNotifierHostRegistered": {Value: true, Emit: prop.EmitFalse},
|
||||
"ProtocolVersion": {Value: int32(0), Emit: prop.EmitFalse},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("export properties: %w", err)
|
||||
}
|
||||
if err := conn.Export(introspect.Introspectable(introXML), objPath, "org.freedesktop.DBus.Introspectable"); err != nil {
|
||||
return fmt.Errorf("export introspection: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchSender("org.freedesktop.DBus"),
|
||||
dbus.WithMatchInterface("org.freedesktop.DBus"),
|
||||
dbus.WithMatchMember("NameOwnerChanged"),
|
||||
dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("match NameOwnerChanged: %w", err)
|
||||
}
|
||||
sigs := make(chan *dbus.Signal, 128)
|
||||
conn.Signal(sigs)
|
||||
|
||||
// No flags: queue for the name and take it when the current owner exits.
|
||||
reply, err := conn.RequestName(busName, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request %s: %w", busName, err)
|
||||
}
|
||||
if reply == dbus.RequestNameReplyInQueue {
|
||||
log.Infof("name busy, queued for %s", busName)
|
||||
}
|
||||
|
||||
for sig := range sigs {
|
||||
switch sig.Name {
|
||||
case "org.freedesktop.DBus.NameAcquired":
|
||||
if len(sig.Body) == 1 && sig.Body[0] == busName {
|
||||
log.Infof("acquired %s", busName)
|
||||
}
|
||||
case "org.freedesktop.DBus.NameOwnerChanged":
|
||||
if len(sig.Body) != 3 {
|
||||
continue
|
||||
}
|
||||
name, _ := sig.Body[0].(string)
|
||||
newOwner, _ := sig.Body[2].(string)
|
||||
if name != busName && newOwner == "" {
|
||||
w.pruneOwner(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("session bus connection closed")
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AppChecker interface {
|
||||
@@ -31,7 +34,20 @@ func (DefaultAppChecker) AnyFlatpakExists(flatpaks ...string) bool {
|
||||
|
||||
func CommandExists(cmd string) bool {
|
||||
_, err := exec.LookPath(cmd)
|
||||
return err == nil
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if strings.ContainsRune(cmd, os.PathSeparator) {
|
||||
return false
|
||||
}
|
||||
for _, dir := range userBinDirs() {
|
||||
path := filepath.Join(dir, cmd)
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func AnyCommandExists(cmds ...string) bool {
|
||||
@@ -42,3 +58,58 @@ func AnyCommandExists(cmds ...string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func EnvWithUserBinPath(env []string) []string {
|
||||
if env == nil {
|
||||
env = os.Environ()
|
||||
}
|
||||
|
||||
out := append([]string(nil), env...)
|
||||
pathIndex := -1
|
||||
pathValue := ""
|
||||
for i, entry := range out {
|
||||
if strings.HasPrefix(entry, "PATH=") {
|
||||
pathIndex = i
|
||||
pathValue = strings.TrimPrefix(entry, "PATH=")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
parts := filepath.SplitList(pathValue)
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
seen[part] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
prepend := make([]string, 0, 2)
|
||||
for _, dir := range userBinDirs() {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[dir]; ok {
|
||||
continue
|
||||
}
|
||||
prepend = append(prepend, dir)
|
||||
seen[dir] = struct{}{}
|
||||
}
|
||||
|
||||
parts = append(prepend, parts...)
|
||||
newPath := "PATH=" + strings.Join(parts, string(os.PathListSeparator))
|
||||
if pathIndex >= 0 {
|
||||
out[pathIndex] = newPath
|
||||
} else {
|
||||
out = append(out, newPath)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func userBinDirs() []string {
|
||||
dirs := []string{}
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
dirs = append(dirs, filepath.Join(home, ".local", "bin"))
|
||||
}
|
||||
dirs = append(dirs, "/usr/local/bin")
|
||||
return dirs
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCommandExistsFallsBackToLocalBin(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
binDir := filepath.Join(home, ".local", "bin")
|
||||
require.NoError(t, os.MkdirAll(binDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(binDir, "pywalfox"), []byte("#!/bin/sh\n"), 0o755))
|
||||
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
assert.True(t, CommandExists("pywalfox"))
|
||||
}
|
||||
|
||||
func TestCommandExistsIgnoresNonExecutableLocalBinFile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
binDir := filepath.Join(home, ".local", "bin")
|
||||
require.NoError(t, os.MkdirAll(binDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(binDir, "pywalfox"), []byte("not executable"), 0o644))
|
||||
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
assert.False(t, CommandExists("pywalfox"))
|
||||
}
|
||||
|
||||
func TestEnvWithUserBinPathPrependsLocalBin(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
env := EnvWithUserBinPath([]string{"PATH=/usr/bin", "OTHER=value"})
|
||||
var pathValue string
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(entry, "PATH=") {
|
||||
pathValue = strings.TrimPrefix(entry, "PATH=")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
parts := filepath.SplitList(pathValue)
|
||||
require.NotEmpty(t, parts)
|
||||
assert.Equal(t, filepath.Join(home, ".local", "bin"), parts[0])
|
||||
assert.Contains(t, parts, "/usr/local/bin")
|
||||
assert.Contains(t, env, "OTHER=value")
|
||||
}
|
||||
@@ -72,8 +72,10 @@ override_dh_auto_install:
|
||||
if [ -d quickshell ]; then \
|
||||
cp -r quickshell/* debian/dms-git/usr/share/quickshell/dms/; \
|
||||
install -Dm644 assets/systemd/dms.service debian/dms-git/usr/lib/systemd/user/dms.service; \
|
||||
install -Dm644 assets/systemd/dms-tray-watcher.service debian/dms-git/usr/lib/systemd/user/dms-tray-watcher.service; \
|
||||
install -Dm644 assets/dms-open.desktop debian/dms-git/usr/share/applications/dms-open.desktop; \
|
||||
install -Dm644 assets/com.danklinux.dms.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.desktop; \
|
||||
install -Dm644 assets/com.danklinux.dms.notepad.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.notepad.desktop; \
|
||||
install -Dm644 assets/danklogo.svg debian/dms-git/usr/share/icons/hicolor/scalable/apps/danklogo.svg; \
|
||||
else \
|
||||
echo "ERROR: quickshell directory not found!" && \
|
||||
|
||||
@@ -65,8 +65,10 @@ override_dh_auto_install:
|
||||
if [ -d DankMaterialShell-$(UPSTREAM_VERSION) ]; then \
|
||||
cp -r DankMaterialShell-$(UPSTREAM_VERSION)/quickshell/* debian/dms/usr/share/quickshell/dms/; \
|
||||
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/systemd/dms.service debian/dms/usr/lib/systemd/user/dms.service; \
|
||||
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/systemd/dms-tray-watcher.service debian/dms/usr/lib/systemd/user/dms-tray-watcher.service; \
|
||||
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/dms-open.desktop debian/dms/usr/share/applications/dms-open.desktop; \
|
||||
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.desktop debian/dms/usr/share/applications/com.danklinux.dms.desktop; \
|
||||
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.notepad.desktop debian/dms/usr/share/applications/com.danklinux.dms.notepad.desktop; \
|
||||
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/danklogo.svg debian/dms/usr/share/icons/hicolor/scalable/apps/danklogo.svg; \
|
||||
else \
|
||||
echo "ERROR: DankMaterialShell-$(UPSTREAM_VERSION) directory not found!" && \
|
||||
|
||||
@@ -119,9 +119,11 @@ core/bin/${DMS_BINARY} completion fish > %{buildroot}%{_datadir}/fish/vendor_com
|
||||
|
||||
# Install systemd user service
|
||||
install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
|
||||
install -Dm644 assets/systemd/dms-tray-watcher.service %{buildroot}%{_userunitdir}/dms-tray-watcher.service
|
||||
|
||||
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
|
||||
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
|
||||
install -Dm644 assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
|
||||
install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
# Install shell files to shared data location
|
||||
@@ -144,8 +146,10 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
|
||||
%doc quickshell/README.md
|
||||
%{_datadir}/quickshell/dms/
|
||||
%{_userunitdir}/dms.service
|
||||
%{_userunitdir}/dms-tray-watcher.service
|
||||
%{_datadir}/applications/dms-open.desktop
|
||||
%{_datadir}/applications/com.danklinux.dms.desktop
|
||||
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
|
||||
%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
%files -n dms-cli
|
||||
|
||||
@@ -87,9 +87,11 @@ install -d %{buildroot}%{_datadir}/fish/vendor_completions.d
|
||||
%{_builddir}/dms-cli completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || :
|
||||
|
||||
install -Dm644 %{_builddir}/dms-qml/assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
|
||||
install -Dm644 %{_builddir}/dms-qml/assets/systemd/dms-tray-watcher.service %{buildroot}%{_userunitdir}/dms-tray-watcher.service
|
||||
|
||||
install -Dm644 %{_builddir}/dms-qml/assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
|
||||
install -Dm644 %{_builddir}/dms-qml/assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
|
||||
install -Dm644 %{_builddir}/dms-qml/assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
|
||||
install -Dm644 %{_builddir}/dms-qml/assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
install -dm755 %{buildroot}%{_datadir}/quickshell/dms
|
||||
@@ -111,8 +113,10 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
|
||||
%doc README.md CONTRIBUTING.md
|
||||
%{_datadir}/quickshell/dms/
|
||||
%{_userunitdir}/dms.service
|
||||
%{_userunitdir}/dms-tray-watcher.service
|
||||
%{_datadir}/applications/dms-open.desktop
|
||||
%{_datadir}/applications/com.danklinux.dms.desktop
|
||||
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
|
||||
%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
%files -n dms-cli
|
||||
|
||||
@@ -18,8 +18,7 @@ in
|
||||
]
|
||||
++ lib.optional cfg.enableDynamicTheming pkgs.matugen
|
||||
++ lib.optional cfg.enableAudioWavelength pkgs.cava
|
||||
++ lib.optional cfg.enableCalendarEvents pkgs.khal
|
||||
++ lib.optional cfg.enableClipboardPaste pkgs.wtype;
|
||||
++ lib.optional cfg.enableCalendarEvents pkgs.khal;
|
||||
|
||||
plugins = lib.mapAttrs (name: plugin: {
|
||||
source = plugin.src;
|
||||
|
||||
+49
-1
@@ -10,6 +10,8 @@ let
|
||||
inherit (lib) types;
|
||||
cfg = config.programs.dank-material-shell.greeter;
|
||||
cfgDms = config.programs.dank-material-shell;
|
||||
cfgAutoLogin = config.services.displayManager.autoLogin;
|
||||
sessionData = config.services.displayManager.sessionData;
|
||||
|
||||
inherit (config.services.greetd.settings.default_session) user;
|
||||
|
||||
@@ -26,6 +28,7 @@ let
|
||||
cfg.quickshell.package
|
||||
compositorPackage
|
||||
pkgs.glib # provides gdbus, used by the fprintd hardware probe in GreeterContent.qml
|
||||
pkgs.jq # reads the user's cursor theme from settings.json in dms-greeter
|
||||
]
|
||||
}
|
||||
${
|
||||
@@ -48,6 +51,38 @@ let
|
||||
} ${lib.optionalString cfg.logs.save "> ${cfg.logs.path} 2>&1"}
|
||||
'';
|
||||
|
||||
autoLoginCommand =
|
||||
pkgs.runCommand "dms-greeter-autologin-command"
|
||||
{
|
||||
nativeBuildInputs = [
|
||||
pkgs.gnugrep
|
||||
pkgs.coreutils
|
||||
];
|
||||
}
|
||||
''
|
||||
set -euo pipefail
|
||||
|
||||
session="${sessionData.autologinSession}"
|
||||
desktops="${sessionData.desktops}"
|
||||
|
||||
for sessionFile in \
|
||||
"$desktops/share/wayland-sessions/$session.desktop" \
|
||||
"$desktops/share/xsessions/$session.desktop"
|
||||
do
|
||||
if [ -f "$sessionFile" ]; then
|
||||
command="$(grep -m1 '^Exec=' "$sessionFile" | cut -d= -f2- || true)"
|
||||
|
||||
if [ -n "$command" ]; then
|
||||
printf '%s\n' "$command" > "$out"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "dms-greeter autologin: could not resolve Exec for session '$session'" >&2
|
||||
exit 1
|
||||
'';
|
||||
|
||||
jq = lib.getExe pkgs.jq;
|
||||
in
|
||||
{
|
||||
@@ -155,6 +190,13 @@ in
|
||||
dmsgreeter: user set for greetd default_session ${user} does not exist. Please create it before referencing it.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = cfgAutoLogin.enable -> sessionData.autologinSession != null;
|
||||
message = ''
|
||||
dms-greeter auto-login requires services.displayManager.defaultSession to be set,
|
||||
or at least one session in services.displayManager.sessionPackages.
|
||||
'';
|
||||
}
|
||||
];
|
||||
# DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS.
|
||||
# Declare security.pam.services.dankshell only if you want to override that runtime fallback.
|
||||
@@ -165,7 +207,13 @@ in
|
||||
# };
|
||||
services.greetd = {
|
||||
enable = lib.mkDefault true;
|
||||
settings.default_session.command = lib.mkDefault (lib.getExe greeterScript);
|
||||
settings = {
|
||||
default_session.command = lib.mkDefault (lib.getExe greeterScript);
|
||||
initial_session = lib.mkIf (cfgAutoLogin.enable && (cfgAutoLogin.user != null)) {
|
||||
inherit (cfgAutoLogin) user;
|
||||
command = ''${lib.getExe pkgs.bash} -lc "${pkgs.systemd}/bin/systemd-cat $(<${autoLoginCommand})"'';
|
||||
};
|
||||
};
|
||||
};
|
||||
fonts.packages = with pkgs; [
|
||||
fira-code
|
||||
|
||||
@@ -101,6 +101,24 @@ in
|
||||
Install.WantedBy = [ cfg.systemd.target ];
|
||||
};
|
||||
|
||||
# Early SNI watcher so autostart tray apps register before the shell loads.
|
||||
systemd.user.services.dms-tray-watcher = lib.mkIf cfg.systemd.enable {
|
||||
Unit = {
|
||||
Description = "DMS Status Notifier Watcher (early tray)";
|
||||
PartOf = [ cfg.systemd.target ];
|
||||
Before = [ cfg.systemd.target ];
|
||||
};
|
||||
|
||||
Service = {
|
||||
Type = "dbus";
|
||||
BusName = "org.kde.StatusNotifierWatcher";
|
||||
ExecStart = lib.getExe cfg.package + " tray-watcher";
|
||||
Restart = "on-failure";
|
||||
};
|
||||
|
||||
Install.WantedBy = [ cfg.systemd.target ];
|
||||
};
|
||||
|
||||
xdg.stateFile."DankMaterialShell/session.json" = lib.mkIf (cfg.session != { }) {
|
||||
source = jsonFormat.generate "session.json" cfg.session;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,24 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# Early SNI watcher so autostart tray apps register before the shell loads.
|
||||
systemd.user.services.dms-tray-watcher = lib.mkIf cfg.systemd.enable {
|
||||
description = "DMS Status Notifier Watcher (early tray)";
|
||||
path = lib.mkForce [ ];
|
||||
|
||||
partOf = [ cfg.systemd.target ];
|
||||
before = [ cfg.systemd.target ];
|
||||
wantedBy = [ cfg.systemd.target ];
|
||||
restartIfChanged = cfg.systemd.restartIfChanged;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "dbus";
|
||||
BusName = "org.kde.StatusNotifierWatcher";
|
||||
ExecStart = lib.getExe cfg.package + " tray-watcher";
|
||||
Restart = "on-failure";
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ cfg.quickshell.package ] ++ common.packages;
|
||||
|
||||
environment.etc = lib.mapAttrs' (name: value: {
|
||||
|
||||
@@ -76,7 +76,7 @@ in
|
||||
enableClipboardPaste = lib.mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Adds needed dependencies for directly pasting items from the clipboard history.";
|
||||
description = "Deprecated: paste is built into dms; no extra dependencies needed. Kept as a no-op for compatibility.";
|
||||
};
|
||||
|
||||
quickshell = {
|
||||
|
||||
@@ -16,8 +16,14 @@ pkgs.testers.runNixOSTest {
|
||||
isSystemUser = true;
|
||||
group = "greeter";
|
||||
};
|
||||
users.users.alice.isNormalUser = true;
|
||||
|
||||
services.greetd.settings.default_session.user = "greeter";
|
||||
services.displayManager.autoLogin = {
|
||||
enable = true;
|
||||
user = "alice";
|
||||
};
|
||||
services.displayManager.defaultSession = "niri";
|
||||
|
||||
programs.niri.enable = true;
|
||||
|
||||
@@ -46,6 +52,11 @@ pkgs.testers.runNixOSTest {
|
||||
greetd_config_path = config_match.group(1)
|
||||
greetd_config = machine.succeed(f"cat {greetd_config_path}")
|
||||
t.assertIn("dms-greeter", greetd_config)
|
||||
t.assertIn("[initial_session]", greetd_config)
|
||||
|
||||
initial_session = greetd_config.split("[initial_session]", 1)[1]
|
||||
t.assertIn('user = "alice"', initial_session)
|
||||
t.assertIn("systemd-cat", initial_session)
|
||||
|
||||
script_match = re.search(r'command\s*=\s*"([^"]+/bin/dms-greeter)"', greetd_config)
|
||||
if script_match is None:
|
||||
|
||||
@@ -25,7 +25,7 @@ Recommends: danksearch
|
||||
Recommends: matugen
|
||||
Recommends: quickshell-git
|
||||
Recommends: NetworkManager
|
||||
Recommends: qt6-qtmultimedia
|
||||
Recommends: qt6-multimedia-imports
|
||||
Suggests: cups-pk-helper
|
||||
Suggests: qt6ct
|
||||
|
||||
@@ -114,9 +114,11 @@ install -d %{buildroot}%{_datadir}/fish/vendor_completions.d
|
||||
./dms completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || :
|
||||
|
||||
install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
|
||||
install -Dm644 assets/systemd/dms-tray-watcher.service %{buildroot}%{_userunitdir}/dms-tray-watcher.service
|
||||
|
||||
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
|
||||
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
|
||||
install -Dm644 assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
|
||||
install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
install -dm755 %{buildroot}%{_datadir}/quickshell/dms
|
||||
@@ -151,8 +153,10 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
|
||||
%dir %{_datadir}/quickshell
|
||||
%{_datadir}/quickshell/dms/
|
||||
%{_userunitdir}/dms.service
|
||||
%{_userunitdir}/dms-tray-watcher.service
|
||||
%{_datadir}/applications/dms-open.desktop
|
||||
%{_datadir}/applications/com.danklinux.dms.desktop
|
||||
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
|
||||
%dir %{_datadir}/icons/hicolor
|
||||
%dir %{_datadir}/icons/hicolor/scalable
|
||||
%dir %{_datadir}/icons/hicolor/scalable/apps
|
||||
|
||||
@@ -26,7 +26,7 @@ Recommends: cava
|
||||
Recommends: danksearch
|
||||
Recommends: matugen
|
||||
Recommends: NetworkManager
|
||||
Recommends: qt6-qtmultimedia
|
||||
Recommends: qt6-multimedia-imports
|
||||
Suggests: cups-pk-helper
|
||||
Suggests: qt6ct
|
||||
|
||||
@@ -63,9 +63,11 @@ install -d %{buildroot}%{_datadir}/fish/vendor_completions.d
|
||||
./dms completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || :
|
||||
|
||||
install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
|
||||
install -Dm644 assets/systemd/dms-tray-watcher.service %{buildroot}%{_userunitdir}/dms-tray-watcher.service
|
||||
|
||||
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
|
||||
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
|
||||
install -Dm644 assets/com.danklinux.dms.notepad.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.notepad.desktop
|
||||
install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
install -dm755 %{buildroot}%{_datadir}/quickshell/dms
|
||||
@@ -98,8 +100,10 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
|
||||
%dir %{_datadir}/quickshell
|
||||
%{_datadir}/quickshell/dms/
|
||||
%{_userunitdir}/dms.service
|
||||
%{_userunitdir}/dms-tray-watcher.service
|
||||
%{_datadir}/applications/dms-open.desktop
|
||||
%{_datadir}/applications/com.danklinux.dms.desktop
|
||||
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
|
||||
%dir %{_datadir}/icons/hicolor
|
||||
%dir %{_datadir}/icons/hicolor/scalable
|
||||
%dir %{_datadir}/icons/hicolor/scalable/apps
|
||||
|
||||
@@ -69,12 +69,16 @@ override_dh_auto_install:
|
||||
# Install systemd user service
|
||||
install -Dm644 dms-git-repo/assets/systemd/dms.service \
|
||||
debian/dms-git/usr/lib/systemd/user/dms.service
|
||||
install -Dm644 dms-git-repo/assets/systemd/dms-tray-watcher.service \
|
||||
debian/dms-git/usr/lib/systemd/user/dms-tray-watcher.service
|
||||
|
||||
# Install desktop file and icon
|
||||
install -Dm644 dms-git-repo/assets/dms-open.desktop \
|
||||
debian/dms-git/usr/share/applications/dms-open.desktop
|
||||
install -Dm644 dms-git-repo/assets/com.danklinux.dms.desktop \
|
||||
debian/dms-git/usr/share/applications/com.danklinux.dms.desktop
|
||||
install -Dm644 dms-git-repo/assets/com.danklinux.dms.notepad.desktop \
|
||||
debian/dms-git/usr/share/applications/com.danklinux.dms.notepad.desktop
|
||||
install -Dm644 dms-git-repo/assets/danklogo.svg \
|
||||
debian/dms-git/usr/share/icons/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
|
||||
@@ -50,12 +50,16 @@ override_dh_auto_install:
|
||||
# Install systemd user service (before copying everything else)
|
||||
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/systemd/dms.service \
|
||||
debian/dms/usr/lib/systemd/user/dms.service
|
||||
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/systemd/dms-tray-watcher.service \
|
||||
debian/dms/usr/lib/systemd/user/dms-tray-watcher.service
|
||||
|
||||
# Install desktop file and icon
|
||||
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/dms-open.desktop \
|
||||
debian/dms/usr/share/applications/dms-open.desktop
|
||||
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/com.danklinux.dms.desktop \
|
||||
debian/dms/usr/share/applications/com.danklinux.dms.desktop
|
||||
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/com.danklinux.dms.notepad.desktop \
|
||||
debian/dms/usr/share/applications/com.danklinux.dms.notepad.desktop
|
||||
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/danklogo.svg \
|
||||
debian/dms/usr/share/icons/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ install whichever features you want:
|
||||
| `cava` | audio visualiser widget |
|
||||
| `qt6-multimedia` | system sound feedback |
|
||||
| `qt6ct` | Qt app theming |
|
||||
| `wtype` | virtual keyboard input |
|
||||
| `power-profiles-daemon` | power profile control |
|
||||
| `cups-pk-helper` | printer management |
|
||||
| `NetworkManager` | network control |
|
||||
|
||||
@@ -43,6 +43,7 @@ post_install() {
|
||||
# Desktop entry + icon
|
||||
vinstall "${wrksrc}/assets/dms-open.desktop" 644 usr/share/applications
|
||||
vinstall "${wrksrc}/assets/com.danklinux.dms.desktop" 644 usr/share/applications
|
||||
vinstall "${wrksrc}/assets/com.danklinux.dms.notepad.desktop" 644 usr/share/applications
|
||||
vinstall "${wrksrc}/assets/danklogo.svg" 644 usr/share/icons/hicolor/scalable/apps
|
||||
|
||||
# Shell completions (generated by the built binary; skip when cross-building)
|
||||
|
||||
@@ -33,6 +33,7 @@ post_install() {
|
||||
# Desktop entry + icon
|
||||
vinstall "${wrksrc}/assets/dms-open.desktop" 644 usr/share/applications
|
||||
vinstall "${wrksrc}/assets/com.danklinux.dms.desktop" 644 usr/share/applications
|
||||
vinstall "${wrksrc}/assets/com.danklinux.dms.notepad.desktop" 644 usr/share/applications
|
||||
vinstall "${wrksrc}/assets/danklogo.svg" 644 usr/share/icons/hicolor/scalable/apps
|
||||
|
||||
# Shell completions (generated by the built binary; skip when cross-building)
|
||||
|
||||
@@ -684,6 +684,10 @@ Notepad/scratchpad modal control for quick note-taking.
|
||||
- `open` - Show notepad modal
|
||||
- `close` - Hide notepad modal
|
||||
- `toggle` - Toggle notepad modal visibility
|
||||
- `openFile <path>` - Show notepad and load a file into a tab, reusing an existing tab if that file is already open
|
||||
- Parameters: `path` - Absolute path to a file. Empty path behaves like `open`.
|
||||
- Returns: Success/failure message
|
||||
- This is what `com.danklinux.dms.notepad.desktop`'s `Exec=dms ipc call notepad openFile %f` uses so Notepad shows up in "Open With" pickers for text files.
|
||||
- `expand` - Expand the active notepad width and open it if hidden
|
||||
- `collapse` - Collapse the active notepad width without changing visibility
|
||||
- `toggleExpand` - Toggle the active notepad width between collapsed and expanded
|
||||
|
||||
@@ -133,6 +133,8 @@
|
||||
$out/share/applications/dms-open.desktop
|
||||
install -D ${rootSrc}/assets/com.danklinux.dms.desktop \
|
||||
$out/share/applications/com.danklinux.dms.desktop
|
||||
install -D ${rootSrc}/assets/com.danklinux.dms.notepad.desktop \
|
||||
$out/share/applications/com.danklinux.dms.notepad.desktop
|
||||
install -D ${rootSrc}/core/assets/danklogo.svg \
|
||||
$out/share/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
|
||||
@@ -68,27 +68,6 @@ const KEY_MAP = {
|
||||
16777429: "XF86Explorer",
|
||||
16777442: "XF86Launch0",
|
||||
16777443: "XF86Launch1",
|
||||
33: "1",
|
||||
64: "2",
|
||||
35: "3",
|
||||
36: "4",
|
||||
37: "5",
|
||||
94: "6",
|
||||
38: "7",
|
||||
42: "8",
|
||||
40: "9",
|
||||
41: "0",
|
||||
60: "Comma",
|
||||
62: "Period",
|
||||
63: "Slash",
|
||||
58: "Semicolon",
|
||||
34: "Apostrophe",
|
||||
123: "BracketLeft",
|
||||
125: "BracketRight",
|
||||
124: "Backslash",
|
||||
95: "Minus",
|
||||
43: "Equal",
|
||||
126: "grave",
|
||||
196: "Adiaeresis",
|
||||
214: "Odiaeresis",
|
||||
220: "Udiaeresis",
|
||||
@@ -126,6 +105,56 @@ const KEY_MAP = {
|
||||
161: "exclamdown"
|
||||
};
|
||||
|
||||
// Preserve unshifted symbols from the active layout
|
||||
const SYMBOL_KEYSYM = {
|
||||
33: "exclam",
|
||||
34: "quotedbl",
|
||||
35: "numbersign",
|
||||
36: "dollar",
|
||||
37: "percent",
|
||||
38: "ampersand",
|
||||
40: "parenleft",
|
||||
41: "parenright",
|
||||
42: "asterisk",
|
||||
43: "plus",
|
||||
58: "colon",
|
||||
60: "less",
|
||||
62: "greater",
|
||||
63: "question",
|
||||
64: "at",
|
||||
94: "asciicircum",
|
||||
95: "underscore",
|
||||
123: "braceleft",
|
||||
124: "bar",
|
||||
125: "braceright",
|
||||
126: "asciitilde"
|
||||
};
|
||||
|
||||
// Preserve the existing shifted-US physical-key mapping
|
||||
const SHIFTED_US_FALLBACK = {
|
||||
33: "1",
|
||||
34: "Apostrophe",
|
||||
35: "3",
|
||||
36: "4",
|
||||
37: "5",
|
||||
38: "7",
|
||||
40: "9",
|
||||
41: "0",
|
||||
42: "8",
|
||||
43: "Equal",
|
||||
58: "Semicolon",
|
||||
60: "Comma",
|
||||
62: "Period",
|
||||
63: "Slash",
|
||||
64: "2",
|
||||
94: "6",
|
||||
95: "Minus",
|
||||
123: "BracketLeft",
|
||||
124: "Backslash",
|
||||
125: "BracketRight",
|
||||
126: "grave"
|
||||
};
|
||||
|
||||
// Numpad (keypad) keys. Qt reuses the same Qt::Key_* values for the numpad and
|
||||
// the main rows/nav cluster; only Qt.KeypadModifier distinguishes them. niri and
|
||||
// the other compositors bind against the xkb KP_* keysym names, so we must emit
|
||||
@@ -153,13 +182,17 @@ const KP_MAP = {
|
||||
46: "KP_Decimal"
|
||||
};
|
||||
|
||||
function xkbKeyFromQtKey(qk, isKeypad) {
|
||||
function xkbKeyFromQtKey(qk, isKeypad, hasShift) {
|
||||
if (isKeypad) {
|
||||
if (qk >= 48 && qk <= 57)
|
||||
return "KP_" + (qk - 48);
|
||||
if (KP_MAP[qk])
|
||||
return KP_MAP[qk];
|
||||
}
|
||||
if (!hasShift && SYMBOL_KEYSYM[qk])
|
||||
return SYMBOL_KEYSYM[qk];
|
||||
if (hasShift && SHIFTED_US_FALLBACK[qk])
|
||||
return SHIFTED_US_FALLBACK[qk];
|
||||
if (qk >= 65 && qk <= 90)
|
||||
return String.fromCharCode(qk);
|
||||
if (qk >= 97 && qk <= 122)
|
||||
@@ -192,23 +225,42 @@ function formatToken(mods, key) {
|
||||
return (mods.length ? mods.join("+") + "+" : "") + key;
|
||||
}
|
||||
|
||||
function normalizeKeyCombo(keyCombo) {
|
||||
if (!keyCombo)
|
||||
return "";
|
||||
return keyCombo.toLowerCase().replace(/\bmod\b/g, "super").replace(/\bsuper\b/g, "super");
|
||||
function canonicalModifier(modifier) {
|
||||
var normalized = (modifier || "").toLowerCase();
|
||||
if (normalized === "control")
|
||||
return "ctrl";
|
||||
if (normalized === "win")
|
||||
return "super";
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getConflictingBinds(keyCombo, currentAction, allBinds) {
|
||||
function withSymbolicMod(mods, modKey) {
|
||||
var configuredMod = canonicalModifier(modKey);
|
||||
if (!configuredMod)
|
||||
return mods;
|
||||
return mods.map(function (modifier) {
|
||||
return canonicalModifier(modifier) === configuredMod ? "Mod" : modifier;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeKeyCombo(keyCombo, modKey) {
|
||||
if (!keyCombo)
|
||||
return "";
|
||||
var configuredMod = canonicalModifier(modKey) || "super";
|
||||
return keyCombo.toLowerCase().replace(/\bmod\b/g, configuredMod).replace(/\bcontrol\b/g, "ctrl").replace(/\bwin\b/g, "super");
|
||||
}
|
||||
|
||||
function getConflictingBinds(keyCombo, currentAction, allBinds, modKey) {
|
||||
if (!keyCombo)
|
||||
return [];
|
||||
var conflicts = [];
|
||||
var normalizedKey = normalizeKeyCombo(keyCombo);
|
||||
var normalizedKey = normalizeKeyCombo(keyCombo, modKey);
|
||||
for (var i = 0; i < allBinds.length; i++) {
|
||||
var bind = allBinds[i];
|
||||
if (bind.action === currentAction)
|
||||
continue;
|
||||
for (var k = 0; k < bind.keys.length; k++) {
|
||||
if (normalizeKeyCombo(bind.keys[k].key) === normalizedKey) {
|
||||
if (normalizeKeyCombo(bind.keys[k].key, modKey) === normalizedKey) {
|
||||
conflicts.push({
|
||||
action: bind.action,
|
||||
desc: bind.desc || bind.action
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
.pragma library
|
||||
|
||||
// English language name -> ISO 639-1 code, for abbreviating xkb layout names.
|
||||
const LANG_CODES = {
|
||||
"afrikaans": "af",
|
||||
"albanian": "sq",
|
||||
"amharic": "am",
|
||||
"arabic": "ar",
|
||||
"armenian": "hy",
|
||||
"azerbaijani": "az",
|
||||
"basque": "eu",
|
||||
"belarusian": "be",
|
||||
"bengali": "bn",
|
||||
"bosnian": "bs",
|
||||
"bulgarian": "bg",
|
||||
"burmese": "my",
|
||||
"catalan": "ca",
|
||||
"chinese": "zh",
|
||||
"croatian": "hr",
|
||||
"czech": "cs",
|
||||
"danish": "da",
|
||||
"dutch": "nl",
|
||||
"english": "en",
|
||||
"esperanto": "eo",
|
||||
"estonian": "et",
|
||||
"filipino": "fil",
|
||||
"finnish": "fi",
|
||||
"french": "fr",
|
||||
"galician": "gl",
|
||||
"georgian": "ka",
|
||||
"german": "de",
|
||||
"greek": "el",
|
||||
"gujarati": "gu",
|
||||
"hausa": "ha",
|
||||
"hebrew": "he",
|
||||
"hindi": "hi",
|
||||
"hungarian": "hu",
|
||||
"icelandic": "is",
|
||||
"igbo": "ig",
|
||||
"indonesian": "id",
|
||||
"irish": "ga",
|
||||
"italian": "it",
|
||||
"japanese": "ja",
|
||||
"javanese": "jv",
|
||||
"kannada": "kn",
|
||||
"kazakh": "kk",
|
||||
"khmer": "km",
|
||||
"korean": "ko",
|
||||
"kurdish": "ku",
|
||||
"kyrgyz": "ky",
|
||||
"lao": "lo",
|
||||
"latvian": "lv",
|
||||
"lithuanian": "lt",
|
||||
"luxembourgish": "lb",
|
||||
"macedonian": "mk",
|
||||
"malay": "ms",
|
||||
"malayalam": "ml",
|
||||
"maltese": "mt",
|
||||
"maori": "mi",
|
||||
"marathi": "mr",
|
||||
"mongolian": "mn",
|
||||
"nepali": "ne",
|
||||
"norwegian": "no",
|
||||
"pashto": "ps",
|
||||
"persian": "fa",
|
||||
"iranian": "fa",
|
||||
"farsi": "fa",
|
||||
"polish": "pl",
|
||||
"portuguese": "pt",
|
||||
"punjabi": "pa",
|
||||
"romanian": "ro",
|
||||
"russian": "ru",
|
||||
"serbian": "sr",
|
||||
"sindhi": "sd",
|
||||
"sinhala": "si",
|
||||
"slovak": "sk",
|
||||
"slovenian": "sl",
|
||||
"somali": "so",
|
||||
"spanish": "es",
|
||||
"swahili": "sw",
|
||||
"swedish": "sv",
|
||||
"tajik": "tg",
|
||||
"tamil": "ta",
|
||||
"tatar": "tt",
|
||||
"telugu": "te",
|
||||
"thai": "th",
|
||||
"tibetan": "bo",
|
||||
"turkish": "tr",
|
||||
"turkmen": "tk",
|
||||
"ukrainian": "uk",
|
||||
"urdu": "ur",
|
||||
"uyghur": "ug",
|
||||
"uzbek": "uz",
|
||||
"vietnamese": "vi",
|
||||
"welsh": "cy",
|
||||
"yiddish": "yi",
|
||||
"yoruba": "yo",
|
||||
"zulu": "zu"
|
||||
};
|
||||
|
||||
function layoutCode(layoutName) {
|
||||
if (!layoutName)
|
||||
return "";
|
||||
const lang = layoutName.split(" ")[0].toLowerCase();
|
||||
const code = LANG_CODES[lang] || lang.substring(0, 2);
|
||||
return code.toUpperCase();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import QtCore
|
||||
import QtQuick
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
@@ -19,6 +20,8 @@ Singleton {
|
||||
|
||||
readonly property url imagecache: `${cache}/imagecache`
|
||||
|
||||
Component.onCompleted: mkdir(imagecache)
|
||||
|
||||
function stringify(path: url): string {
|
||||
return path.toString().replace(/%20/g, " ");
|
||||
}
|
||||
|
||||
@@ -79,12 +79,6 @@ Singleton {
|
||||
|
||||
function _closePopout(popout) {
|
||||
try {
|
||||
if (popout?.hoverDismissEnabled) {
|
||||
if (typeof popout.closeFromHoverDismiss === "function") {
|
||||
popout.closeFromHoverDismiss();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (popout.hoverDismissEnabled !== undefined)
|
||||
popout.hoverDismissEnabled = false;
|
||||
switch (true) {
|
||||
@@ -104,6 +98,20 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function _dismissPopoutFromHover(popout) {
|
||||
try {
|
||||
if (!popout || popout.hoverDismissEnabled !== true)
|
||||
return;
|
||||
if (typeof popout.closeFromHoverDismiss === "function") {
|
||||
popout.closeFromHoverDismiss();
|
||||
return;
|
||||
}
|
||||
_closePopout(popout);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function _isStale(popout) {
|
||||
try {
|
||||
if (!popout || !("shouldBeVisible" in popout))
|
||||
@@ -174,6 +182,19 @@ Singleton {
|
||||
_closePopout(popout);
|
||||
}
|
||||
|
||||
function dismissHoverPopoutForScreen(screen) {
|
||||
if (!screen)
|
||||
return;
|
||||
const screenName = screen.name;
|
||||
const popout = currentPopoutsByScreen[screenName];
|
||||
if (!popout || _isStale(popout)) {
|
||||
currentPopoutsByScreen[screenName] = null;
|
||||
currentPopoutTriggers[screenName] = null;
|
||||
return;
|
||||
}
|
||||
_dismissPopoutFromHover(popout);
|
||||
}
|
||||
|
||||
function cancelHoverDismiss(screen) {
|
||||
const popout = getActivePopout(screen);
|
||||
if (popout?.cancelHoverDismiss)
|
||||
@@ -191,7 +212,8 @@ Singleton {
|
||||
const p = getActivePopout(screen);
|
||||
if (!p || !_isPopoutPresented(p))
|
||||
return false;
|
||||
return p.hoverDismissEnabled === false || p.hoverDismissSuspended === true;
|
||||
const dismissSuspended = p.effectiveHoverDismissSuspended ?? p.hoverDismissSuspended;
|
||||
return p.hoverDismissEnabled === false || dismissSuspended === true;
|
||||
}
|
||||
|
||||
function isCurrentPopout(popout, screenName) {
|
||||
|
||||
@@ -15,7 +15,7 @@ Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("SettingsData")
|
||||
|
||||
readonly property int settingsConfigVersion: 11
|
||||
readonly property int settingsConfigVersion: 12
|
||||
|
||||
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
||||
|
||||
@@ -177,10 +177,12 @@ Singleton {
|
||||
property int niriLayoutRadiusOverride: -1
|
||||
property int niriLayoutBorderSize: -1
|
||||
property int hyprlandLayoutGapsOverride: -1
|
||||
property int hyprlandLayoutGapsOutOverride: -1
|
||||
property int hyprlandLayoutRadiusOverride: -1
|
||||
property int hyprlandLayoutBorderSize: -1
|
||||
property bool hyprlandResizeOnBorder: false
|
||||
property int mangoLayoutGapsOverride: -1
|
||||
property int mangoLayoutGapsOutOverride: -1
|
||||
property int mangoLayoutRadiusOverride: -1
|
||||
property int mangoLayoutBorderSize: -1
|
||||
property bool mangoTrackpadNaturalScrolling: true
|
||||
@@ -460,6 +462,7 @@ Singleton {
|
||||
property bool clockCompactMode: false
|
||||
property int focusedWindowSize: 1
|
||||
property bool focusedWindowCompactMode: false
|
||||
property bool focusedWindowShowIcon: true
|
||||
property bool runningAppsCompactMode: true
|
||||
property int barMaxVisibleApps: 0
|
||||
property int barMaxVisibleRunningApps: 0
|
||||
@@ -494,6 +497,8 @@ Singleton {
|
||||
property string greeterLockDateFormat: ""
|
||||
property string greeterFontFamily: ""
|
||||
property string greeterWallpaperFillMode: ""
|
||||
property bool greeterSyncPending: false
|
||||
property var greeterSyncBaseline: ({})
|
||||
property int mediaSize: 1
|
||||
|
||||
property string appLauncherViewMode: "list"
|
||||
@@ -748,12 +753,16 @@ Singleton {
|
||||
property bool batteryNotifyCritical: true
|
||||
property int batteryLowThreshold: 20
|
||||
property bool batteryNotifyLow: false
|
||||
property int batteryNotificationType: 0
|
||||
property int batteryChargeLimitNotificationType: 0
|
||||
property int batteryLowNotificationType: 0
|
||||
property int batteryCriticalNotificationType: 1
|
||||
property bool batteryAutoPowerSaver: false
|
||||
property bool showBatteryPercent: true
|
||||
property bool showBatteryPercentOnlyOnBattery: false
|
||||
property bool showBatteryTime: false
|
||||
property bool showBatteryTimeOnlyOnBattery: false
|
||||
property bool batteryPillStyle: false
|
||||
property bool batteryPillPercentSign: false
|
||||
property bool lockBeforeSuspend: false
|
||||
property bool loginctlLockIntegration: true
|
||||
property bool fadeToLockEnabled: true
|
||||
@@ -885,12 +894,14 @@ Singleton {
|
||||
readonly property bool greeterU2fReady: Processes.greeterU2fReady
|
||||
readonly property string greeterU2fReason: Processes.greeterU2fReason
|
||||
readonly property string greeterU2fSource: Processes.greeterU2fSource
|
||||
property string lockScreenActiveMonitor: "all"
|
||||
property string lockScreenInactiveColor: "#000000"
|
||||
property int lockScreenNotificationMode: 0
|
||||
property bool lockScreenVideoEnabled: false
|
||||
property string lockScreenVideoPath: ""
|
||||
property bool lockScreenVideoCycling: false
|
||||
property string lockScreenWallpaperPath: ""
|
||||
property string lockScreenWallpaperFillMode: ""
|
||||
property string lockScreenFontFamily: ""
|
||||
property bool hideBrightnessSlider: false
|
||||
|
||||
property int notificationTimeoutLow: 5000
|
||||
@@ -1016,17 +1027,6 @@ Singleton {
|
||||
}
|
||||
]
|
||||
|
||||
// Standalone bar xray is unsafe when windows can render beneath its surface
|
||||
function _standaloneBarXrayAvailable(configs) {
|
||||
const list = configs || [];
|
||||
const activeBars = list.filter(c => c && c.enabled && (c.visible ?? true));
|
||||
const gapsOverride = (typeof CompositorService !== "undefined" && CompositorService.isHyprland) ? hyprlandLayoutGapsOverride : niriLayoutGapsOverride;
|
||||
const layoutGaps = gapsOverride >= 0 ? gapsOverride : Math.max(4, (list[0]?.spacing ?? 4));
|
||||
return activeBars.every(c => !c.autoHide && !(c.useOverlayLayer ?? false) && (c.spacing ?? 4) + (c.bottomGap ?? 0) + layoutGaps >= 0);
|
||||
}
|
||||
|
||||
readonly property bool standaloneBarXrayAvailable: _standaloneBarXrayAvailable(barConfigs)
|
||||
|
||||
property bool desktopClockEnabled: false
|
||||
property string desktopClockStyle: "analog"
|
||||
property real desktopClockTransparency: 0.8
|
||||
@@ -1677,6 +1677,32 @@ Singleton {
|
||||
});
|
||||
}
|
||||
|
||||
function markGreeterSyncPending(who, key, oldValue) {
|
||||
if (isGreeterMode)
|
||||
return;
|
||||
if (!(key in greeterSyncBaseline)) {
|
||||
var baseline = greeterSyncBaseline;
|
||||
baseline[key] = oldValue;
|
||||
greeterSyncBaseline = baseline;
|
||||
}
|
||||
greeterSyncPending = true;
|
||||
}
|
||||
|
||||
function clearGreeterSyncPending() {
|
||||
greeterSyncBaseline = {};
|
||||
greeterSyncPending = false;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function revertGreeterSyncPending() {
|
||||
for (var key in greeterSyncBaseline) {
|
||||
root[key] = greeterSyncBaseline[key];
|
||||
}
|
||||
greeterSyncBaseline = {};
|
||||
greeterSyncPending = false;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
readonly property var _hooks: ({
|
||||
"applyStoredTheme": applyStoredTheme,
|
||||
"regenSystemThemes": regenSystemThemes,
|
||||
@@ -1685,7 +1711,8 @@ Singleton {
|
||||
"updateBarConfigs": updateBarConfigs,
|
||||
"updateCompositorCursor": updateCompositorCursor,
|
||||
"scheduleAuthApply": scheduleAuthApply,
|
||||
"scheduleGreeterAutoLoginSync": scheduleGreeterAutoLoginSync
|
||||
"scheduleGreeterAutoLoginSync": scheduleGreeterAutoLoginSync,
|
||||
"markGreeterSyncPending": markGreeterSyncPending
|
||||
})
|
||||
|
||||
function set(key, value) {
|
||||
@@ -1711,6 +1738,18 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
if (obj?.lockScreenActiveMonitor !== undefined) {
|
||||
var oldVal = obj.lockScreenActiveMonitor;
|
||||
if (oldVal && oldVal !== "all") {
|
||||
if (!obj.screenPreferences)
|
||||
obj.screenPreferences = {};
|
||||
if (obj.screenPreferences.lockScreen === undefined) {
|
||||
obj.screenPreferences.lockScreen = [oldVal];
|
||||
}
|
||||
}
|
||||
delete obj.lockScreenActiveMonitor;
|
||||
}
|
||||
|
||||
Store.parse(root, obj);
|
||||
|
||||
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
|
||||
@@ -2429,17 +2468,13 @@ Singleton {
|
||||
if (index === -1)
|
||||
return;
|
||||
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
|
||||
const barXrayTargetWasAvailable = _standaloneBarXrayAvailable(configs);
|
||||
if (updates.autoHide === false || updates.visible === false)
|
||||
setBarIpcReveal(barId, false);
|
||||
|
||||
Object.assign(configs[index], updates);
|
||||
const sanitizedConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
|
||||
barConfigs = sanitizedConfigs;
|
||||
barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
|
||||
updateBarConfigs();
|
||||
|
||||
if (!frameEnabled && _standaloneBarXrayAvailable(sanitizedConfigs) !== barXrayTargetWasAvailable)
|
||||
updateCompositorLayout();
|
||||
if (positionChanged) {
|
||||
NotificationService.dismissAllPopups();
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@ Singleton {
|
||||
|
||||
Component.onCompleted: {
|
||||
Quickshell.execDetached(["mkdir", "-p", stateDir]);
|
||||
if (typeof SessionData === "undefined" || !SessionData.isGreeterMode)
|
||||
Quickshell.execDetached([shellDir + "/scripts/gtk.sh", configDir, "", shellDir, "assets-only"]);
|
||||
Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => {
|
||||
matugenAvailable = (code === 0) && !envDisableMatugen;
|
||||
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
|
||||
@@ -1801,8 +1803,11 @@ Singleton {
|
||||
const iconTheme = (typeof SettingsData !== "undefined" && SettingsData.iconTheme) ? SettingsData.iconTheme : "System Default";
|
||||
|
||||
if (currentTheme === dynamic) {
|
||||
if (!rawWallpaperPath)
|
||||
if (!rawWallpaperPath) {
|
||||
log.warn("Auto theme has no wallpaper - skipping matugen, syncing portal mode only");
|
||||
PortalService.setLightMode(isLight);
|
||||
return;
|
||||
}
|
||||
const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot";
|
||||
const kind = rawWallpaperPath.startsWith("#") ? "hex" : "image";
|
||||
setDesiredTheme(kind, rawWallpaperPath, isLight, iconTheme, selectedMatugenType, null);
|
||||
@@ -1827,19 +1832,21 @@ Singleton {
|
||||
const storedLight = isGreeterMode ? (GreetdSettings.registryThemeVariants[themeId]?.light || lightDefaults) : (typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, lightDefaults, "light") : lightDefaults);
|
||||
const darkFlavorId = storedDark.flavor || darkDefaults.flavor || "";
|
||||
const lightFlavorId = storedLight.flavor || lightDefaults.flavor || "";
|
||||
const accentId = storedDark.accent || darkDefaults.accent || "";
|
||||
const darkAccentId = storedDark.accent || darkDefaults.accent || "";
|
||||
const lightAccentId = storedLight.accent || lightDefaults.accent || "";
|
||||
const darkFlavor = findVariant(customThemeRawData.variants.flavors, darkFlavorId);
|
||||
const lightFlavor = findVariant(customThemeRawData.variants.flavors, lightFlavorId);
|
||||
const accent = findAccent(customThemeRawData.variants.accents, accentId);
|
||||
const darkAccent = findAccent(customThemeRawData.variants.accents, darkAccentId);
|
||||
const lightAccent = findAccent(customThemeRawData.variants.accents, lightAccentId);
|
||||
if (darkFlavor) {
|
||||
darkTheme = mergeColors(darkTheme, darkFlavor.dark || {});
|
||||
if (accent)
|
||||
darkTheme = mergeColors(darkTheme, accent[darkFlavor.id] || {});
|
||||
if (darkAccent)
|
||||
darkTheme = mergeColors(darkTheme, darkAccent[darkFlavor.id] || {});
|
||||
}
|
||||
if (lightFlavor) {
|
||||
lightTheme = mergeColors(lightTheme, lightFlavor.light || {});
|
||||
if (accent)
|
||||
lightTheme = mergeColors(lightTheme, accent[lightFlavor.id] || {});
|
||||
if (lightAccent)
|
||||
lightTheme = mergeColors(lightTheme, lightAccent[lightFlavor.id] || {});
|
||||
}
|
||||
} else if (customThemeRawData.variants.options) {
|
||||
const isGreeterMode = typeof SessionData !== "undefined" && SessionData.isGreeterMode;
|
||||
|
||||
@@ -32,6 +32,8 @@ Singleton {
|
||||
showBatteryPercentOnlyOnBattery: false,
|
||||
showBatteryTime: false,
|
||||
showBatteryTimeOnlyOnBattery: false,
|
||||
batteryPillStyle: false,
|
||||
batteryPillPercentSign: false,
|
||||
showPrinterIcon: false,
|
||||
showScreenSharingIcon: true
|
||||
};
|
||||
@@ -95,6 +97,10 @@ Singleton {
|
||||
item.showBatteryTime = order[i].showBatteryTime;
|
||||
if (isObj && order[i].showBatteryTimeOnlyOnBattery !== undefined)
|
||||
item.showBatteryTimeOnlyOnBattery = order[i].showBatteryTimeOnlyOnBattery;
|
||||
if (isObj && order[i].batteryPillStyle !== undefined)
|
||||
item.batteryPillStyle = order[i].batteryPillStyle;
|
||||
if (isObj && order[i].batteryPillPercentSign !== undefined)
|
||||
item.batteryPillPercentSign = order[i].batteryPillPercentSign;
|
||||
if (isObj && order[i].showPrinterIcon !== undefined)
|
||||
item.showPrinterIcon = order[i].showPrinterIcon;
|
||||
if (isObj && order[i].showScreenSharingIcon !== undefined)
|
||||
|
||||
@@ -332,7 +332,6 @@ Singleton {
|
||||
property bool greeterAutoLoginSyncRerunRequested: false
|
||||
property string greeterAutoLoginSyncStdout: ""
|
||||
property string greeterAutoLoginSyncStderr: ""
|
||||
property string greeterAutoLoginSyncTerminalFallbackStderr: ""
|
||||
|
||||
function scheduleGreeterAutoLoginSync() {
|
||||
if (!settingsRoot || settingsRoot.isGreeterMode)
|
||||
@@ -355,15 +354,16 @@ Singleton {
|
||||
greeterAutoLoginSyncRerunRequested = false;
|
||||
greeterAutoLoginSyncStdout = "";
|
||||
greeterAutoLoginSyncStderr = "";
|
||||
greeterAutoLoginSyncTerminalFallbackStderr = "";
|
||||
greeterAutoLoginSyncRunning = true;
|
||||
greeterAutoLoginSyncSudoProbeProcess.running = true;
|
||||
}
|
||||
|
||||
function launchGreeterAutoLoginSyncTerminalFallback(details) {
|
||||
ToastService.showWarning(I18n.tr("Opening terminal to update greetd"), I18n.tr("DMS needs administrator access. The terminal closes automatically when done.") + (details ? "\n\n" + details : ""), "dms greeter sync --autologin", "greeter-autologin-sync");
|
||||
greeterAutoLoginSyncTerminalFallbackStderr = "";
|
||||
greeterAutoLoginSyncTerminalFallbackProcess.running = true;
|
||||
function deferGreeterAutoLoginSyncToPill(details) {
|
||||
ToastService.dismissCategory("greeter-autologin-sync");
|
||||
if (settingsRoot)
|
||||
settingsRoot.set("greeterSyncPending", true);
|
||||
ToastService.showWarning(I18n.tr("Auto-login change needs a sync"), I18n.tr("Administrator access is required. Use the Sync button in Settings → Greeter to apply.") + (details ? "\n\n" + details : ""), "dms greeter sync --autologin", "greeter-autologin-sync");
|
||||
finishGreeterAutoLoginSync();
|
||||
}
|
||||
|
||||
function greeterAutoLoginSyncSuccessToast(details) {
|
||||
@@ -559,7 +559,7 @@ Singleton {
|
||||
details = out;
|
||||
if (err !== "")
|
||||
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
|
||||
root.launchGreeterAutoLoginSyncTerminalFallback(details);
|
||||
root.deferGreeterAutoLoginSyncToPill(details);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,26 +575,7 @@ Singleton {
|
||||
return;
|
||||
}
|
||||
|
||||
root.launchGreeterAutoLoginSyncTerminalFallback();
|
||||
}
|
||||
}
|
||||
|
||||
property var greeterAutoLoginSyncTerminalFallbackProcess: Process {
|
||||
command: ["dms", "greeter", "sync", "--terminal", "--yes", "--autologin"]
|
||||
running: false
|
||||
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: root.greeterAutoLoginSyncTerminalFallbackStderr = text || ""
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
if (exitCode === 0) {
|
||||
root.greeterAutoLoginSyncSuccessToast("");
|
||||
} else {
|
||||
let details = (root.greeterAutoLoginSyncTerminalFallbackStderr || "").trim();
|
||||
ToastService.showError(I18n.tr("Couldn't open a terminal for the auto-login update.") + " (exit " + exitCode + ")", details, "dms greeter sync --autologin", "greeter-autologin-sync");
|
||||
}
|
||||
root.finishGreeterAutoLoginSync();
|
||||
root.deferGreeterAutoLoginSyncToPill("");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,12 @@ var SPEC = {
|
||||
niriLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
|
||||
niriLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
|
||||
hyprlandLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
|
||||
hyprlandLayoutGapsOutOverride: { def: -1, onChange: "updateCompositorLayout" },
|
||||
hyprlandLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
|
||||
hyprlandLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
|
||||
hyprlandResizeOnBorder: { def: false, onChange: "updateCompositorLayout" },
|
||||
mangoLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
|
||||
mangoLayoutGapsOutOverride: { def: -1, onChange: "updateCompositorLayout" },
|
||||
mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
|
||||
mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
|
||||
mangoTrackpadNaturalScrolling: { def: true, onChange: "updateCompositorCursor" },
|
||||
@@ -100,6 +102,8 @@ var SPEC = {
|
||||
showBatteryPercentOnlyOnBattery: { def: false },
|
||||
showBatteryTime: { def: false },
|
||||
showBatteryTimeOnlyOnBattery: { def: false },
|
||||
batteryPillStyle: { def: false },
|
||||
batteryPillPercentSign: { def: false },
|
||||
showControlCenterButton: { def: true },
|
||||
showCapsLockIndicator: { def: true },
|
||||
|
||||
@@ -188,6 +192,7 @@ var SPEC = {
|
||||
clockCompactMode: { def: false },
|
||||
focusedWindowCompactMode: { def: false },
|
||||
focusedWindowSize: { def: 1 },
|
||||
focusedWindowShowIcon: { def: true },
|
||||
runningAppsCompactMode: { def: true },
|
||||
barMaxVisibleApps: { def: 0 },
|
||||
barMaxVisibleRunningApps: { def: 0 },
|
||||
@@ -218,18 +223,20 @@ var SPEC = {
|
||||
centeringMode: { def: "index" },
|
||||
clockDateFormat: { def: "" },
|
||||
lockDateFormat: { def: "" },
|
||||
greeterRememberLastSession: { def: true },
|
||||
greeterRememberLastUser: { def: true },
|
||||
greeterRememberLastSession: { def: true, onChange: "markGreeterSyncPending" },
|
||||
greeterRememberLastUser: { def: true, onChange: "markGreeterSyncPending" },
|
||||
greeterAutoLogin: { def: false, onChange: "scheduleGreeterAutoLoginSync" },
|
||||
greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" },
|
||||
greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" },
|
||||
greeterWallpaperPath: { def: "" },
|
||||
greeterUse24HourClock: { def: true },
|
||||
greeterShowSeconds: { def: false },
|
||||
greeterPadHours12Hour: { def: false },
|
||||
greeterLockDateFormat: { def: "" },
|
||||
greeterFontFamily: { def: "" },
|
||||
greeterWallpaperFillMode: { def: "" },
|
||||
greeterWallpaperPath: { def: "", onChange: "markGreeterSyncPending" },
|
||||
greeterUse24HourClock: { def: true, onChange: "markGreeterSyncPending" },
|
||||
greeterShowSeconds: { def: false, onChange: "markGreeterSyncPending" },
|
||||
greeterPadHours12Hour: { def: false, onChange: "markGreeterSyncPending" },
|
||||
greeterLockDateFormat: { def: "", onChange: "markGreeterSyncPending" },
|
||||
greeterFontFamily: { def: "", onChange: "markGreeterSyncPending" },
|
||||
greeterWallpaperFillMode: { def: "", onChange: "markGreeterSyncPending" },
|
||||
greeterSyncPending: { def: false },
|
||||
greeterSyncBaseline: { def: {} },
|
||||
mediaSize: { def: 1 },
|
||||
|
||||
appLauncherViewMode: { def: "list" },
|
||||
@@ -335,7 +342,9 @@ var SPEC = {
|
||||
batteryNotifyCritical: { def: true },
|
||||
batteryLowThreshold: { def: 20 },
|
||||
batteryNotifyLow: { def: false },
|
||||
batteryNotificationType: { def: 0 },
|
||||
batteryChargeLimitNotificationType: { def: 0 },
|
||||
batteryLowNotificationType: { def: 0 },
|
||||
batteryCriticalNotificationType: { def: 1 },
|
||||
batteryAutoPowerSaver: { def: false },
|
||||
lockBeforeSuspend: { def: false },
|
||||
loginctlLockIntegration: { def: true },
|
||||
@@ -447,12 +456,14 @@ var SPEC = {
|
||||
maxFprintTries: { def: 15 },
|
||||
enableU2f: { def: false, onChange: "scheduleAuthApply" },
|
||||
u2fMode: { def: "or" },
|
||||
lockScreenActiveMonitor: { def: "all" },
|
||||
lockScreenInactiveColor: { def: "#000000" },
|
||||
lockScreenNotificationMode: { def: 0 },
|
||||
lockScreenVideoEnabled: { def: false },
|
||||
lockScreenVideoPath: { def: "" },
|
||||
lockScreenVideoCycling: { def: false },
|
||||
lockScreenWallpaperPath: { def: "" },
|
||||
lockScreenWallpaperFillMode: { def: "" },
|
||||
lockScreenFontFamily: { def: "" },
|
||||
hideBrightnessSlider: { def: false },
|
||||
|
||||
notificationTimeoutLow: { def: 5000 },
|
||||
@@ -659,10 +670,11 @@ function getValidKeys() {
|
||||
function set(root, key, value, saveFn, hooks) {
|
||||
if (!(key in SPEC)) return;
|
||||
if (value === undefined || value === null) value = SPEC[key].def;
|
||||
var oldValue = root[key];
|
||||
root[key] = value;
|
||||
var hookName = SPEC[key].onChange;
|
||||
if (hookName && hooks && hooks[hookName]) {
|
||||
hooks[hookName](root);
|
||||
hooks[hookName](root, key, oldValue);
|
||||
}
|
||||
saveFn();
|
||||
}
|
||||
|
||||
@@ -252,6 +252,17 @@ function migrateToVersion(obj, targetVersion) {
|
||||
settings.configVersion = 11;
|
||||
}
|
||||
|
||||
if (currentVersion < 12) {
|
||||
console.info("Migrating settings from version", currentVersion, "to version 12");
|
||||
if (settings.batteryNotificationType !== undefined) {
|
||||
settings.batteryChargeLimitNotificationType = settings.batteryNotificationType;
|
||||
settings.batteryLowNotificationType = settings.batteryNotificationType;
|
||||
settings.batteryCriticalNotificationType = settings.batteryNotificationType;
|
||||
delete settings.batteryNotificationType;
|
||||
}
|
||||
settings.configVersion = 12;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
|
||||
@@ -385,6 +385,22 @@ Item {
|
||||
return "NOTEPAD_OPEN_FAILED";
|
||||
}
|
||||
|
||||
function openFile(path: string): string {
|
||||
if (!path)
|
||||
return open();
|
||||
if (SettingsData.notepadDefaultMode === "popout") {
|
||||
PopoutService.openNotepadPopoutWithFile(path);
|
||||
return "NOTEPAD_OPEN_FILE_SUCCESS";
|
||||
}
|
||||
var instance = getActiveNotepadInstance();
|
||||
if (instance) {
|
||||
instance.show();
|
||||
instance.loadedItem?.openExternalFile(path);
|
||||
return "NOTEPAD_OPEN_FILE_SUCCESS";
|
||||
}
|
||||
return "NOTEPAD_OPEN_FILE_FAILED";
|
||||
}
|
||||
|
||||
function close(): string {
|
||||
if (SettingsData.notepadDefaultMode === "popout") {
|
||||
PopoutService.notepadPopout?.hide();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Modals.Common
|
||||
import qs.Widgets
|
||||
@@ -23,6 +24,7 @@ DankModal {
|
||||
property var rememberMimeTypes: []
|
||||
property bool rememberChoice: false
|
||||
property var mimeMatchedAppIds: []
|
||||
property var mimeMatchedRawIds: []
|
||||
|
||||
signal applicationSelected(var app, string targetData)
|
||||
|
||||
@@ -61,6 +63,7 @@ DankModal {
|
||||
|
||||
function fetchMimeMatches() {
|
||||
mimeMatchedAppIds = [];
|
||||
mimeMatchedRawIds = [];
|
||||
const queriedMime = mimeType;
|
||||
if (queriedMime.length === 0)
|
||||
return;
|
||||
@@ -74,6 +77,7 @@ DankModal {
|
||||
return;
|
||||
}
|
||||
const ids = (response.result && response.result.desktopIds) || [];
|
||||
mimeMatchedRawIds = ids;
|
||||
mimeMatchedAppIds = ids.map(_normAppId);
|
||||
updateApplicationList();
|
||||
});
|
||||
@@ -93,11 +97,13 @@ DankModal {
|
||||
const hasMimeMatches = mimeMatchedAppIds.length > 0;
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
let filteredApps = [];
|
||||
const listedIds = new Set();
|
||||
|
||||
for (const app of apps) {
|
||||
if (!app)
|
||||
continue;
|
||||
const appId = _normAppId(app.id || app.execString || app.exec || "");
|
||||
listedIds.add(appId);
|
||||
const mimeIdMatch = hasMimeMatches && mimeMatchedAppIds.includes(appId);
|
||||
const mimeFieldMatch = hasMime && _appMatchesMime(app, mimeType);
|
||||
const mimeMatch = mimeIdMatch || mimeFieldMatch;
|
||||
@@ -135,6 +141,28 @@ DankModal {
|
||||
});
|
||||
}
|
||||
|
||||
// NoDisplay entries are excluded from DesktopEntries.applications but
|
||||
// remain valid mime handlers; resolve them by id so they stay pickable
|
||||
for (const rawId of mimeMatchedRawIds) {
|
||||
const normId = _normAppId(rawId);
|
||||
if (normId === "dms-open" || listedIds.has(normId))
|
||||
continue;
|
||||
const entry = DesktopEntries.byId(rawId) || DesktopEntries.heuristicLookup(rawId);
|
||||
if (!entry)
|
||||
continue;
|
||||
const name = entry.name || "";
|
||||
if (searchQuery !== "" && !name.toLowerCase().includes(lowerQuery))
|
||||
continue;
|
||||
filteredApps.push({
|
||||
name: name,
|
||||
icon: entry.icon || "application-x-executable",
|
||||
exec: entry.execString || "",
|
||||
startupClass: entry.startupClass || "",
|
||||
appData: entry,
|
||||
mimeMatch: true
|
||||
});
|
||||
}
|
||||
|
||||
filteredApps.sort((a, b) => {
|
||||
if (a.mimeMatch !== b.mimeMatch) {
|
||||
return a.mimeMatch ? -1 : 1;
|
||||
|
||||
@@ -8,6 +8,7 @@ Item {
|
||||
id: clipboardContent
|
||||
|
||||
required property var modal
|
||||
property var transientSurfaceTracker: null
|
||||
|
||||
property alias searchField: searchField
|
||||
property alias clipboardListView: clipboardListView
|
||||
@@ -66,7 +67,7 @@ Item {
|
||||
contextMenu.hide();
|
||||
}
|
||||
|
||||
readonly property bool contextMenuActive: contextMenu.openState
|
||||
readonly property bool contextMenuActive: contextMenu.renderActive
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -74,6 +75,7 @@ Item {
|
||||
id: contextMenu
|
||||
modal: clipboardContent.modal
|
||||
parentHandler: clipboardContent
|
||||
transientSurfaceTracker: clipboardContent.transientSurfaceTracker
|
||||
}
|
||||
|
||||
Column {
|
||||
@@ -394,7 +396,7 @@ Item {
|
||||
}
|
||||
|
||||
sourceComponent: ClipboardKeyboardHints {
|
||||
wtypeAvailable: modal.wtypeAvailable
|
||||
pasteAvailable: modal.pasteAvailable
|
||||
enterToPaste: SettingsData.clipboardEnterToPaste
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ Item {
|
||||
property real anchorY: 0
|
||||
property bool openState: false
|
||||
property bool renderActive: false
|
||||
property var transientSurfaceTracker: null
|
||||
readonly property alias contextWindow: menuWindow
|
||||
readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
|
||||
|
||||
readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null
|
||||
@@ -90,6 +92,18 @@ Item {
|
||||
readonly property real effectiveMenuHeight: Math.min(maxMenuHeight, naturalMenuHeight)
|
||||
readonly property bool menuScrolls: naturalMenuHeight > effectiveMenuHeight + 0.5
|
||||
|
||||
onRenderActiveChanged: transientSurfaceTracker?.setActive(root, renderActive, contextWindow)
|
||||
Component.onDestruction: transientSurfaceTracker?.unregister(root)
|
||||
|
||||
Connections {
|
||||
target: root.transientSurfaceTracker
|
||||
ignoreUnknownSignals: true
|
||||
|
||||
function onCloseRequested() {
|
||||
root.hide();
|
||||
}
|
||||
}
|
||||
|
||||
TextMetrics {
|
||||
id: menuTextMetrics
|
||||
text: root.longestMenuText
|
||||
@@ -258,8 +272,8 @@ Item {
|
||||
height: root.effectiveMenuHeight
|
||||
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
radius: Theme.cornerRadius
|
||||
border.color: BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium
|
||||
border.width: BlurService.enabled ? BlurService.borderWidth : 1
|
||||
border.color: BlurService.borderColor
|
||||
border.width: BlurService.borderWidth
|
||||
opacity: root.openState ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
|
||||
@@ -496,7 +496,7 @@ Item {
|
||||
implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0)
|
||||
opacity: modal.wtypeAvailable ? 1 : 0.5
|
||||
opacity: modal.pasteAvailable ? 1 : 0.5
|
||||
|
||||
Row {
|
||||
id: saveMenuPasteRow
|
||||
@@ -522,7 +522,7 @@ Item {
|
||||
id: saveMenuPasteArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: modal.wtypeAvailable
|
||||
enabled: modal.pasteAvailable
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
saveMenu.close();
|
||||
|
||||
@@ -49,6 +49,15 @@ Rectangle {
|
||||
cornerRadius: root.radius
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: mouse => {
|
||||
const scenePos = mapToItem(null, mouse.x, mouse.y);
|
||||
contextMenuRequested(scenePos.x, scenePos.y);
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: indexBadge
|
||||
anchors.left: parent.left
|
||||
@@ -234,13 +243,4 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: mouse => {
|
||||
const scenePos = mapToItem(null, mouse.x, mouse.y);
|
||||
contextMenuRequested(scenePos.x, scenePos.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ FocusScope {
|
||||
|
||||
property var clearConfirmDialog: null
|
||||
property var surfaceHost: null
|
||||
property var transientSurfaceTracker: null
|
||||
|
||||
property string activeTab: "recents"
|
||||
property bool showKeyboardHints: false
|
||||
@@ -20,7 +21,7 @@ FocusScope {
|
||||
property string activeFilter: SettingsData.clipboardRememberTypeFilter ? SettingsData.clipboardTypeFilter : "all"
|
||||
|
||||
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
|
||||
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
|
||||
readonly property bool pasteAvailable: ClipboardService.pasteAvailable
|
||||
readonly property int totalCount: ClipboardService.totalCount
|
||||
readonly property var clipboardEntries: ClipboardService.clipboardEntries
|
||||
readonly property var pinnedEntries: ClipboardService.pinnedEntries
|
||||
@@ -198,6 +199,7 @@ FocusScope {
|
||||
id: historyContent
|
||||
anchors.fill: parent
|
||||
modal: root
|
||||
transientSurfaceTracker: root.transientSurfaceTracker
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ DankModal {
|
||||
content: Component {
|
||||
ClipboardHistoryContent {
|
||||
surfaceHost: clipboardHistoryModal
|
||||
transientSurfaceTracker: clipboardHistoryModal.transientSurfaceTracker
|
||||
clearConfirmDialog: clearConfirmDialog
|
||||
onCloseRequested: clipboardHistoryModal.hide()
|
||||
onInstantCloseRequested: clipboardHistoryModal.instantHide()
|
||||
|
||||
@@ -141,6 +141,7 @@ DankPopout {
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
surfaceHost: root
|
||||
transientSurfaceTracker: root.transientSurfaceTracker
|
||||
clearConfirmDialog: clearConfirmDialog
|
||||
onCloseRequested: root.hide()
|
||||
onInstantCloseRequested: root.hide()
|
||||
|
||||
@@ -5,10 +5,10 @@ import qs.Widgets
|
||||
Rectangle {
|
||||
id: keyboardHints
|
||||
|
||||
property bool wtypeAvailable: false
|
||||
property bool pasteAvailable: false
|
||||
property bool enterToPaste: false
|
||||
readonly property string hintsText: {
|
||||
if (!wtypeAvailable)
|
||||
if (!pasteAvailable)
|
||||
return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close");
|
||||
return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • F10: Help • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • F10: Help • Esc: Close");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user