mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-03 19:12:11 -04:00
Compare commits
164 Commits
546cbfb3ca
...
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd4c41a6b2 | ||
|
|
92a25fdb6a | ||
|
|
d6650be008 | ||
|
|
2646e7b19a | ||
|
|
4133f11d82 | ||
|
|
22ed740394 | ||
|
|
063299a434 | ||
|
|
44d836c975 | ||
|
|
da437e77fb | ||
|
|
34a6bbfb32 | ||
|
|
9ed53bac9e | ||
|
|
3a6752c3d2 | ||
|
|
ef19568dd7 | ||
|
|
f280cd9d3b | ||
|
|
cf4ce3c476 | ||
|
|
ae59e53c4c | ||
|
|
7e0d661f63 | ||
|
|
0b33d3f905 | ||
|
|
d62bdda56b | ||
|
|
5841b38cd9 | ||
|
|
83e2b5a7a6 | ||
|
|
2f863f64ee | ||
|
|
1a8b397cfd | ||
|
|
196c421b75 | ||
|
|
8399d64c2d | ||
|
|
c530eab303 | ||
|
|
45b6362dd3 | ||
|
|
50b77dcfc3 | ||
|
|
be8f3adf01 | ||
|
|
75a8c171ea | ||
|
|
466ff59573 | ||
|
|
053bb91927 | ||
|
|
2c9b22c016 | ||
|
|
a9ee91586e | ||
|
|
81bce74612 | ||
|
|
f2a6d2c7da | ||
|
|
0a9a34912e | ||
|
|
abff670814 | ||
|
|
0d49acaaa8 | ||
|
|
ebe1785411 | ||
|
|
f9f0192b22 | ||
|
|
e5cdbf4cf5 | ||
|
|
13ef1efa7b | ||
|
|
fbd9301a2d | ||
|
|
24e3024b57 | ||
|
|
52d5af11ba | ||
|
|
44a45b00cf | ||
|
|
2b78fe5b9f | ||
|
|
14f92669c6 | ||
|
|
124106de87 | ||
|
|
bb8e0d384f | ||
|
|
59d37847ec | ||
|
|
acdc531dca | ||
|
|
ce75dac81b | ||
|
|
b8d40761ff | ||
|
|
3a7430f6da | ||
|
|
242660c51d | ||
|
|
8a6c1e45ce | ||
|
|
b8e5f9f3b1 | ||
|
|
d60e70f9cc | ||
|
|
cdb70fadb3 | ||
|
|
7867deef60 | ||
|
|
a77c1adb32 | ||
|
|
da14d75a3b | ||
|
|
7c66a34931 | ||
|
|
425715e0f0 | ||
|
|
a3baf8ce31 | ||
|
|
605e03b065 | ||
|
|
0e9b21d359 | ||
|
|
ba5bf0cabc | ||
|
|
96b9d7aab3 | ||
|
|
750e4c4527 | ||
|
|
7417e26444 | ||
|
|
00e1099912 | ||
|
|
bd46d29ff0 | ||
|
|
1a9d7684b9 | ||
|
|
0133c19276 | ||
|
|
46bb3b613b | ||
|
|
5839a5de30 | ||
|
|
535d0bb0f0 | ||
|
|
4d316007af | ||
|
|
3c2d60d8e1 | ||
|
|
9c4f4cbd0d | ||
|
|
a337585b00 | ||
|
|
1cdec5d687 | ||
|
|
081b15e24c | ||
|
|
b04cb7b3cc | ||
|
|
e2c3ff00fb | ||
|
|
c783ff3dcf | ||
|
|
2c360dc3e8 | ||
|
|
5342647bfb | ||
|
|
46a2f6f0d8 | ||
|
|
f8af8fc171 | ||
|
|
3d0ee9d72b | ||
|
|
5a0bb260b4 | ||
|
|
9a7f1f5f2f | ||
|
|
d88b04fc30 | ||
|
|
6fe4cc98b9 | ||
|
|
b9bcfd8d2c | ||
|
|
e3bd31bb52 | ||
|
|
0922e3e459 | ||
|
|
a168b12bb2 | ||
|
|
8c01deba86 | ||
|
|
b645487e79 | ||
|
|
91569affd7 | ||
|
|
1ed44ee6f3 | ||
|
|
fce120fa31 | ||
|
|
a02b0c0c3c | ||
|
|
c86999f389 | ||
|
|
2b546967d2 | ||
|
|
591d2ba4d4 | ||
|
|
37cc4ab197 | ||
|
|
d775974a90 | ||
|
|
cc62aa4a9e | ||
|
|
5b8b7b04be | ||
|
|
b4a8853591 | ||
|
|
4220dfe2a5 | ||
|
|
4557426c28 | ||
|
|
8ee7fe8e66 | ||
|
|
c4a41f994a | ||
|
|
fa639424f5 | ||
|
|
e618a8390c | ||
|
|
393e9ed2e4 | ||
|
|
e1ea441215 | ||
|
|
654661fd66 | ||
|
|
c5a21f8da0 | ||
|
|
ca5b168117 | ||
|
|
aa88eb42ee | ||
|
|
ac84cadd77 | ||
|
|
81d5235b9f | ||
|
|
8944762c76 | ||
|
|
3d05c34673 | ||
|
|
c2ee41c844 | ||
|
|
6b537f30a5 | ||
|
|
a3ae95df09 | ||
|
|
4349d68f87 | ||
|
|
7d5c20125a | ||
|
|
2583dbd3f2 | ||
|
|
a103b93583 | ||
|
|
fff018eafb | ||
|
|
60b824e7a4 | ||
|
|
e27e904157 | ||
|
|
fe15667986 | ||
|
|
bd9029e533 | ||
|
|
fa71d563ea | ||
|
|
143918bc5e | ||
|
|
961680af8c | ||
|
|
6e3b3ce888 | ||
|
|
44292c3b55 | ||
|
|
c024c1b8e4 | ||
|
|
13adfdec11 | ||
|
|
e9ec28aab7 | ||
|
|
0af4d1d6e3 | ||
|
|
3ef0e63533 | ||
|
|
f4dad69ccd | ||
|
|
b811316d0c | ||
|
|
f59aeb2782 | ||
|
|
24ce41935e | ||
|
|
3c4749ead0 | ||
|
|
22ab5b9660 | ||
|
|
22f16f1da3 | ||
|
|
a97409dfd7 | ||
|
|
eaa6a664c8 | ||
|
|
d934b3b3b4 |
57
.agents/settings.local.json
Normal file
57
.agents/settings.local.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cat:*)",
|
||||
"Bash(git -C /home/purian23/dms diff --stat .github/workflows/)",
|
||||
"Bash(git -C /home/purian23/projects/danklinux diff --stat .github/workflows/)",
|
||||
"Bash(git -C /home/purian23/dms diff .github/workflows/)",
|
||||
"Bash(git -C /home/purian23/dms diff .github/workflows/run-ppa.yml)",
|
||||
"Bash(osc cat:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(git show-ref:*)",
|
||||
"Bash(git tag:*)",
|
||||
"Bash(bash -c 'ALL_PATHS=$(grep -A 5 \"\"<service name=\\\"\"download_url\\\"\">\"\" distro/debian/dms/_service | grep \"\"<param name=\\\"\"path\\\"\">\"\" | sed \"\"s/.*<param name=\\\"\"path\\\"\">\\(.*\\)<\\/param>.*/\\1/\"\"); SOURCE_PATH=\"\"\"\"; for path in $ALL_PATHS; do if echo \"\"$path\"\" | grep -qE \"\"(source|archive|\\.tar\\.(gz|xz|bz2))\"\" && ! echo \"\"$path\"\" | grep -qE \"\"(distropkg|binary)\"\"; then SOURCE_PATH=\"\"$path\"\"; break; fi; done; echo \"\"Selected path: $SOURCE_PATH\"\"')",
|
||||
"Bash(curl:*)",
|
||||
"Bash(tar:*)",
|
||||
"Bash(git -C /home/purian23/dms log:*)",
|
||||
"Bash(osc status:*)",
|
||||
"Bash(osc commit:*)",
|
||||
"Bash(osc up:*)",
|
||||
"Bash(osc results:*)",
|
||||
"Bash(osc api:*)",
|
||||
"Bash(systemctl:*)",
|
||||
"Bash(dms version:*)",
|
||||
"Bash(git describe:*)",
|
||||
"Bash(qmlsc:*)",
|
||||
"Bash(qmllint-qt6:*)",
|
||||
"Bash(make fmt:*)",
|
||||
"Bash(make test:*)",
|
||||
"Bash(dms chroma list-styles:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(time dms chroma:*)",
|
||||
"Bash(dms chroma:*)",
|
||||
"Bash(make build:*)",
|
||||
"Bash(pgrep:*)",
|
||||
"Bash(go build:*)",
|
||||
"Bash(/tmp/dms-test chroma:*)",
|
||||
"Bash(1)",
|
||||
"Bash(go install:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(journalctl:*)",
|
||||
"Bash(qdbus:*)",
|
||||
"Bash(TZ='Asia/Tokyo' date:*)",
|
||||
"Bash(dms --help:*)",
|
||||
"Bash(dms run:*)",
|
||||
"Bash(dms status:*)",
|
||||
"Bash(dms kill:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(qmlscene:*)",
|
||||
"Bash(quickshell --version:*)",
|
||||
"WebFetch(domain:forum.qt.io)",
|
||||
"Bash(gh api:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@@ -191,6 +191,11 @@ jobs:
|
||||
git fetch origin --force tag ${TAG}
|
||||
git checkout ${TAG}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
- name: Download core artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -229,6 +234,7 @@ jobs:
|
||||
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
|
||||
- **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems
|
||||
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
|
||||
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
|
||||
- **`dms-qml.tar.gz`** - QML source code only
|
||||
|
||||
### Checksums
|
||||
@@ -387,6 +393,19 @@ jobs:
|
||||
rm -rf _temp_full
|
||||
done
|
||||
|
||||
- name: Generate vendored source tarball
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
VERSION_NUM=${TAG#v}
|
||||
cd core
|
||||
go mod vendor
|
||||
cd ..
|
||||
tar czf "_release_assets/dms-cli-${VERSION_NUM}.tar.gz" \
|
||||
--transform "s,^core/,dms-cli-${VERSION_NUM}/," \
|
||||
--exclude='core/.git' \
|
||||
core/
|
||||
(cd _release_assets && sha256sum "dms-cli-${VERSION_NUM}.tar.gz" > "dms-cli-${VERSION_NUM}.tar.gz.sha256")
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
2
.github/workflows/run-obs.yml
vendored
2
.github/workflows/run-obs.yml
vendored
@@ -335,7 +335,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
- name: Install OSC
|
||||
run: |
|
||||
|
||||
2
.github/workflows/run-ppa.yml
vendored
2
.github/workflows/run-ppa.yml
vendored
@@ -158,7 +158,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version-file: ./core/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Install build dependencies
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -56,6 +56,8 @@ UNUSED
|
||||
|
||||
CLAUDE-activeContext.md
|
||||
CLAUDE-temp.md
|
||||
AGENTS-activeContext.md
|
||||
AGENTS-temp.md
|
||||
|
||||
# Auto-generated theme files
|
||||
*.generated.*
|
||||
|
||||
@@ -37,10 +37,43 @@ This is a monorepo, the easiest thing to do is to open an editor in either `quic
|
||||
1. Install the [QML Extension](https://doc.qt.io/vscodeext/)
|
||||
2. Configure `ctrl+shift+p` -> user preferences (json) with qmlls path
|
||||
|
||||
**Note:** Paths may vary by distribution. Below are examples for Arch Linux and Fedora.
|
||||
|
||||
**Arch Linux:**
|
||||
|
||||
```json
|
||||
{
|
||||
"[qml]": {
|
||||
"editor.defaultFormatter": "qt-project.qmlls",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"qt-qml.doNotAskForQmllsDownload": true,
|
||||
"qt-qml.qmlls.customExePath": "/usr/lib/qt6/bin/qmlls"
|
||||
"qt-qml.qmlls.customExePath": "/usr/lib/qt6/bin/qmlls",
|
||||
"qt-core.additionalQtPaths": [
|
||||
{
|
||||
"name": "Qt-6.x-linux-g++",
|
||||
"path": "/usr/bin/qmake"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Fedora:**
|
||||
|
||||
```json
|
||||
{
|
||||
"[qml]": {
|
||||
"editor.defaultFormatter": "qt-project.qmlls",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"qt-qml.doNotAskForQmllsDownload": true,
|
||||
"qt-qml.qmlls.customExePath": "/usr/bin/qmlls",
|
||||
"qt-core.additionalQtPaths": [
|
||||
{
|
||||
"name": "Qt-6.x-Fedora-linux-g++",
|
||||
"path": "/usr/bin/qmake6"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
|
||||
|
||||
</div>
|
||||
|
||||
DankMaterialShell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
|
||||
DankMaterialShell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), [Miracle WM](https://github.com/miracle-wm-org/miracle-wm), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -105,7 +105,7 @@ Extend functionality with the [plugin registry](https://plugins.danklinux.com).
|
||||
|
||||
## Supported Compositors
|
||||
|
||||
Works best with [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [Sway](https://swaywm.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [labwc](https://labwc.github.io/), and [Scroll](https://github.com/dawsers/scroll) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features.
|
||||
Works best with [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [Sway](https://swaywm.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), and [Miracle WM](https://github.com/miracle-wm-org/miracle-wm) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features.
|
||||
|
||||
[Compositor configuration guide](https://danklinux.com/docs/dankmaterialshell/compositors)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v2.6.2
|
||||
rev: v2.9.0
|
||||
hooks:
|
||||
- id: golangci-lint-fmt
|
||||
require_serial: true
|
||||
|
||||
@@ -112,6 +112,7 @@ var clipClearCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var clipWatchStore bool
|
||||
var clipWatchMimes bool
|
||||
|
||||
var clipSearchCmd = &cobra.Command{
|
||||
Use: "search [query]",
|
||||
@@ -211,6 +212,7 @@ func init() {
|
||||
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard tracking")
|
||||
|
||||
clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)")
|
||||
clipWatchCmd.Flags().BoolVarP(&clipWatchMimes, "mimes", "m", false, "Show all offered MIME types")
|
||||
|
||||
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
|
||||
|
||||
@@ -328,6 +330,30 @@ func runClipWatch(cmd *cobra.Command, args []string) {
|
||||
}); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
case clipWatchMimes:
|
||||
if err := clipboard.WatchAll(ctx, func(data []byte, mimeType string, allMimes []string) {
|
||||
if clipJSONOutput {
|
||||
out := map[string]any{
|
||||
"data": string(data),
|
||||
"mimeType": mimeType,
|
||||
"mimeTypes": allMimes,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"size": len(data),
|
||||
}
|
||||
j, _ := json.Marshal(out)
|
||||
fmt.Println(string(j))
|
||||
return
|
||||
}
|
||||
fmt.Printf("=== Clipboard Change ===\n")
|
||||
fmt.Printf("Selected: %s\n", mimeType)
|
||||
fmt.Printf("All MIME types:\n")
|
||||
for _, m := range allMimes {
|
||||
fmt.Printf(" - %s\n", m)
|
||||
}
|
||||
fmt.Printf("Size: %d bytes\n\n", len(data))
|
||||
}); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
case clipJSONOutput:
|
||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
out := map[string]any{
|
||||
|
||||
@@ -524,5 +524,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
chromaCmd,
|
||||
doctorCmd,
|
||||
configCmd,
|
||||
dlCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
@@ -101,11 +102,13 @@ var doctorCmd = &cobra.Command{
|
||||
var (
|
||||
doctorVerbose bool
|
||||
doctorJSON bool
|
||||
doctorCopy bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions")
|
||||
doctorCmd.Flags().BoolVarP(&doctorJSON, "json", "j", false, "Output results in JSON format")
|
||||
doctorCmd.Flags().BoolVarP(&doctorCopy, "copy", "C", false, "Copy results to clipboard in GitHub-friendly format")
|
||||
}
|
||||
|
||||
type category int
|
||||
@@ -192,7 +195,7 @@ func (r checkResult) toJSON() checkResultJSON {
|
||||
}
|
||||
|
||||
func runDoctor(cmd *cobra.Command, args []string) {
|
||||
if !doctorJSON {
|
||||
if !doctorJSON && !doctorCopy {
|
||||
printDoctorHeader()
|
||||
}
|
||||
|
||||
@@ -210,9 +213,17 @@ func runDoctor(cmd *cobra.Command, args []string) {
|
||||
checkEnvironmentVars(),
|
||||
)
|
||||
|
||||
if doctorJSON {
|
||||
switch {
|
||||
case doctorCopy:
|
||||
text := formatResultsPlain(results)
|
||||
if err := clipboard.CopyOpts([]byte(text), "text/plain;charset=utf-8", false, false); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to copy to clipboard: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Doctor report copied to clipboard")
|
||||
case doctorJSON:
|
||||
printResultsJSON(results)
|
||||
} else {
|
||||
default:
|
||||
printResults(results)
|
||||
printSummary(results, qsMissingFeatures)
|
||||
}
|
||||
@@ -638,6 +649,76 @@ func checkI2CAvailability() checkResult {
|
||||
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control", doctorDocsURL + "#optional-features"}
|
||||
}
|
||||
|
||||
func checkKImageFormats() checkResult {
|
||||
url := doctorDocsURL + "#optional-features"
|
||||
desc := "Extra image format support (AVIF, HEIF, JXL)"
|
||||
|
||||
pluginDir := findQtPluginDir()
|
||||
if pluginDir == "" {
|
||||
return checkResult{catOptionalFeatures, "kimageformats", statusInfo, "Cannot detect (qtpaths not found)", desc, url}
|
||||
}
|
||||
|
||||
imageFormatsDir := filepath.Join(pluginDir, "imageformats")
|
||||
keyPlugins := []struct{ file, format string }{
|
||||
{"kimg_avif.so", "AVIF"},
|
||||
{"kimg_heif.so", "HEIF"},
|
||||
{"kimg_jxl.so", "JXL"},
|
||||
{"kimg_exr.so", "EXR"},
|
||||
}
|
||||
|
||||
var found []string
|
||||
for _, p := range keyPlugins {
|
||||
if _, err := os.Stat(filepath.Join(imageFormatsDir, p.file)); err == nil {
|
||||
found = append(found, p.format)
|
||||
}
|
||||
}
|
||||
|
||||
if len(found) == 0 {
|
||||
return checkResult{catOptionalFeatures, "kimageformats", statusWarn, "Not installed", desc, url}
|
||||
}
|
||||
|
||||
details := ""
|
||||
if doctorVerbose {
|
||||
details = fmt.Sprintf("Formats: %s (%s)", strings.Join(found, ", "), imageFormatsDir)
|
||||
}
|
||||
|
||||
return checkResult{catOptionalFeatures, "kimageformats", statusOK, fmt.Sprintf("Installed (%d formats)", len(found)), details, url}
|
||||
}
|
||||
|
||||
func findQtPluginDir() string {
|
||||
// Check QT_PLUGIN_PATH env var first (used by NixOS and custom setups)
|
||||
if envPath := os.Getenv("QT_PLUGIN_PATH"); envPath != "" {
|
||||
for dir := range strings.SplitSeq(envPath, ":") {
|
||||
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try qtpaths
|
||||
for _, cmd := range []string{"qtpaths6", "qtpaths"} {
|
||||
if output, err := exec.Command(cmd, "-query", "QT_INSTALL_PLUGINS").Output(); err == nil {
|
||||
if dir := strings.TrimSpace(string(output)); dir != "" {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: common distro paths
|
||||
for _, dir := range []string{
|
||||
"/usr/lib/qt6/plugins",
|
||||
"/usr/lib64/qt6/plugins",
|
||||
"/usr/lib/x86_64-linux-gnu/qt6/plugins",
|
||||
"/usr/lib/aarch64-linux-gnu/qt6/plugins",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectNetworkBackend(stackResult *network.DetectResult) string {
|
||||
switch stackResult.Backend {
|
||||
case network.BackendNetworkManager:
|
||||
@@ -678,7 +759,21 @@ func checkOptionalDependencies() []checkResult {
|
||||
logindStatus, logindMsg := getOptionalDBusStatus("org.freedesktop.login1")
|
||||
results = append(results, checkResult{catOptionalFeatures, "logind", logindStatus, logindMsg, "Session management", optionalFeaturesURL})
|
||||
|
||||
cupsPkHelperBus := "org.opensuse.CupsPkHelper.Mechanism"
|
||||
var cupsPkStatus status
|
||||
var cupsPkMsg string
|
||||
switch {
|
||||
case utils.IsDBusServiceAvailable(cupsPkHelperBus):
|
||||
cupsPkStatus, cupsPkMsg = statusOK, "Running"
|
||||
case utils.IsDBusServiceActivatable(cupsPkHelperBus):
|
||||
cupsPkStatus, cupsPkMsg = statusOK, "Available"
|
||||
default:
|
||||
cupsPkStatus, cupsPkMsg = statusWarn, "Not available (install cups-pk-helper)"
|
||||
}
|
||||
results = append(results, checkResult{catOptionalFeatures, "cups-pk-helper", cupsPkStatus, cupsPkMsg, "Printer management", optionalFeaturesURL})
|
||||
|
||||
results = append(results, checkI2CAvailability())
|
||||
results = append(results, checkKImageFormats())
|
||||
|
||||
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
||||
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
|
||||
@@ -929,3 +1024,36 @@ func printSummary(results []checkResult, qsMissingFeatures bool) {
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func formatResultsPlain(results []checkResult) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## DMS Doctor Report\n\n")
|
||||
|
||||
currentCategory := category(-1)
|
||||
for _, r := range results {
|
||||
if r.category != currentCategory {
|
||||
if currentCategory != -1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**%s**\n", r.category.String()))
|
||||
currentCategory = r.category
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("- [%s] %s: %s\n", r.status, r.name, r.message))
|
||||
|
||||
if doctorVerbose && r.details != "" {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", r.details))
|
||||
}
|
||||
}
|
||||
|
||||
var ds DoctorStatus
|
||||
for _, r := range results {
|
||||
ds.Add(r)
|
||||
}
|
||||
|
||||
sb.WriteString("\n---\n")
|
||||
sb.WriteString(fmt.Sprintf("**Summary:** %d error(s), %d warning(s), %d ok\n",
|
||||
ds.ErrorCount(), ds.WarningCount(), ds.OKCount()))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
99
core/cmd/dms/commands_download.go
Normal file
99
core/cmd/dms/commands_download.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var dlOutput string
|
||||
var dlUserAgent string
|
||||
var dlTimeout int
|
||||
var dlIPv4Only bool
|
||||
|
||||
var dlCmd = &cobra.Command{
|
||||
Use: "dl <url>",
|
||||
Short: "Download a URL to stdout or file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runDownload(args[0]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)")
|
||||
dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header")
|
||||
dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Request timeout in seconds")
|
||||
dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only")
|
||||
}
|
||||
|
||||
func runDownload(url string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(dlTimeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case dlUserAgent != "":
|
||||
req.Header.Set("User-Agent", dlUserAgent)
|
||||
default:
|
||||
req.Header.Set("User-Agent", "DankMaterialShell/1.0 (Linux)")
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
transport := &http.Transport{DialContext: dialer.DialContext}
|
||||
if dlIPv4Only {
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "tcp4", addr)
|
||||
}
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if dlOutput == "" {
|
||||
_, err = io.Copy(os.Stdout, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(dlOutput); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Create(dlOutput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create failed: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
os.Remove(dlOutput)
|
||||
return fmt.Errorf("write failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(dlOutput)
|
||||
return nil
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func installGreeter() error {
|
||||
}
|
||||
|
||||
fmt.Println("\nSynchronizing DMS configurations...")
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, ""); err != nil {
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -147,12 +147,30 @@ func syncGreeter() error {
|
||||
}
|
||||
fmt.Printf("✓ Found DMS at: %s\n", dmsPath)
|
||||
|
||||
if !isGreeterEnabled() {
|
||||
fmt.Println("\n⚠ DMS greeter is not enabled in greetd config.")
|
||||
fmt.Print("Would you like to enable it now? (Y/n): ")
|
||||
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
|
||||
if response != "n" && response != "no" {
|
||||
if err := enableGreeter(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("greeter must be enabled before syncing")
|
||||
}
|
||||
}
|
||||
|
||||
cacheDir := "/var/cache/dms-greeter"
|
||||
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("greeter cache directory not found at %s\nPlease install the greeter first", cacheDir)
|
||||
}
|
||||
|
||||
greeterGroupExists := checkGroupExists("greeter")
|
||||
greeterGroup := greeter.DetectGreeterGroup()
|
||||
greeterGroupExists := utils.HasGroup(greeterGroup)
|
||||
if greeterGroupExists {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
@@ -165,36 +183,59 @@ func syncGreeter() error {
|
||||
return fmt.Errorf("failed to check groups: %w", err)
|
||||
}
|
||||
|
||||
inGreeterGroup := strings.Contains(string(groupsOutput), "greeter")
|
||||
inGreeterGroup := strings.Contains(string(groupsOutput), greeterGroup)
|
||||
if !inGreeterGroup {
|
||||
fmt.Println("\n⚠ Warning: You are not in the greeter group.")
|
||||
fmt.Print("Would you like to add your user to the greeter group? (y/N): ")
|
||||
fmt.Printf("\n⚠ Warning: You are not in the %s group.\n", greeterGroup)
|
||||
fmt.Printf("Would you like to add your user to the %s group? (Y/n): ", greeterGroup)
|
||||
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
|
||||
if response == "y" || response == "yes" {
|
||||
fmt.Println("\nAdding user to greeter group...")
|
||||
addUserCmd := exec.Command("sudo", "usermod", "-aG", "greeter", currentUser.Username)
|
||||
if response != "n" && response != "no" {
|
||||
fmt.Printf("\nAdding user to %s group...\n", greeterGroup)
|
||||
addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username)
|
||||
addUserCmd.Stdout = os.Stdout
|
||||
addUserCmd.Stderr = os.Stderr
|
||||
if err := addUserCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to add user to greeter group: %w", err)
|
||||
return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err)
|
||||
}
|
||||
fmt.Println("✓ User added to greeter group")
|
||||
fmt.Printf("✓ User added to %s group\n", greeterGroup)
|
||||
fmt.Println("⚠ You will need to log out and back in for the group change to take effect")
|
||||
} else {
|
||||
return fmt.Errorf("aborted: user must be in the greeter group before syncing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compositor := detectConfiguredCompositor()
|
||||
if compositor == "" {
|
||||
compositors := greeter.DetectCompositors()
|
||||
switch len(compositors) {
|
||||
case 0:
|
||||
return fmt.Errorf("no supported compositors found")
|
||||
case 1:
|
||||
compositor = compositors[0]
|
||||
fmt.Printf("✓ Using compositor: %s\n", compositor)
|
||||
default:
|
||||
var err error
|
||||
compositor, err = promptCompositorChoice(compositors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ Selected compositor: %s\n", compositor)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("✓ Detected compositor from config: %s\n", compositor)
|
||||
}
|
||||
|
||||
fmt.Println("\nSetting up permissions and ACLs...")
|
||||
if err := greeter.SetupDMSGroup(logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\nSynchronizing DMS configurations...")
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, ""); err != nil {
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -205,21 +246,6 @@ func syncGreeter() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkGroupExists(groupName string) bool {
|
||||
data, err := os.ReadFile("/etc/group")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
lines := strings.SplitSeq(string(data), "\n")
|
||||
for line := range lines {
|
||||
if strings.HasPrefix(line, groupName+":") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func disableDisplayManager(dmName string) (bool, error) {
|
||||
state, err := getSystemdServiceState(dmName)
|
||||
if err != nil {
|
||||
@@ -351,7 +377,7 @@ func ensureGraphicalTarget() error {
|
||||
func handleConflictingDisplayManagers() error {
|
||||
fmt.Println("\n=== Checking for Conflicting Display Managers ===")
|
||||
|
||||
conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm"}
|
||||
conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
|
||||
|
||||
disabledAny := false
|
||||
var errors []string
|
||||
@@ -552,6 +578,39 @@ func enableGreeter() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isGreeterEnabled() bool {
|
||||
data, err := os.ReadFile("/etc/greetd/config.toml")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(data), "dms-greeter")
|
||||
}
|
||||
|
||||
func detectConfiguredCompositor() string {
|
||||
data, err := os.ReadFile("/etc/greetd/config.toml")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "command") || !strings.Contains(trimmed, "dms-greeter") {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(trimmed, "--command niri"):
|
||||
return "niri"
|
||||
case strings.Contains(trimmed, "--command hyprland"):
|
||||
return "hyprland"
|
||||
case strings.Contains(trimmed, "--command sway"):
|
||||
return "sway"
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func promptCompositorChoice(compositors []string) (string, error) {
|
||||
fmt.Println("\nMultiple compositors detected:")
|
||||
for i, comp := range compositors {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
|
||||
@@ -63,6 +64,7 @@ func init() {
|
||||
keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked")
|
||||
keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds")
|
||||
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
|
||||
keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)")
|
||||
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
|
||||
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
|
||||
|
||||
@@ -81,24 +83,35 @@ func init() {
|
||||
func initializeProviders() {
|
||||
registry := keybinds.GetDefaultRegistry()
|
||||
|
||||
hyprlandProvider := providers.NewHyprlandProvider("$HOME/.config/hypr")
|
||||
hyprlandProvider := providers.NewHyprlandProvider("")
|
||||
if err := registry.Register(hyprlandProvider); err != nil {
|
||||
log.Warnf("Failed to register Hyprland provider: %v", err)
|
||||
}
|
||||
|
||||
mangowcProvider := providers.NewMangoWCProvider("$HOME/.config/mango")
|
||||
mangowcProvider := providers.NewMangoWCProvider("")
|
||||
if err := registry.Register(mangowcProvider); err != nil {
|
||||
log.Warnf("Failed to register MangoWC provider: %v", err)
|
||||
}
|
||||
|
||||
scrollProvider := providers.NewSwayProvider("$HOME/.config/scroll")
|
||||
if err := registry.Register(scrollProvider); err != nil {
|
||||
log.Warnf("Failed to register Scroll provider: %v", err)
|
||||
configDir, _ := os.UserConfigDir()
|
||||
|
||||
if configDir != "" {
|
||||
scrollProvider := providers.NewSwayProvider(filepath.Join(configDir, "scroll"))
|
||||
if err := registry.Register(scrollProvider); err != nil {
|
||||
log.Warnf("Failed to register Scroll provider: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
swayProvider := providers.NewSwayProvider("$HOME/.config/sway")
|
||||
if err := registry.Register(swayProvider); err != nil {
|
||||
log.Warnf("Failed to register Sway provider: %v", err)
|
||||
miracleProvider := providers.NewMiracleProvider("")
|
||||
if err := registry.Register(miracleProvider); err != nil {
|
||||
log.Warnf("Failed to register Miracle WM provider: %v", err)
|
||||
}
|
||||
|
||||
if configDir != "" {
|
||||
swayProvider := providers.NewSwayProvider(filepath.Join(configDir, "sway"))
|
||||
if err := registry.Register(swayProvider); err != nil {
|
||||
log.Warnf("Failed to register Sway provider: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
niriProvider := providers.NewNiriProvider("")
|
||||
@@ -143,6 +156,8 @@ func makeProviderWithPath(name, path string) keybinds.Provider {
|
||||
return providers.NewSwayProvider(path)
|
||||
case "scroll":
|
||||
return providers.NewSwayProvider(path)
|
||||
case "miracle":
|
||||
return providers.NewMiracleProvider(path)
|
||||
case "niri":
|
||||
return providers.NewNiriProvider(path)
|
||||
default:
|
||||
@@ -212,6 +227,9 @@ func runKeybindsSet(cmd *cobra.Command, args []string) {
|
||||
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
||||
options["repeat"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
|
||||
options["allow-inhibiting"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
||||
options["flags"] = v
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -24,6 +26,243 @@ var setupCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var setupBindsCmd = &cobra.Command{
|
||||
Use: "binds",
|
||||
Short: "Deploy default keybinds config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("binds"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupLayoutCmd = &cobra.Command{
|
||||
Use: "layout",
|
||||
Short: "Deploy default layout config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("layout"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupColorsCmd = &cobra.Command{
|
||||
Use: "colors",
|
||||
Short: "Deploy default colors config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("colors"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupAlttabCmd = &cobra.Command{
|
||||
Use: "alttab",
|
||||
Short: "Deploy default alt-tab config (niri only)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("alttab"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupOutputsCmd = &cobra.Command{
|
||||
Use: "outputs",
|
||||
Short: "Deploy default outputs config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("outputs"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupCursorCmd = &cobra.Command{
|
||||
Use: "cursor",
|
||||
Short: "Deploy default cursor config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("cursor"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var setupWindowrulesCmd = &cobra.Command{
|
||||
Use: "windowrules",
|
||||
Short: "Deploy default window rules config",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetupDmsConfig("windowrules"); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type dmsConfigSpec struct {
|
||||
niriFile string
|
||||
hyprFile string
|
||||
niriContent func(terminal string) string
|
||||
hyprContent func(terminal string) string
|
||||
}
|
||||
|
||||
var dmsConfigSpecs = map[string]dmsConfigSpec{
|
||||
"binds": {
|
||||
niriFile: "binds.kdl",
|
||||
hyprFile: "binds.conf",
|
||||
niriContent: func(t string) string {
|
||||
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
||||
},
|
||||
hyprContent: func(t string) string {
|
||||
return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
||||
},
|
||||
},
|
||||
"layout": {
|
||||
niriFile: "layout.kdl",
|
||||
hyprFile: "layout.conf",
|
||||
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
||||
hyprContent: func(_ string) string { return config.HyprLayoutConfig },
|
||||
},
|
||||
"colors": {
|
||||
niriFile: "colors.kdl",
|
||||
hyprFile: "colors.conf",
|
||||
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
||||
hyprContent: func(_ string) string { return config.HyprColorsConfig },
|
||||
},
|
||||
"alttab": {
|
||||
niriFile: "alttab.kdl",
|
||||
niriContent: func(_ string) string { return config.NiriAlttabConfig },
|
||||
},
|
||||
"outputs": {
|
||||
niriFile: "outputs.kdl",
|
||||
hyprFile: "outputs.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return "" },
|
||||
},
|
||||
"cursor": {
|
||||
niriFile: "cursor.kdl",
|
||||
hyprFile: "cursor.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return "" },
|
||||
},
|
||||
"windowrules": {
|
||||
niriFile: "windowrules.kdl",
|
||||
hyprFile: "windowrules.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return "" },
|
||||
},
|
||||
}
|
||||
|
||||
func detectTerminal() (string, error) {
|
||||
terminals := []string{"ghostty", "foot", "kitty", "alacritty"}
|
||||
var found []string
|
||||
for _, t := range terminals {
|
||||
if utils.CommandExists(t) {
|
||||
found = append(found, t)
|
||||
}
|
||||
}
|
||||
|
||||
switch len(found) {
|
||||
case 0:
|
||||
return "ghostty", nil
|
||||
case 1:
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
fmt.Println("Multiple terminals detected:")
|
||||
for i, t := range found {
|
||||
fmt.Printf("%d) %s\n", i+1, t)
|
||||
}
|
||||
fmt.Printf("\nChoice (1-%d): ", len(found))
|
||||
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
choice := 0
|
||||
fmt.Sscanf(response, "%d", &choice)
|
||||
if choice < 1 || choice > len(found) {
|
||||
return "", fmt.Errorf("invalid choice")
|
||||
}
|
||||
return found[choice-1], nil
|
||||
}
|
||||
|
||||
func detectCompositorForSetup() (string, error) {
|
||||
compositors := greeter.DetectCompositors()
|
||||
|
||||
switch len(compositors) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)")
|
||||
case 1:
|
||||
return strings.ToLower(compositors[0]), nil
|
||||
}
|
||||
|
||||
selected, err := greeter.PromptCompositorChoice(compositors)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(selected), nil
|
||||
}
|
||||
|
||||
func runSetupDmsConfig(name string) error {
|
||||
spec, ok := dmsConfigSpecs[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown config: %s", name)
|
||||
}
|
||||
|
||||
compositor, err := detectCompositorForSetup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filename string
|
||||
var contentFn func(string) string
|
||||
switch compositor {
|
||||
case "niri":
|
||||
filename = spec.niriFile
|
||||
contentFn = spec.niriContent
|
||||
case "hyprland":
|
||||
filename = spec.hyprFile
|
||||
contentFn = spec.hyprContent
|
||||
default:
|
||||
return fmt.Errorf("unsupported compositor: %s", compositor)
|
||||
}
|
||||
|
||||
if filename == "" {
|
||||
return fmt.Errorf("%s is not supported for %s", name, compositor)
|
||||
}
|
||||
|
||||
var dmsDir string
|
||||
switch compositor {
|
||||
case "niri":
|
||||
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms")
|
||||
case "hyprland":
|
||||
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create dms directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dmsDir, filename)
|
||||
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||
return fmt.Errorf("%s already exists and is not empty: %s", name, path)
|
||||
}
|
||||
|
||||
terminal := "ghostty"
|
||||
if contentFn != nil && name == "binds" {
|
||||
terminal, err = detectTerminal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
content := contentFn(terminal)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", filename, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Deployed %s to %s\n", name, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSetup() error {
|
||||
fmt.Println("=== DMS Configuration Setup ===")
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ func init() {
|
||||
// Add subcommands to greeter
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
||||
|
||||
// Add subcommands to setup
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
|
||||
// Add subcommands to update
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ func init() {
|
||||
// Add subcommands to greeter
|
||||
greeterCmd.AddCommand(greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
||||
|
||||
// Add subcommands to setup
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
|
||||
// Add subcommands to plugins
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ func runShellInteractive(session bool) {
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
||||
}
|
||||
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland")
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||
}
|
||||
|
||||
cmd.Stdin = os.Stdin
|
||||
@@ -450,7 +450,7 @@ func runShellDaemon(session bool) {
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
|
||||
}
|
||||
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland")
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||
}
|
||||
|
||||
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
||||
|
||||
39
core/go.mod
39
core/go.mod
@@ -1,11 +1,11 @@
|
||||
module github.com/AvengeMedia/DankMaterialShell/core
|
||||
|
||||
go 1.24.6
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
@@ -19,44 +19,43 @@ require (
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
|
||||
golang.org/x/image v0.35.0
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
|
||||
golang.org/x/image v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.8.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.4 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -70,7 +69,11 @@ require (
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/text v0.33.0
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/text v0.34.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
// v0.0.1 tag is missing a LICENSE file; master has it.
|
||||
// See: https://github.com/mattn/go-localereader/issues/2
|
||||
replace github.com/mattn/go-localereader v0.0.1 => github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75
|
||||
|
||||
70
core/go.sum
70
core/go.sum
@@ -20,30 +20,28 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
|
||||
github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.8.0 h1:/z8v+H+4XLluJKS7rAc7uHZTalT5Z+1430ld3lePSRI=
|
||||
github.com/clipperhouse/displaywidth v0.8.0/go.mod h1:UpOXiIKep+TohQYwvAAM/VDU8v3Z5rnWTxiwueR0XvQ=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
|
||||
github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -66,12 +64,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc h1:rhkjrnRkamkRC7woapp425E4CAH6RPcqsS9X8LA93IY=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc/go.mod h1:X1oe0Z2qMsa9hkar3AAPuL9hu4Mi3ztXEjdqRhr6fcc=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9 h1:VzdR70t+SMjYnBgnbtNpq4ElZAAovLPMG+GFX8OBRtM=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9/go.mod h1:EWlxLBkiFCzXNCadvt05fT9PCAE2sUedgDsvUUIo18s=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -88,8 +86,8 @@ github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvE
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -103,8 +101,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
@@ -152,24 +150,24 @@ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.m
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
)
|
||||
|
||||
type ClipboardChange struct {
|
||||
Data []byte
|
||||
MimeType string
|
||||
Data []byte
|
||||
MimeType string
|
||||
MimeTypes []string
|
||||
}
|
||||
|
||||
func Watch(ctx context.Context, callback func(data []byte, mimeType string)) error {
|
||||
@@ -141,6 +142,131 @@ func Watch(ctx context.Context, callback func(data []byte, mimeType string)) err
|
||||
}
|
||||
}
|
||||
|
||||
func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, allMimeTypes []string)) error {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
defer display.Destroy()
|
||||
|
||||
wlCtx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
defer registry.Destroy()
|
||||
|
||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
var seat *wlclient.Seat
|
||||
var bindErr error
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case "ext_data_control_manager_v1":
|
||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(wlCtx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
case "wl_seat":
|
||||
if seat != nil {
|
||||
return
|
||||
}
|
||||
seat = wlclient.NewSeat(wlCtx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
return fmt.Errorf("registry bind: %w", bindErr)
|
||||
}
|
||||
if dataControlMgr == nil {
|
||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
}
|
||||
defer dataControlMgr.Destroy()
|
||||
if seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
defer device.Destroy()
|
||||
|
||||
offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string)
|
||||
|
||||
device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
|
||||
if e.Id == nil {
|
||||
return
|
||||
}
|
||||
offerMimeTypes[e.Id] = nil
|
||||
e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) {
|
||||
offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType)
|
||||
})
|
||||
})
|
||||
|
||||
device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
|
||||
if e.Id == nil {
|
||||
return
|
||||
}
|
||||
|
||||
mimes := offerMimeTypes[e.Id]
|
||||
selectedMime := selectPreferredMimeType(mimes)
|
||||
if selectedMime == "" {
|
||||
return
|
||||
}
|
||||
|
||||
mimesCopy := make([]string, len(mimes))
|
||||
copy(mimesCopy, mimes)
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil {
|
||||
w.Close()
|
||||
r.Close()
|
||||
return
|
||||
}
|
||||
w.Close()
|
||||
|
||||
go func() {
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
callback(data, selectedMime, mimesCopy)
|
||||
}()
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
|
||||
return fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
if err := wlCtx.Dispatch(); err != nil {
|
||||
if isTimeoutError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("dispatch: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeoutError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -644,7 +644,7 @@ func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalC
|
||||
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") {
|
||||
startupSectionFound = true
|
||||
result = append(result, "exec-once = dms run")
|
||||
result = append(result, "env = QT_QPA_PLATFORM,wayland")
|
||||
result = append(result, "env = QT_QPA_PLATFORM,wayland;xcb")
|
||||
result = append(result, "env = ELECTRON_OZONE_PLATFORM_HINT,auto")
|
||||
result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3")
|
||||
result = append(result, "env = QT_QPA_PLATFORMTHEME_QT6,gtk3")
|
||||
@@ -659,7 +659,7 @@ func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalC
|
||||
if strings.Contains(line, "STARTUP APPS") {
|
||||
insertLines := []string{
|
||||
"exec-once = dms run",
|
||||
"env = QT_QPA_PLATFORM,wayland",
|
||||
"env = QT_QPA_PLATFORM,wayland;xcb",
|
||||
"env = ELECTRON_OZONE_PLATFORM_HINT,auto",
|
||||
"env = QT_QPA_PLATFORMTHEME,gtk3",
|
||||
"env = QT_QPA_PLATFORMTHEME_QT6,gtk3",
|
||||
@@ -677,7 +677,7 @@ func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalC
|
||||
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
|
||||
envVars := fmt.Sprintf(`environment {
|
||||
XDG_CURRENT_DESKTOP "niri"
|
||||
QT_QPA_PLATFORM "wayland"
|
||||
QT_QPA_PLATFORM "wayland;xcb"
|
||||
ELECTRON_OZONE_PLATFORM_HINT "auto"
|
||||
QT_QPA_PLATFORMTHEME "gtk3"
|
||||
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
|
||||
|
||||
@@ -27,6 +27,8 @@ bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
|
||||
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
|
||||
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
|
||||
bindl = , XF86AudioNext, exec, dms ipc call mpris next
|
||||
bindel = CTRL, XF86AudioRaiseVolume, exec, dms ipc call mpris increment 3
|
||||
bindel = CTRL, XF86AudioLowerVolume, exec, dms ipc call mpris decrement 3
|
||||
|
||||
# === Brightness Controls ===
|
||||
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
|
||||
|
||||
@@ -98,9 +98,11 @@ windowrule = float on, match:class ^(gnome-calculator)$
|
||||
windowrule = float on, match:class ^(galculator)$
|
||||
windowrule = float on, match:class ^(blueman-manager)$
|
||||
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
|
||||
windowrule = float on, match:class ^(steam)$
|
||||
windowrule = float on, match:class ^(xdg-desktop-portal)$
|
||||
|
||||
windowrule = noinitialfocus on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
||||
windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
||||
|
||||
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
|
||||
windowrule = float on, match:class ^(zoom)$
|
||||
|
||||
|
||||
@@ -60,6 +60,12 @@ binds {
|
||||
XF86AudioNext allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "next";
|
||||
}
|
||||
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "increment" "3";
|
||||
}
|
||||
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
|
||||
}
|
||||
|
||||
// === Brightness Controls ===
|
||||
XF86MonBrightnessUp allow-when-locked=true {
|
||||
|
||||
17
core/internal/config/embedded/niri-greeter.kdl
Normal file
17
core/internal/config/embedded/niri-greeter.kdl
Normal file
@@ -0,0 +1,17 @@
|
||||
hotkey-overlay {
|
||||
skip-at-startup
|
||||
}
|
||||
|
||||
environment {
|
||||
DMS_RUN_GREETER "1"
|
||||
}
|
||||
|
||||
gestures {
|
||||
hot-corners {
|
||||
off
|
||||
}
|
||||
}
|
||||
|
||||
layout {
|
||||
background-color "#000000"
|
||||
}
|
||||
@@ -228,10 +228,14 @@ window-rule {
|
||||
match app-id=r#"^galculator$"#
|
||||
match app-id=r#"^blueman-manager$"#
|
||||
match app-id=r#"^org\.gnome\.Nautilus$"#
|
||||
match app-id=r#"^steam$"#
|
||||
match app-id=r#"^xdg-desktop-portal$"#
|
||||
open-floating true
|
||||
}
|
||||
window-rule {
|
||||
match app-id=r#"^steam$"# title=r#"^notificationtoasts_\d+_desktop$"#
|
||||
default-floating-position x=10 y=10 relative-to="bottom-right"
|
||||
open-focused false
|
||||
}
|
||||
window-rule {
|
||||
match app-id=r#"^org\.wezfurlong\.wezterm$"#
|
||||
match app-id="Alacritty"
|
||||
|
||||
@@ -16,3 +16,6 @@ var NiriAlttabConfig string
|
||||
|
||||
//go:embed embedded/niri-binds.kdl
|
||||
var NiriBindsConfig string
|
||||
|
||||
//go:embed embedded/niri-greeter.kdl
|
||||
var NiriGreeterConfig string
|
||||
|
||||
@@ -8,10 +8,13 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/sblinch/kdl-go"
|
||||
"github.com/sblinch/kdl-go/document"
|
||||
)
|
||||
|
||||
// DetectDMSPath checks for DMS installation following XDG Base Directory specification
|
||||
@@ -19,6 +22,21 @@ func DetectDMSPath() (string, error) {
|
||||
return config.LocateDMSConfig()
|
||||
}
|
||||
|
||||
func DetectGreeterGroup() string {
|
||||
data, err := os.ReadFile("/etc/group")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "⚠ Warning: could not read /etc/group, defaulting to greeter")
|
||||
return "greeter"
|
||||
}
|
||||
|
||||
if group, found := utils.FindGroupData(string(data), "greeter", "greetd", "_greeter"); found {
|
||||
return group
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "⚠ Warning: no greeter group found in /etc/group, defaulting to greeter")
|
||||
return "greeter"
|
||||
}
|
||||
|
||||
// DetectCompositors checks which compositors are installed
|
||||
func DetectCompositors() []string {
|
||||
var compositors []string
|
||||
@@ -191,14 +209,17 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
if err := runSudoCmd(sudoPassword, "chown", "greeter:greeter", cacheDir); err != nil {
|
||||
group := DetectGreeterGroup()
|
||||
owner := fmt.Sprintf("%s:%s", group, group)
|
||||
|
||||
if err := runSudoCmd(sudoPassword, "chown", owner, cacheDir); err != nil {
|
||||
return fmt.Errorf("failed to set cache directory owner: %w", err)
|
||||
}
|
||||
|
||||
if err := runSudoCmd(sudoPassword, "chmod", "750", cacheDir); err != nil {
|
||||
if err := runSudoCmd(sudoPassword, "chmod", "755", cacheDir); err != nil {
|
||||
return fmt.Errorf("failed to set cache directory permissions: %w", err)
|
||||
}
|
||||
logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: greeter:greeter, permissions: 750)", cacheDir))
|
||||
logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: %s, permissions: 755)", cacheDir, owner))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -231,6 +252,8 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
|
||||
{filepath.Join(homeDir, ".local", "share"), ".local/share directory"},
|
||||
}
|
||||
|
||||
owner := DetectGreeterGroup()
|
||||
|
||||
logFunc("\nSetting up parent directory ACLs for greeter user access...")
|
||||
|
||||
for _, dir := range parentDirs {
|
||||
@@ -242,9 +265,9 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
|
||||
}
|
||||
|
||||
// Set ACL to allow greeter user read+execute permission (for session discovery)
|
||||
if err := runSudoCmd(sudoPassword, "setfacl", "-m", "u:greeter:rx", dir.path); err != nil {
|
||||
if err := runSudoCmd(sudoPassword, "setfacl", "-m", fmt.Sprintf("u:%s:rx", owner), dir.path); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err))
|
||||
logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m u:greeter:x %s", dir.path))
|
||||
logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m u:%s:x %s", owner, dir.path))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -268,17 +291,19 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
||||
return fmt.Errorf("failed to determine current user")
|
||||
}
|
||||
|
||||
group := DetectGreeterGroup()
|
||||
|
||||
// Check if user is already in greeter group
|
||||
groupsCmd := exec.Command("groups", currentUser)
|
||||
groupsOutput, err := groupsCmd.Output()
|
||||
if err == nil && strings.Contains(string(groupsOutput), "greeter") {
|
||||
logFunc(fmt.Sprintf("✓ %s is already in greeter group", currentUser))
|
||||
if err == nil && strings.Contains(string(groupsOutput), group) {
|
||||
logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group))
|
||||
} else {
|
||||
// Add current user to greeter group for file access permissions
|
||||
if err := runSudoCmd(sudoPassword, "usermod", "-aG", "greeter", currentUser); err != nil {
|
||||
return fmt.Errorf("failed to add %s to greeter group: %w", currentUser, err)
|
||||
if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, currentUser); err != nil {
|
||||
return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err)
|
||||
}
|
||||
logFunc(fmt.Sprintf("✓ Added %s to greeter group (logout/login required for changes to take effect)", currentUser))
|
||||
logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group))
|
||||
}
|
||||
|
||||
configDirs := []struct {
|
||||
@@ -301,7 +326,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := runSudoCmd(sudoPassword, "chgrp", "-R", "greeter", dir.path); err != nil {
|
||||
if err := runSudoCmd(sudoPassword, "chgrp", "-R", group, dir.path); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err))
|
||||
continue
|
||||
}
|
||||
@@ -322,7 +347,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func SyncDMSConfigs(dmsPath string, logFunc func(string), sudoPassword string) error {
|
||||
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
@@ -378,9 +403,351 @@ func SyncDMSConfigs(dmsPath string, logFunc func(string), sudoPassword string) e
|
||||
logFunc(fmt.Sprintf("✓ Synced %s", link.desc))
|
||||
}
|
||||
|
||||
if strings.ToLower(compositor) != "niri" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := syncNiriGreeterConfig(logFunc, sudoPassword); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to sync niri greeter config: %v", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type niriGreeterSync struct {
|
||||
processed map[string]bool
|
||||
nodes []*document.Node
|
||||
inputCount int
|
||||
outputCount int
|
||||
cursorCount int
|
||||
debugCount int
|
||||
cursorNode *document.Node
|
||||
}
|
||||
|
||||
func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve user config directory: %w", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "niri", "config.kdl")
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
logFunc("ℹ Niri config not found; skipping greeter niri sync")
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to stat niri config: %w", err)
|
||||
}
|
||||
|
||||
extractor := &niriGreeterSync{
|
||||
processed: make(map[string]bool),
|
||||
}
|
||||
|
||||
if err := extractor.processFile(configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(extractor.nodes) == 0 {
|
||||
logFunc("ℹ No niri input/output sections found; skipping greeter niri sync")
|
||||
return nil
|
||||
}
|
||||
|
||||
content := extractor.render()
|
||||
if strings.TrimSpace(content) == "" {
|
||||
logFunc("ℹ No niri input/output content to sync; skipping greeter niri sync")
|
||||
return nil
|
||||
}
|
||||
|
||||
greeterDir := "/etc/greetd/niri"
|
||||
greeterGroup := DetectGreeterGroup()
|
||||
if err := runSudoCmd(sudoPassword, "mkdir", "-p", greeterDir); err != nil {
|
||||
return fmt.Errorf("failed to create greetd niri directory: %w", err)
|
||||
}
|
||||
if err := runSudoCmd(sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil {
|
||||
return fmt.Errorf("failed to set greetd niri directory ownership: %w", err)
|
||||
}
|
||||
if err := runSudoCmd(sudoPassword, "chmod", "755", greeterDir); err != nil {
|
||||
return fmt.Errorf("failed to set greetd niri directory permissions: %w", err)
|
||||
}
|
||||
|
||||
dmsTemp, err := os.CreateTemp("", "dms-greeter-niri-dms-*.kdl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(dmsTemp.Name())
|
||||
|
||||
if _, err := dmsTemp.WriteString(content); err != nil {
|
||||
_ = dmsTemp.Close()
|
||||
return fmt.Errorf("failed to write temp niri config: %w", err)
|
||||
}
|
||||
if err := dmsTemp.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp niri config: %w", err)
|
||||
}
|
||||
|
||||
dmsPath := filepath.Join(greeterDir, "dms.kdl")
|
||||
if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil {
|
||||
return fmt.Errorf("failed to backup %s: %w", dmsPath, err)
|
||||
}
|
||||
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil {
|
||||
return fmt.Errorf("failed to install greetd niri dms config: %w", err)
|
||||
}
|
||||
|
||||
mainContent := fmt.Sprintf("%s\ninclude \"%s\"\n", config.NiriGreeterConfig, dmsPath)
|
||||
mainTemp, err := os.CreateTemp("", "dms-greeter-niri-main-*.kdl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(mainTemp.Name())
|
||||
|
||||
if _, err := mainTemp.WriteString(mainContent); err != nil {
|
||||
_ = mainTemp.Close()
|
||||
return fmt.Errorf("failed to write temp niri main config: %w", err)
|
||||
}
|
||||
if err := mainTemp.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp niri main config: %w", err)
|
||||
}
|
||||
|
||||
mainPath := filepath.Join(greeterDir, "config.kdl")
|
||||
if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil {
|
||||
return fmt.Errorf("failed to backup %s: %w", mainPath, err)
|
||||
}
|
||||
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil {
|
||||
return fmt.Errorf("failed to install greetd niri main config: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureGreetdNiriConfig(logFunc, sudoPassword, mainPath); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to update greetd config for niri: %v", err))
|
||||
}
|
||||
|
||||
logFunc(fmt.Sprintf("✓ Synced niri greeter config (%d input, %d output, %d cursor, %d debug) to %s", extractor.inputCount, extractor.outputCount, extractor.cursorCount, extractor.debugCount, dmsPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfigPath string) error {
|
||||
configPath := "/etc/greetd/config.toml"
|
||||
data, err := os.ReadFile(configPath)
|
||||
if os.IsNotExist(err) {
|
||||
logFunc("ℹ greetd config not found; skipping niri config wiring")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read greetd config: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
updated := false
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "command") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
command := strings.Trim(strings.TrimSpace(parts[1]), "\"")
|
||||
if !strings.Contains(command, "dms-greeter") {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(command, "--command niri") {
|
||||
continue
|
||||
}
|
||||
// Strip existing -C or --config and their arguments
|
||||
command = stripConfigFlag(command)
|
||||
|
||||
newCommand := fmt.Sprintf("%s -C %s", command, niriConfigPath)
|
||||
idx := strings.Index(line, "command")
|
||||
leading := ""
|
||||
if idx > 0 {
|
||||
leading = line[:idx]
|
||||
}
|
||||
lines[i] = fmt.Sprintf("%scommand = \"%s\"", leading, newCommand)
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
|
||||
if !updated {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := backupFileIfExists(sudoPassword, configPath, ".backup"); err != nil {
|
||||
return fmt.Errorf("failed to backup greetd config: %w", err)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp greetd config: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.WriteString(strings.Join(lines, "\n")); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
return fmt.Errorf("failed to write temp greetd config: %w", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp greetd config: %w", err)
|
||||
}
|
||||
|
||||
if err := runSudoCmd(sudoPassword, "mv", tmpFile.Name(), configPath); err != nil {
|
||||
return fmt.Errorf("failed to update greetd config: %w", err)
|
||||
}
|
||||
|
||||
logFunc(fmt.Sprintf("✓ Updated greetd config to use niri config %s", niriConfigPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupFileIfExists(sudoPassword string, path string, suffix string) error {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405"))
|
||||
return runSudoCmd(sudoPassword, "cp", "-p", path, backupPath)
|
||||
}
|
||||
|
||||
func (s *niriGreeterSync) processFile(filePath string) error {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve path %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if s.processed[absPath] {
|
||||
return nil
|
||||
}
|
||||
s.processed[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
doc, err := kdl.Parse(strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse KDL in %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(absPath)
|
||||
for _, node := range doc.Nodes {
|
||||
name := node.Name.String()
|
||||
switch name {
|
||||
case "include":
|
||||
if err := s.handleInclude(node, baseDir); err != nil {
|
||||
return err
|
||||
}
|
||||
case "input":
|
||||
s.nodes = append(s.nodes, node)
|
||||
s.inputCount++
|
||||
case "output":
|
||||
s.nodes = append(s.nodes, node)
|
||||
s.outputCount++
|
||||
case "cursor":
|
||||
if s.cursorNode == nil {
|
||||
s.cursorNode = node
|
||||
s.cursorNode.Children = dedupeCursorChildren(s.cursorNode.Children)
|
||||
s.nodes = append(s.nodes, node)
|
||||
s.cursorCount++
|
||||
} else if len(node.Children) > 0 {
|
||||
s.cursorNode.Children = mergeCursorChildren(s.cursorNode.Children, node.Children)
|
||||
}
|
||||
case "debug":
|
||||
s.nodes = append(s.nodes, node)
|
||||
s.debugCount++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeCursorChildren(existing []*document.Node, incoming []*document.Node) []*document.Node {
|
||||
if len(incoming) == 0 {
|
||||
return existing
|
||||
}
|
||||
|
||||
indexByName := make(map[string]int, len(existing))
|
||||
for i, child := range existing {
|
||||
indexByName[child.Name.String()] = i
|
||||
}
|
||||
|
||||
for _, child := range incoming {
|
||||
name := child.Name.String()
|
||||
if idx, ok := indexByName[name]; ok {
|
||||
existing[idx] = child
|
||||
continue
|
||||
}
|
||||
indexByName[name] = len(existing)
|
||||
existing = append(existing, child)
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
func dedupeCursorChildren(children []*document.Node) []*document.Node {
|
||||
if len(children) == 0 {
|
||||
return children
|
||||
}
|
||||
|
||||
var result []*document.Node
|
||||
indexByName := make(map[string]int, len(children))
|
||||
for _, child := range children {
|
||||
name := child.Name.String()
|
||||
if idx, ok := indexByName[name]; ok {
|
||||
result[idx] = child
|
||||
continue
|
||||
}
|
||||
indexByName[name] = len(result)
|
||||
result = append(result, child)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *niriGreeterSync) handleInclude(node *document.Node, baseDir string) error {
|
||||
if len(node.Arguments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
includePath := strings.Trim(node.Arguments[0].String(), "\"")
|
||||
if includePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fullPath := includePath
|
||||
if !filepath.IsAbs(includePath) {
|
||||
fullPath = filepath.Join(baseDir, includePath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to stat include %s: %w", fullPath, err)
|
||||
}
|
||||
|
||||
return s.processFile(fullPath)
|
||||
}
|
||||
|
||||
func (s *niriGreeterSync) render() string {
|
||||
if len(s.nodes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for _, node := range s.nodes {
|
||||
_, _ = node.WriteToOptions(&builder, document.NodeWriteOptions{
|
||||
LeadingTrailingSpace: true,
|
||||
NameAndType: true,
|
||||
Depth: 0,
|
||||
Indent: []byte(" "),
|
||||
IgnoreFlags: false,
|
||||
})
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func ConfigureGreetd(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||
configPath := "/etc/greetd/config.toml"
|
||||
|
||||
@@ -392,17 +759,19 @@ func ConfigureGreetd(dmsPath, compositor string, logFunc func(string), sudoPassw
|
||||
logFunc(fmt.Sprintf("✓ Backed up existing config to %s", backupPath))
|
||||
}
|
||||
|
||||
greeterUser := DetectGreeterGroup()
|
||||
|
||||
var configContent string
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
configContent = string(data)
|
||||
} else {
|
||||
configContent = `[terminal]
|
||||
configContent = fmt.Sprintf(`[terminal]
|
||||
vt = 1
|
||||
|
||||
[default_session]
|
||||
|
||||
user = "greeter"
|
||||
`
|
||||
user = "%s"
|
||||
`, greeterUser)
|
||||
}
|
||||
|
||||
lines := strings.Split(configContent, "\n")
|
||||
@@ -411,7 +780,7 @@ user = "greeter"
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") {
|
||||
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
|
||||
newLines = append(newLines, `user = "greeter"`)
|
||||
newLines = append(newLines, fmt.Sprintf(`user = "%s"`, greeterUser))
|
||||
} else {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
@@ -463,10 +832,41 @@ user = "greeter"
|
||||
return fmt.Errorf("failed to move config to /etc/greetd: %w", err)
|
||||
}
|
||||
|
||||
logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: greeter, command: %s --command %s -p %s)", wrapperCmd, compositorLower, dmsPath))
|
||||
logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s --command %s -p %s)", greeterUser, wrapperCmd, compositorLower, dmsPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripConfigFlag(command string) string {
|
||||
for _, flag := range []string{" -C ", " --config "} {
|
||||
idx := strings.Index(command, flag)
|
||||
if idx == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
before := command[:idx]
|
||||
after := command[idx+len(flag):]
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(after, `"`):
|
||||
if end := strings.Index(after[1:], `"`); end != -1 {
|
||||
after = after[end+2:]
|
||||
} else {
|
||||
after = ""
|
||||
}
|
||||
default:
|
||||
if space := strings.Index(after, " "); space != -1 {
|
||||
after = after[space:]
|
||||
} else {
|
||||
after = ""
|
||||
}
|
||||
}
|
||||
|
||||
command = strings.TrimSpace(before + after)
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func runSudoCmd(sudoPassword string, command string, args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
|
||||
95
core/internal/keybinds/providers/miracle.go
Normal file
95
core/internal/keybinds/providers/miracle.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
type MiracleProvider struct {
|
||||
configPath string
|
||||
}
|
||||
|
||||
func NewMiracleProvider(configPath string) *MiracleProvider {
|
||||
if configPath == "" {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err == nil {
|
||||
configPath = filepath.Join(configDir, "miracle-wm")
|
||||
}
|
||||
}
|
||||
return &MiracleProvider{configPath: configPath}
|
||||
}
|
||||
|
||||
func (m *MiracleProvider) Name() string {
|
||||
return "miracle"
|
||||
}
|
||||
|
||||
func (m *MiracleProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
||||
config, err := ParseMiracleConfig(m.configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse miracle-wm config: %w", err)
|
||||
}
|
||||
|
||||
bindings := MiracleConfigToBindings(config)
|
||||
categorizedBinds := make(map[string][]keybinds.Keybind)
|
||||
|
||||
for _, kb := range bindings {
|
||||
category := m.categorizeAction(kb.Action)
|
||||
bind := keybinds.Keybind{
|
||||
Key: m.formatKey(kb),
|
||||
Description: kb.Comment,
|
||||
Action: kb.Action,
|
||||
}
|
||||
categorizedBinds[category] = append(categorizedBinds[category], bind)
|
||||
}
|
||||
|
||||
return &keybinds.CheatSheet{
|
||||
Title: "Miracle WM Keybinds",
|
||||
Provider: m.Name(),
|
||||
Binds: categorizedBinds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MiracleProvider) GetOverridePath() string {
|
||||
expanded, err := utils.ExpandPath(m.configPath)
|
||||
if err != nil {
|
||||
return filepath.Join(m.configPath, "config.yaml")
|
||||
}
|
||||
return filepath.Join(expanded, "config.yaml")
|
||||
}
|
||||
|
||||
func (m *MiracleProvider) formatKey(kb MiracleKeyBinding) string {
|
||||
parts := make([]string, 0, len(kb.Mods)+1)
|
||||
parts = append(parts, kb.Mods...)
|
||||
parts = append(parts, kb.Key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (m *MiracleProvider) categorizeAction(action string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(action, "select_workspace_") || strings.HasPrefix(action, "move_to_workspace_"):
|
||||
return "Workspace"
|
||||
case strings.Contains(action, "select_") || strings.Contains(action, "move_"):
|
||||
return "Window"
|
||||
case action == "toggle_resize" || strings.HasPrefix(action, "resize_"):
|
||||
return "Window"
|
||||
case action == "fullscreen" || action == "toggle_floating" || action == "quit_active_window" || action == "toggle_pinned_to_workspace":
|
||||
return "Window"
|
||||
case action == "toggle_tabbing" || action == "toggle_stacking" || action == "request_vertical" || action == "request_horizontal":
|
||||
return "Layout"
|
||||
case action == "quit_compositor":
|
||||
return "System"
|
||||
case action == "terminal":
|
||||
return "Execute"
|
||||
case strings.HasPrefix(action, "magnifier_"):
|
||||
return "Accessibility"
|
||||
case strings.HasPrefix(action, "dms ") || strings.Contains(action, "dms ipc"):
|
||||
return "Execute"
|
||||
default:
|
||||
return "Execute"
|
||||
}
|
||||
}
|
||||
320
core/internal/keybinds/providers/miracle_parser.go
Normal file
320
core/internal/keybinds/providers/miracle_parser.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type MiracleConfig struct {
|
||||
Terminal string `yaml:"terminal"`
|
||||
ActionKey string `yaml:"action_key"`
|
||||
DefaultActionOverrides []MiracleActionOverride `yaml:"default_action_overrides"`
|
||||
CustomActions []MiracleCustomAction `yaml:"custom_actions"`
|
||||
}
|
||||
|
||||
type MiracleActionOverride struct {
|
||||
Name string `yaml:"name"`
|
||||
Action string `yaml:"action"`
|
||||
Modifiers []string `yaml:"modifiers"`
|
||||
Key string `yaml:"key"`
|
||||
}
|
||||
|
||||
type MiracleCustomAction struct {
|
||||
Command string `yaml:"command"`
|
||||
Action string `yaml:"action"`
|
||||
Modifiers []string `yaml:"modifiers"`
|
||||
Key string `yaml:"key"`
|
||||
}
|
||||
|
||||
type MiracleKeyBinding struct {
|
||||
Mods []string
|
||||
Key string
|
||||
Action string
|
||||
Comment string
|
||||
}
|
||||
|
||||
var miracleDefaultBinds = []MiracleKeyBinding{
|
||||
{Mods: []string{"Super"}, Key: "Return", Action: "terminal", Comment: "Open terminal"},
|
||||
{Mods: []string{"Super"}, Key: "v", Action: "request_vertical", Comment: "Layout windows vertically"},
|
||||
{Mods: []string{"Super"}, Key: "h", Action: "request_horizontal", Comment: "Layout windows horizontally"},
|
||||
{Mods: []string{"Super"}, Key: "Up", Action: "select_up", Comment: "Select window above"},
|
||||
{Mods: []string{"Super"}, Key: "Down", Action: "select_down", Comment: "Select window below"},
|
||||
{Mods: []string{"Super"}, Key: "Left", Action: "select_left", Comment: "Select window left"},
|
||||
{Mods: []string{"Super"}, Key: "Right", Action: "select_right", Comment: "Select window right"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "Up", Action: "move_up", Comment: "Move window up"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "Down", Action: "move_down", Comment: "Move window down"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "Left", Action: "move_left", Comment: "Move window left"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "Right", Action: "move_right", Comment: "Move window right"},
|
||||
{Mods: []string{"Super"}, Key: "r", Action: "toggle_resize", Comment: "Toggle resize mode"},
|
||||
{Mods: []string{"Super"}, Key: "f", Action: "fullscreen", Comment: "Toggle fullscreen"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "q", Action: "quit_active_window", Comment: "Close window"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "e", Action: "quit_compositor", Comment: "Exit compositor"},
|
||||
{Mods: []string{"Super"}, Key: "Space", Action: "toggle_floating", Comment: "Toggle floating"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "p", Action: "toggle_pinned_to_workspace", Comment: "Toggle pinned to workspace"},
|
||||
{Mods: []string{"Super"}, Key: "w", Action: "toggle_tabbing", Comment: "Toggle tabbing layout"},
|
||||
{Mods: []string{"Super"}, Key: "s", Action: "toggle_stacking", Comment: "Toggle stacking layout"},
|
||||
{Mods: []string{"Super"}, Key: "1", Action: "select_workspace_0", Comment: "Workspace 1"},
|
||||
{Mods: []string{"Super"}, Key: "2", Action: "select_workspace_1", Comment: "Workspace 2"},
|
||||
{Mods: []string{"Super"}, Key: "3", Action: "select_workspace_2", Comment: "Workspace 3"},
|
||||
{Mods: []string{"Super"}, Key: "4", Action: "select_workspace_3", Comment: "Workspace 4"},
|
||||
{Mods: []string{"Super"}, Key: "5", Action: "select_workspace_4", Comment: "Workspace 5"},
|
||||
{Mods: []string{"Super"}, Key: "6", Action: "select_workspace_5", Comment: "Workspace 6"},
|
||||
{Mods: []string{"Super"}, Key: "7", Action: "select_workspace_6", Comment: "Workspace 7"},
|
||||
{Mods: []string{"Super"}, Key: "8", Action: "select_workspace_7", Comment: "Workspace 8"},
|
||||
{Mods: []string{"Super"}, Key: "9", Action: "select_workspace_8", Comment: "Workspace 9"},
|
||||
{Mods: []string{"Super"}, Key: "0", Action: "select_workspace_9", Comment: "Workspace 10"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "1", Action: "move_to_workspace_0", Comment: "Move to workspace 1"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "2", Action: "move_to_workspace_1", Comment: "Move to workspace 2"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "3", Action: "move_to_workspace_2", Comment: "Move to workspace 3"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "4", Action: "move_to_workspace_3", Comment: "Move to workspace 4"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "5", Action: "move_to_workspace_4", Comment: "Move to workspace 5"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "6", Action: "move_to_workspace_5", Comment: "Move to workspace 6"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "7", Action: "move_to_workspace_6", Comment: "Move to workspace 7"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "8", Action: "move_to_workspace_7", Comment: "Move to workspace 8"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "9", Action: "move_to_workspace_8", Comment: "Move to workspace 9"},
|
||||
{Mods: []string{"Super", "Shift"}, Key: "0", Action: "move_to_workspace_9", Comment: "Move to workspace 10"},
|
||||
}
|
||||
|
||||
func ParseMiracleConfig(configPath string) (*MiracleConfig, error) {
|
||||
expanded, err := utils.ExpandPath(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := os.Stat(expanded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var configFile string
|
||||
if info.IsDir() {
|
||||
configFile = filepath.Join(expanded, "config.yaml")
|
||||
} else {
|
||||
configFile = expanded
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config MiracleConfig
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config.ActionKey == "" {
|
||||
config.ActionKey = "meta"
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func resolveMiracleModifier(mod, actionKey string) string {
|
||||
switch mod {
|
||||
case "primary":
|
||||
return resolveActionKey(actionKey)
|
||||
case "alt", "alt_left", "alt_right":
|
||||
return "Alt"
|
||||
case "shift", "shift_left", "shift_right":
|
||||
return "Shift"
|
||||
case "ctrl", "ctrl_left", "ctrl_right":
|
||||
return "Ctrl"
|
||||
case "meta", "meta_left", "meta_right":
|
||||
return "Super"
|
||||
default:
|
||||
return mod
|
||||
}
|
||||
}
|
||||
|
||||
func resolveActionKey(actionKey string) string {
|
||||
switch actionKey {
|
||||
case "meta":
|
||||
return "Super"
|
||||
case "alt":
|
||||
return "Alt"
|
||||
case "ctrl":
|
||||
return "Ctrl"
|
||||
default:
|
||||
return "Super"
|
||||
}
|
||||
}
|
||||
|
||||
func miracleKeyCodeToName(keyCode string) string {
|
||||
name := strings.TrimPrefix(keyCode, "KEY_")
|
||||
name = strings.ToLower(name)
|
||||
|
||||
switch name {
|
||||
case "enter":
|
||||
return "Return"
|
||||
case "space":
|
||||
return "Space"
|
||||
case "up":
|
||||
return "Up"
|
||||
case "down":
|
||||
return "Down"
|
||||
case "left":
|
||||
return "Left"
|
||||
case "right":
|
||||
return "Right"
|
||||
case "tab":
|
||||
return "Tab"
|
||||
case "escape", "esc":
|
||||
return "Escape"
|
||||
case "delete":
|
||||
return "Delete"
|
||||
case "backspace":
|
||||
return "BackSpace"
|
||||
case "home":
|
||||
return "Home"
|
||||
case "end":
|
||||
return "End"
|
||||
case "pageup":
|
||||
return "Page_Up"
|
||||
case "pagedown":
|
||||
return "Page_Down"
|
||||
case "print":
|
||||
return "Print"
|
||||
case "pause":
|
||||
return "Pause"
|
||||
case "volumeup":
|
||||
return "XF86AudioRaiseVolume"
|
||||
case "volumedown":
|
||||
return "XF86AudioLowerVolume"
|
||||
case "mute":
|
||||
return "XF86AudioMute"
|
||||
case "micmute":
|
||||
return "XF86AudioMicMute"
|
||||
case "brightnessup":
|
||||
return "XF86MonBrightnessUp"
|
||||
case "brightnessdown":
|
||||
return "XF86MonBrightnessDown"
|
||||
case "kbdillumup":
|
||||
return "XF86KbdBrightnessUp"
|
||||
case "kbdillumdown":
|
||||
return "XF86KbdBrightnessDown"
|
||||
case "comma":
|
||||
return "comma"
|
||||
case "minus":
|
||||
return "minus"
|
||||
case "equal":
|
||||
return "equal"
|
||||
}
|
||||
|
||||
if len(name) == 1 {
|
||||
return name
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func MiracleConfigToBindings(config *MiracleConfig) []MiracleKeyBinding {
|
||||
overridden := make(map[string]bool)
|
||||
var bindings []MiracleKeyBinding
|
||||
|
||||
for _, override := range config.DefaultActionOverrides {
|
||||
mods := make([]string, 0, len(override.Modifiers))
|
||||
for _, mod := range override.Modifiers {
|
||||
mods = append(mods, resolveMiracleModifier(mod, config.ActionKey))
|
||||
}
|
||||
|
||||
bindings = append(bindings, MiracleKeyBinding{
|
||||
Mods: mods,
|
||||
Key: miracleKeyCodeToName(override.Key),
|
||||
Action: override.Name,
|
||||
Comment: miracleActionDescription(override.Name),
|
||||
})
|
||||
overridden[override.Name] = true
|
||||
}
|
||||
|
||||
for _, def := range miracleDefaultBinds {
|
||||
if overridden[def.Action] {
|
||||
continue
|
||||
}
|
||||
bindings = append(bindings, def)
|
||||
}
|
||||
|
||||
for _, custom := range config.CustomActions {
|
||||
mods := make([]string, 0, len(custom.Modifiers))
|
||||
for _, mod := range custom.Modifiers {
|
||||
mods = append(mods, resolveMiracleModifier(mod, config.ActionKey))
|
||||
}
|
||||
|
||||
bindings = append(bindings, MiracleKeyBinding{
|
||||
Mods: mods,
|
||||
Key: miracleKeyCodeToName(custom.Key),
|
||||
Action: custom.Command,
|
||||
Comment: custom.Command,
|
||||
})
|
||||
}
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
||||
func miracleActionDescription(action string) string {
|
||||
switch action {
|
||||
case "terminal":
|
||||
return "Open terminal"
|
||||
case "request_vertical":
|
||||
return "Layout windows vertically"
|
||||
case "request_horizontal":
|
||||
return "Layout windows horizontally"
|
||||
case "select_up":
|
||||
return "Select window above"
|
||||
case "select_down":
|
||||
return "Select window below"
|
||||
case "select_left":
|
||||
return "Select window left"
|
||||
case "select_right":
|
||||
return "Select window right"
|
||||
case "move_up":
|
||||
return "Move window up"
|
||||
case "move_down":
|
||||
return "Move window down"
|
||||
case "move_left":
|
||||
return "Move window left"
|
||||
case "move_right":
|
||||
return "Move window right"
|
||||
case "toggle_resize":
|
||||
return "Toggle resize mode"
|
||||
case "fullscreen":
|
||||
return "Toggle fullscreen"
|
||||
case "quit_active_window":
|
||||
return "Close window"
|
||||
case "quit_compositor":
|
||||
return "Exit compositor"
|
||||
case "toggle_floating":
|
||||
return "Toggle floating"
|
||||
case "toggle_pinned_to_workspace":
|
||||
return "Toggle pinned to workspace"
|
||||
case "toggle_tabbing":
|
||||
return "Toggle tabbing layout"
|
||||
case "toggle_stacking":
|
||||
return "Toggle stacking layout"
|
||||
case "magnifier_on":
|
||||
return "Enable magnifier"
|
||||
case "magnifier_off":
|
||||
return "Disable magnifier"
|
||||
case "magnifier_increase_size":
|
||||
return "Increase magnifier area"
|
||||
case "magnifier_decrease_size":
|
||||
return "Decrease magnifier area"
|
||||
case "magnifier_increase_scale":
|
||||
return "Increase magnifier scale"
|
||||
case "magnifier_decrease_scale":
|
||||
return "Decrease magnifier scale"
|
||||
}
|
||||
|
||||
if num, ok := strings.CutPrefix(action, "select_workspace_"); ok {
|
||||
return "Workspace " + num
|
||||
}
|
||||
if num, ok := strings.CutPrefix(action, "move_to_workspace_"); ok {
|
||||
return "Move to workspace " + num
|
||||
}
|
||||
|
||||
return action
|
||||
}
|
||||
@@ -118,6 +118,9 @@ func (n *NiriProvider) categorizeByAction(action string) string {
|
||||
return "Overview"
|
||||
case action == "quit" ||
|
||||
action == "power-off-monitors" ||
|
||||
action == "power-on-monitors" ||
|
||||
action == "suspend" ||
|
||||
action == "do-screen-transition" ||
|
||||
action == "toggle-keyboard-shortcuts-inhibit" ||
|
||||
strings.Contains(action, "dpms"):
|
||||
return "System"
|
||||
@@ -151,13 +154,16 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
|
||||
}
|
||||
|
||||
bind := keybinds.Keybind{
|
||||
Key: keyStr,
|
||||
Description: kb.Description,
|
||||
Action: rawAction,
|
||||
Subcategory: subcategory,
|
||||
Source: source,
|
||||
HideOnOverlay: kb.HideOnOverlay,
|
||||
CooldownMs: kb.CooldownMs,
|
||||
Key: keyStr,
|
||||
Description: kb.Description,
|
||||
Action: rawAction,
|
||||
Subcategory: subcategory,
|
||||
Source: source,
|
||||
HideOnOverlay: kb.HideOnOverlay,
|
||||
CooldownMs: kb.CooldownMs,
|
||||
AllowWhenLocked: kb.AllowWhenLocked,
|
||||
AllowInhibiting: kb.AllowInhibiting,
|
||||
Repeat: kb.Repeat,
|
||||
}
|
||||
|
||||
if source == "dms" && conflicts != nil {
|
||||
@@ -341,14 +347,10 @@ func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
|
||||
}
|
||||
|
||||
if actionNode.Properties != nil {
|
||||
if val, ok := actionNode.Properties.Get("focus"); ok {
|
||||
parts = append(parts, "focus="+val.String())
|
||||
}
|
||||
if val, ok := actionNode.Properties.Get("show-pointer"); ok {
|
||||
parts = append(parts, "show-pointer="+val.String())
|
||||
}
|
||||
if val, ok := actionNode.Properties.Get("write-to-disk"); ok {
|
||||
parts = append(parts, "write-to-disk="+val.String())
|
||||
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} {
|
||||
if val, ok := actionNode.Properties.Get(propName); ok {
|
||||
parts = append(parts, propName+"="+val.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +374,9 @@ func (n *NiriProvider) extractOptions(node *document.Node) map[string]any {
|
||||
if val, ok := node.Properties.Get("allow-when-locked"); ok {
|
||||
opts["allow-when-locked"] = val.String() == "true"
|
||||
}
|
||||
if val, ok := node.Properties.Get("allow-inhibiting"); ok {
|
||||
opts["allow-inhibiting"] = val.String() == "true"
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
@@ -405,6 +410,9 @@ func (n *NiriProvider) buildBindNode(bind *overrideBind) *document.Node {
|
||||
if v, ok := bind.Options["allow-when-locked"]; ok && v == true {
|
||||
node.AddProperty("allow-when-locked", true, "")
|
||||
}
|
||||
if v, ok := bind.Options["allow-inhibiting"]; ok && v == false {
|
||||
node.AddProperty("allow-inhibiting", false, "")
|
||||
}
|
||||
}
|
||||
|
||||
if bind.Description != "" {
|
||||
|
||||
@@ -12,14 +12,17 @@ import (
|
||||
)
|
||||
|
||||
type NiriKeyBinding struct {
|
||||
Mods []string
|
||||
Key string
|
||||
Action string
|
||||
Args []string
|
||||
Description string
|
||||
HideOnOverlay bool
|
||||
CooldownMs int
|
||||
Source string
|
||||
Mods []string
|
||||
Key string
|
||||
Action string
|
||||
Args []string
|
||||
Description string
|
||||
HideOnOverlay bool
|
||||
CooldownMs int
|
||||
AllowWhenLocked bool
|
||||
AllowInhibiting *bool
|
||||
Repeat *bool
|
||||
Source string
|
||||
}
|
||||
|
||||
type NiriSection struct {
|
||||
@@ -269,8 +272,10 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBin
|
||||
args = append(args, arg.ValueString())
|
||||
}
|
||||
if actionNode.Properties != nil {
|
||||
if val, ok := actionNode.Properties.Get("focus"); ok {
|
||||
args = append(args, "focus="+val.String())
|
||||
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} {
|
||||
if val, ok := actionNode.Properties.Get(propName); ok {
|
||||
args = append(args, propName+"="+val.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,6 +283,9 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBin
|
||||
var description string
|
||||
var hideOnOverlay bool
|
||||
var cooldownMs int
|
||||
var allowWhenLocked bool
|
||||
var allowInhibiting *bool
|
||||
var repeat *bool
|
||||
if node.Properties != nil {
|
||||
if val, ok := node.Properties.Get("hotkey-overlay-title"); ok {
|
||||
switch val.ValueString() {
|
||||
@@ -290,17 +298,31 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBin
|
||||
if val, ok := node.Properties.Get("cooldown-ms"); ok {
|
||||
cooldownMs, _ = strconv.Atoi(val.String())
|
||||
}
|
||||
if val, ok := node.Properties.Get("allow-when-locked"); ok {
|
||||
allowWhenLocked = val.String() == "true"
|
||||
}
|
||||
if val, ok := node.Properties.Get("allow-inhibiting"); ok {
|
||||
v := val.String() == "true"
|
||||
allowInhibiting = &v
|
||||
}
|
||||
if val, ok := node.Properties.Get("repeat"); ok {
|
||||
v := val.String() == "true"
|
||||
repeat = &v
|
||||
}
|
||||
}
|
||||
|
||||
return &NiriKeyBinding{
|
||||
Mods: mods,
|
||||
Key: key,
|
||||
Action: action,
|
||||
Args: args,
|
||||
Description: description,
|
||||
HideOnOverlay: hideOnOverlay,
|
||||
CooldownMs: cooldownMs,
|
||||
Source: p.currentSource,
|
||||
Mods: mods,
|
||||
Key: key,
|
||||
Action: action,
|
||||
Args: args,
|
||||
Description: description,
|
||||
HideOnOverlay: hideOnOverlay,
|
||||
CooldownMs: cooldownMs,
|
||||
AllowWhenLocked: allowWhenLocked,
|
||||
AllowInhibiting: allowInhibiting,
|
||||
Repeat: repeat,
|
||||
Source: p.currentSource,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package providers
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
@@ -18,14 +19,21 @@ func NewSwayProvider(configPath string) *SwayProvider {
|
||||
_, scrollEnvSet := os.LookupEnv("SCROLLSOCK")
|
||||
|
||||
if configPath == "" {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
configDir = ""
|
||||
}
|
||||
if scrollEnvSet {
|
||||
configPath = "$HOME/.config/scroll"
|
||||
if configDir != "" {
|
||||
configPath = filepath.Join(configDir, "scroll")
|
||||
}
|
||||
isScroll = true
|
||||
} else {
|
||||
configPath = "$HOME/.config/sway"
|
||||
if configDir != "" {
|
||||
configPath = filepath.Join(configDir, "sway")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Determine isScroll based on the provided config path
|
||||
isScroll = strings.Contains(configPath, "scroll")
|
||||
}
|
||||
|
||||
@@ -36,16 +44,16 @@ func NewSwayProvider(configPath string) *SwayProvider {
|
||||
}
|
||||
|
||||
func (s *SwayProvider) Name() string {
|
||||
if s != nil && s.isScroll {
|
||||
return "scroll"
|
||||
}
|
||||
if s == nil {
|
||||
_, ok := os.LookupEnv("SCROLLSOCK")
|
||||
if ok {
|
||||
if os.Getenv("SCROLLSOCK") != "" {
|
||||
return "scroll"
|
||||
}
|
||||
return "sway"
|
||||
}
|
||||
|
||||
if s.isScroll {
|
||||
return "scroll"
|
||||
}
|
||||
return "sway"
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,13 @@ func TestSwayProviderName(t *testing.T) {
|
||||
|
||||
func TestSwayProviderDefaultPath(t *testing.T) {
|
||||
provider := NewSwayProvider("")
|
||||
if provider.configPath != "$HOME/.config/sway" {
|
||||
t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/sway")
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Skip("UserConfigDir not available")
|
||||
}
|
||||
expected := filepath.Join(configDir, "sway")
|
||||
if provider.configPath != expected {
|
||||
t.Errorf("configPath = %q, want %q", provider.configPath, expected)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package keybinds
|
||||
|
||||
type Keybind struct {
|
||||
Key string `json:"key"`
|
||||
Description string `json:"desc"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Subcategory string `json:"subcat,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
HideOnOverlay bool `json:"hideOnOverlay,omitempty"`
|
||||
CooldownMs int `json:"cooldownMs,omitempty"`
|
||||
Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press
|
||||
Conflict *Keybind `json:"conflict,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Description string `json:"desc"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Subcategory string `json:"subcat,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
HideOnOverlay bool `json:"hideOnOverlay,omitempty"`
|
||||
CooldownMs int `json:"cooldownMs,omitempty"`
|
||||
Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press
|
||||
AllowWhenLocked bool `json:"allowWhenLocked,omitempty"`
|
||||
AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled
|
||||
Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled
|
||||
Conflict *Keybind `json:"conflict,omitempty"`
|
||||
}
|
||||
|
||||
type DMSBindsStatus struct {
|
||||
|
||||
@@ -3,6 +3,7 @@ package matugen
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -10,10 +11,12 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
)
|
||||
|
||||
type ColorMode string
|
||||
@@ -77,6 +80,7 @@ func (c *ColorMode) GTKTheme() string {
|
||||
var (
|
||||
matugenVersionOnce sync.Once
|
||||
matugenSupportsCOE bool
|
||||
matugenIsV4 bool
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
@@ -250,8 +254,22 @@ func buildOnce(opts *Options) error {
|
||||
}
|
||||
}
|
||||
|
||||
refreshGTK(opts.ConfigDir, opts.Mode)
|
||||
signalTerminals()
|
||||
if isDMSGTKActive(opts.ConfigDir) {
|
||||
switch opts.Mode {
|
||||
case ColorModeLight:
|
||||
syncAccentColor(primaryLight)
|
||||
default:
|
||||
syncAccentColor(primaryDark)
|
||||
}
|
||||
refreshGTK(opts.Mode)
|
||||
refreshGTK4()
|
||||
}
|
||||
|
||||
if !opts.ShouldSkipTemplate("qt6ct") && appExists(opts.AppChecker, []string{"qt6ct"}, nil) {
|
||||
refreshQt6ct()
|
||||
}
|
||||
|
||||
signalTerminals(opts)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -520,9 +538,13 @@ func checkMatugenVersion() {
|
||||
}
|
||||
|
||||
matugenSupportsCOE = major > 3 || (major == 3 && minor >= 1)
|
||||
matugenIsV4 = major >= 4
|
||||
if matugenSupportsCOE {
|
||||
log.Infof("Matugen %s supports --continue-on-error", versionStr)
|
||||
}
|
||||
if matugenIsV4 {
|
||||
log.Infof("Matugen %s: using v4 flags", versionStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -532,6 +554,9 @@ func runMatugen(args []string) error {
|
||||
if matugenSupportsCOE {
|
||||
args = append([]string{"--continue-on-error"}, args...)
|
||||
}
|
||||
if matugenIsV4 {
|
||||
args = append(args, "--source-color-index", "0")
|
||||
}
|
||||
|
||||
cmd := exec.Command("matugen", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -540,6 +565,8 @@ func runMatugen(args []string) error {
|
||||
}
|
||||
|
||||
func runMatugenDryRun(opts *Options) (string, error) {
|
||||
checkMatugenVersion()
|
||||
|
||||
var args []string
|
||||
switch opts.Kind {
|
||||
case "hex":
|
||||
@@ -548,6 +575,9 @@ func runMatugenDryRun(opts *Options) (string, error) {
|
||||
args = []string{opts.Kind, opts.Value}
|
||||
}
|
||||
args = append(args, "-m", "dark", "-t", opts.MatugenType, "--json", "hex", "--dry-run")
|
||||
if matugenIsV4 {
|
||||
args = append(args, "--source-color-index", "0", "--old-json-output")
|
||||
}
|
||||
|
||||
cmd := exec.Command("matugen", args...)
|
||||
output, err := cmd.Output()
|
||||
@@ -617,40 +647,73 @@ func generateDank16Variants(primaryDark, primaryLight, surface string, mode Colo
|
||||
return dank16.GenerateVariantJSON(variantColors)
|
||||
}
|
||||
|
||||
func refreshGTK(configDir string, mode ColorMode) {
|
||||
func isDMSGTKActive(configDir string) bool {
|
||||
gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
|
||||
|
||||
info, err := os.Lstat(gtkCSS)
|
||||
if err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
shouldRun := false
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
target, err := os.Readlink(gtkCSS)
|
||||
if err == nil && strings.Contains(target, "dank-colors.css") {
|
||||
shouldRun = true
|
||||
}
|
||||
} else {
|
||||
data, err := os.ReadFile(gtkCSS)
|
||||
if err == nil && strings.Contains(string(data), "dank-colors.css") {
|
||||
shouldRun = true
|
||||
}
|
||||
return err == nil && strings.Contains(target, "dank-colors.css")
|
||||
}
|
||||
|
||||
if !shouldRun {
|
||||
return
|
||||
}
|
||||
|
||||
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "").Run()
|
||||
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()).Run()
|
||||
data, err := os.ReadFile(gtkCSS)
|
||||
return err == nil && strings.Contains(string(data), "dank-colors.css")
|
||||
}
|
||||
|
||||
func signalTerminals() {
|
||||
signalByName("kitty", syscall.SIGUSR1)
|
||||
signalByName("ghostty", syscall.SIGUSR2)
|
||||
signalByName(".kitty-wrapped", syscall.SIGUSR1)
|
||||
signalByName(".ghostty-wrappe", syscall.SIGUSR2)
|
||||
func refreshGTK(mode ColorMode) {
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", ""); err != nil {
|
||||
log.Warnf("Failed to reset gtk-theme: %v", err)
|
||||
}
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()); err != nil {
|
||||
log.Warnf("Failed to set gtk-theme: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshGTK4() {
|
||||
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
current := strings.Trim(output, "'")
|
||||
|
||||
var toggle string
|
||||
if current == "prefer-dark" {
|
||||
toggle = "default"
|
||||
} else {
|
||||
toggle = "prefer-dark"
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshQt6ct() {
|
||||
confPath := filepath.Join(utils.XDGConfigHome(), "qt6ct", "qt6ct.conf")
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(confPath, now, now); err != nil {
|
||||
log.Warnf("Failed to touch qt6ct.conf: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func signalTerminals(opts *Options) {
|
||||
if !opts.ShouldSkipTemplate("kitty") && appExists(opts.AppChecker, []string{"kitty"}, nil) {
|
||||
signalByName("kitty", syscall.SIGUSR1)
|
||||
signalByName(".kitty-wrapped", syscall.SIGUSR1)
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("ghostty") && appExists(opts.AppChecker, []string{"ghostty"}, nil) {
|
||||
signalByName("ghostty", syscall.SIGUSR2)
|
||||
signalByName(".ghostty-wrappe", syscall.SIGUSR2)
|
||||
}
|
||||
}
|
||||
|
||||
func signalByName(name string, sig syscall.Signal) {
|
||||
@@ -679,8 +742,59 @@ func syncColorScheme(mode ColorMode) {
|
||||
scheme = "default"
|
||||
}
|
||||
|
||||
if err := exec.Command("gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme).Run(); err != nil {
|
||||
exec.Command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", "'"+scheme+"'").Run()
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil {
|
||||
log.Warnf("Failed to sync color-scheme: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var adwaitaAccents = []struct {
|
||||
name string
|
||||
colors []colorful.Color
|
||||
}{
|
||||
{"blue", hexColors("#3f8ae5", "#438de6", "#a4caee")},
|
||||
{"green", hexColors("#26a269", "#39ac76", "#81d5ad")},
|
||||
{"orange", hexColors("#f17738", "#ff7800", "#ffc994")},
|
||||
{"pink", hexColors("#e4358a", "#e64392", "#f9b3d5")},
|
||||
{"purple", hexColors("#954ab5", "#9c46b9", "#d099d6")},
|
||||
{"red", hexColors("#e84053", "#e01b24", "#f2a1a5")},
|
||||
{"slate", hexColors("#557b9f", "#6a8daf", "#b4c6d6")},
|
||||
{"teal", hexColors("#129eb0", "#2190a4", "#7bdff4")},
|
||||
{"yellow", hexColors("#cbac10", "#d4b411", "#f5c211")},
|
||||
}
|
||||
|
||||
func hexColors(hexes ...string) []colorful.Color {
|
||||
out := make([]colorful.Color, len(hexes))
|
||||
for i, h := range hexes {
|
||||
out[i], _ = colorful.Hex(h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func closestAdwaitaAccent(primaryHex string) string {
|
||||
c, err := colorful.Hex(primaryHex)
|
||||
if err != nil {
|
||||
return "blue"
|
||||
}
|
||||
|
||||
best := "blue"
|
||||
bestDist := math.MaxFloat64
|
||||
for _, a := range adwaitaAccents {
|
||||
for _, ref := range a.colors {
|
||||
d := c.DistanceCIEDE2000(ref)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
best = a.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func syncAccentColor(primaryHex string) {
|
||||
accent := closestAdwaitaAccent(primaryHex)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ const (
|
||||
notifyDest = "org.freedesktop.Notifications"
|
||||
notifyPath = "/org/freedesktop/Notifications"
|
||||
notifyInterface = "org.freedesktop.Notifications"
|
||||
|
||||
maxSummaryLen = 29
|
||||
maxBodyLen = 80
|
||||
)
|
||||
|
||||
type Notification struct {
|
||||
@@ -39,6 +42,13 @@ func Send(n Notification) error {
|
||||
n.Timeout = 5000
|
||||
}
|
||||
|
||||
if len(n.Summary) > maxSummaryLen {
|
||||
n.Summary = n.Summary[:maxSummaryLen-3] + "..."
|
||||
}
|
||||
if len(n.Body) > maxBodyLen {
|
||||
n.Body = n.Body[:maxBodyLen-3] + "..."
|
||||
}
|
||||
|
||||
var actions []string
|
||||
if n.FilePath != "" {
|
||||
actions = []string{
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
CompositorNiri
|
||||
CompositorDWL
|
||||
CompositorScroll
|
||||
CompositorMiracle
|
||||
)
|
||||
|
||||
var detectedCompositor Compositor = -1
|
||||
@@ -34,6 +35,7 @@ func DetectCompositor() Compositor {
|
||||
niriSocket := os.Getenv("NIRI_SOCKET")
|
||||
swaySocket := os.Getenv("SWAYSOCK")
|
||||
scrollSocket := os.Getenv("SCROLLSOCK")
|
||||
miracleSocket := os.Getenv("MIRACLESOCK")
|
||||
|
||||
switch {
|
||||
case niriSocket != "":
|
||||
@@ -46,7 +48,11 @@ func DetectCompositor() Compositor {
|
||||
detectedCompositor = CompositorScroll
|
||||
return detectedCompositor
|
||||
}
|
||||
|
||||
case miracleSocket != "":
|
||||
if _, err := os.Stat(miracleSocket); err == nil {
|
||||
detectedCompositor = CompositorMiracle
|
||||
return detectedCompositor
|
||||
}
|
||||
case swaySocket != "":
|
||||
if _, err := os.Stat(swaySocket); err == nil {
|
||||
detectedCompositor = CompositorSway
|
||||
@@ -260,6 +266,25 @@ func getScrollFocusedMonitor() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func getMiracleFocusedMonitor() string {
|
||||
output, err := exec.Command("miraclemsg", "-t", "get_workspaces").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var workspaces []swayWorkspace
|
||||
if err := json.Unmarshal(output, &workspaces); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, ws := range workspaces {
|
||||
if ws.Focused {
|
||||
return ws.Output
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type niriWorkspace struct {
|
||||
Output string `json:"output"`
|
||||
IsFocused bool `json:"is_focused"`
|
||||
@@ -407,6 +432,8 @@ func GetFocusedMonitor() string {
|
||||
return getSwayFocusedMonitor()
|
||||
case CompositorScroll:
|
||||
return getScrollFocusedMonitor()
|
||||
case CompositorMiracle:
|
||||
return getMiracleFocusedMonitor()
|
||||
case CompositorNiri:
|
||||
return getNiriFocusedMonitor()
|
||||
case CompositorDWL:
|
||||
|
||||
@@ -3,11 +3,11 @@ package screenshot
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
type ThemeColors struct {
|
||||
@@ -83,12 +83,11 @@ func getColorsFilePath() string {
|
||||
}
|
||||
|
||||
func isLightMode() bool {
|
||||
out, err := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme").Output()
|
||||
scheme, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
scheme := strings.TrimSpace(string(out))
|
||||
switch scheme {
|
||||
case "'prefer-light'", "'default'":
|
||||
return true
|
||||
|
||||
@@ -96,6 +96,12 @@ func (m *Manager) Rescan() {
|
||||
}
|
||||
}
|
||||
|
||||
if m.sysfsReady && m.sysfsBackend != nil {
|
||||
if err := m.sysfsBackend.Rescan(); err != nil {
|
||||
log.Debugf("Sysfs rescan failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
m.updateState()
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,10 @@ func shouldSuppressDevice(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *SysfsBackend) Rescan() error {
|
||||
return b.scanDevices()
|
||||
}
|
||||
|
||||
func (b *SysfsBackend) GetDevices() ([]Device, error) {
|
||||
devices := make([]Device, 0)
|
||||
|
||||
|
||||
@@ -146,9 +146,16 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.TouchEntry(uint64(id)); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
if entry.Pinned {
|
||||
if err := m.CreateHistoryEntryFromPinned(entry); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := m.TouchEntry(uint64(id)); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"})
|
||||
|
||||
@@ -388,6 +388,10 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
|
||||
if extractHash(v) != hash {
|
||||
continue
|
||||
}
|
||||
entry, err := decodeEntry(v)
|
||||
if err == nil && entry.Pinned {
|
||||
continue
|
||||
}
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -842,6 +846,62 @@ func (m *Manager) TouchEntry(id uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
|
||||
if m.db == nil {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
// Create a new unpinned entry with the same data
|
||||
newEntry := Entry{
|
||||
Data: pinnedEntry.Data,
|
||||
MimeType: pinnedEntry.MimeType,
|
||||
Size: pinnedEntry.Size,
|
||||
Timestamp: time.Now(),
|
||||
IsImage: pinnedEntry.IsImage,
|
||||
Preview: pinnedEntry.Preview,
|
||||
Pinned: false,
|
||||
}
|
||||
|
||||
if err := m.storeEntryWithoutDedup(newEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.updateState()
|
||||
m.notifySubscribers()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) storeEntryWithoutDedup(entry Entry) error {
|
||||
if m.db == nil {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
entry.Hash = computeHash(entry.Data)
|
||||
|
||||
return m.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("clipboard"))
|
||||
|
||||
id, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry.ID = id
|
||||
|
||||
encoded, err := encodeEntry(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := b.Put(itob(id), encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.trimLengthInTx(b)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) ClearHistory() {
|
||||
if m.db == nil {
|
||||
return
|
||||
@@ -1419,6 +1479,37 @@ func (m *Manager) PinEntry(id uint64) error {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
entryToPin, err := m.GetEntry(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var hashExists bool
|
||||
if err := m.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("clipboard"))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
c := b.Cursor()
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
entry, err := decodeEntry(v)
|
||||
if err != nil || !entry.Pinned {
|
||||
continue
|
||||
}
|
||||
if entry.Hash == entryToPin.Hash {
|
||||
hashExists = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hashExists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check pinned count
|
||||
cfg := m.getConfig()
|
||||
pinnedCount := 0
|
||||
@@ -1443,7 +1534,7 @@ func (m *Manager) PinEntry(id uint64) error {
|
||||
return fmt.Errorf("maximum pinned entries reached (%d)", cfg.MaxPinned)
|
||||
}
|
||||
|
||||
err := m.db.Update(func(tx *bolt.Tx) error {
|
||||
err = m.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("clipboard"))
|
||||
v := b.Get(itob(id))
|
||||
if v == nil {
|
||||
|
||||
@@ -276,9 +276,7 @@ func (m *Manager) UnsubscribeClient(clientID string) {
|
||||
})
|
||||
|
||||
for _, subID := range toDelete {
|
||||
if err := m.Unsubscribe(subID); err != nil {
|
||||
log.Warnf("dbus: failed to unsubscribe %s: %v", subID, err)
|
||||
}
|
||||
_ = m.Unsubscribe(subID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package freedesktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
@@ -107,22 +105,8 @@ func (m *Manager) GetUserIconFile(username string) (string, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) SetIconTheme(iconTheme string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
check := exec.CommandContext(ctx, "gsettings", "writable", "org.gnome.desktop.interface", "icon-theme")
|
||||
if err := check.Run(); err == nil {
|
||||
cmd := exec.CommandContext(ctx, "gsettings", "set", "org.gnome.desktop.interface", "icon-theme", iconTheme)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("gsettings set failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "icon-theme", iconTheme); err != nil {
|
||||
return fmt.Errorf("failed to set icon theme: %w", err)
|
||||
}
|
||||
|
||||
checkDconf := exec.CommandContext(ctx, "dconf", "write", "/org/gnome/desktop/interface/icon-theme", fmt.Sprintf("'%s'", iconTheme))
|
||||
if err := checkDconf.Run(); err != nil {
|
||||
return fmt.Errorf("both gsettings and dconf unavailable or failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -52,11 +52,31 @@ func (m *Manager) initializeScreensaver() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
screensaverIface := introspect.Interface{
|
||||
Name: dbusScreensaverInterface,
|
||||
Methods: []introspect.Method{
|
||||
{
|
||||
Name: "Inhibit",
|
||||
Args: []introspect.Arg{
|
||||
{Name: "application_name", Type: "s", Direction: "in"},
|
||||
{Name: "reason_for_inhibit", Type: "s", Direction: "in"},
|
||||
{Name: "cookie", Type: "u", Direction: "out"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "UnInhibit",
|
||||
Args: []introspect.Arg{
|
||||
{Name: "cookie", Type: "u", Direction: "in"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
introNode := &introspect.Node{
|
||||
Name: dbusScreensaverPath,
|
||||
Interfaces: []introspect.Interface{
|
||||
introspect.IntrospectData,
|
||||
{Name: dbusScreensaverInterface},
|
||||
screensaverIface,
|
||||
},
|
||||
}
|
||||
if err := m.sessionConn.Export(introspect.NewIntrospectable(introNode), dbusScreensaverPath, "org.freedesktop.DBus.Introspectable"); err != nil {
|
||||
@@ -67,7 +87,7 @@ func (m *Manager) initializeScreensaver() error {
|
||||
Name: dbusScreensaverPath2,
|
||||
Interfaces: []introspect.Interface{
|
||||
introspect.IntrospectData,
|
||||
{Name: dbusScreensaverInterface},
|
||||
screensaverIface,
|
||||
},
|
||||
}
|
||||
if err := m.sessionConn.Export(introspect.NewIntrospectable(introNode2), dbusScreensaverPath2, "org.freedesktop.DBus.Introspectable"); err != nil {
|
||||
|
||||
@@ -32,8 +32,10 @@ type SecretAgent struct {
|
||||
backend *NetworkManagerBackend
|
||||
}
|
||||
|
||||
type nmVariantMap map[string]dbus.Variant
|
||||
type nmSettingMap map[string]nmVariantMap
|
||||
type (
|
||||
nmVariantMap map[string]dbus.Variant
|
||||
nmSettingMap map[string]nmVariantMap
|
||||
)
|
||||
|
||||
const introspectXML = `
|
||||
<node>
|
||||
@@ -122,7 +124,7 @@ func (a *SecretAgent) GetSecrets(
|
||||
|
||||
connType, displayName, vpnSvc := readConnTypeAndName(conn)
|
||||
ssid := readSSID(conn)
|
||||
fields := fieldsNeeded(settingName, hints)
|
||||
fields := fieldsNeeded(settingName, hints, conn)
|
||||
vpnPasswordFlags := readVPNPasswordFlags(conn, settingName)
|
||||
|
||||
log.Infof("[SecretAgent] connType=%s, name=%s, vpnSvc=%s, fields=%v, flags=%d, vpnPasswordFlags=%d", connType, displayName, vpnSvc, fields, flags, vpnPasswordFlags)
|
||||
@@ -218,8 +220,16 @@ func (a *SecretAgent) GetSecrets(
|
||||
out[settingName] = nmVariantMap{}
|
||||
return out, nil
|
||||
} else if passwordFlags&NM_SETTING_SECRET_FLAG_AGENT_OWNED != 0 {
|
||||
log.Warnf("[SecretAgent] Secrets are agent-owned but we don't store secrets (flags=%d) - returning NoSecrets error", passwordFlags)
|
||||
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
||||
switch settingName {
|
||||
case "802-11-wireless-security":
|
||||
fields = []string{"psk"}
|
||||
case "802-1x":
|
||||
fields = infer8021xFields(conn)
|
||||
default:
|
||||
log.Warnf("[SecretAgent] Agent-owned secrets for unhandled setting %s (flags=%d)", settingName, passwordFlags)
|
||||
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
||||
}
|
||||
log.Infof("[SecretAgent] Agent-owned secrets, inferred fields: %v", fields)
|
||||
} else {
|
||||
log.Infof("[SecretAgent] No secrets needed, using system stored secrets (flags=%d)", passwordFlags)
|
||||
out := nmSettingMap{}
|
||||
@@ -300,6 +310,63 @@ func (a *SecretAgent) GetSecrets(
|
||||
return out, nil
|
||||
}
|
||||
a.backend.cachedVPNCredsMu.Unlock()
|
||||
|
||||
a.backend.cachedGPSamlMu.Lock()
|
||||
cachedGPSaml := a.backend.cachedGPSamlCookie
|
||||
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
|
||||
|
||||
return buildGPSamlSecretsResponse(settingName, cachedGPSaml.Cookie, cachedGPSaml.Host, cachedGPSaml.Fingerprint), nil
|
||||
}
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
if len(fields) == 1 && fields[0] == "gp-saml" {
|
||||
gateway := ""
|
||||
protocol := ""
|
||||
if vpnSettings, ok := conn["vpn"]; ok {
|
||||
if dataVariant, ok := vpnSettings["data"]; ok {
|
||||
if dataMap, ok := dataVariant.Value().(map[string]string); ok {
|
||||
if gw, ok := dataMap["gateway"]; ok {
|
||||
gateway = gw
|
||||
}
|
||||
if proto, ok := dataMap["protocol"]; ok && proto != "" {
|
||||
protocol = proto
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if protocol != "gp" {
|
||||
return nil, dbus.MakeFailedError(fmt.Errorf("gp-saml auth only supported for GlobalProtect (protocol=gp), got: %s", protocol))
|
||||
}
|
||||
|
||||
log.Infof("[SecretAgent] Starting GlobalProtect SAML authentication for gateway=%s", gateway)
|
||||
|
||||
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer samlCancel()
|
||||
|
||||
authResult, err := a.backend.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
|
||||
if err != nil {
|
||||
log.Warnf("[SecretAgent] GlobalProtect SAML authentication failed: %v", err)
|
||||
return nil, dbus.MakeFailedError(fmt.Errorf("GlobalProtect SAML authentication failed: %w", err))
|
||||
}
|
||||
|
||||
log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager")
|
||||
|
||||
a.backend.cachedGPSamlMu.Lock()
|
||||
a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{
|
||||
ConnectionUUID: connUuid,
|
||||
Cookie: authResult.Cookie,
|
||||
Host: authResult.Host,
|
||||
User: authResult.User,
|
||||
Fingerprint: authResult.Fingerprint,
|
||||
}
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
return buildGPSamlSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
@@ -418,8 +485,19 @@ func (a *SecretAgent) GetSecrets(
|
||||
log.Infof("[SecretAgent] Cached PKCS11 PIN for potential re-request")
|
||||
}
|
||||
case "802-1x":
|
||||
out[settingName] = sec
|
||||
log.Infof("[SecretAgent] Returning 802-1x enterprise secrets with %d fields", len(sec))
|
||||
secretsOnly := nmVariantMap{}
|
||||
for k, v := range reply.Secrets {
|
||||
switch k {
|
||||
case "password", "private-key-password", "phase2-private-key-password", "pin":
|
||||
secretsOnly[k] = dbus.MakeVariant(v)
|
||||
}
|
||||
}
|
||||
out[settingName] = secretsOnly
|
||||
|
||||
if identity, ok := reply.Secrets["identity"]; ok && identity != "" {
|
||||
a.save8021xIdentity(path, identity)
|
||||
}
|
||||
log.Infof("[SecretAgent] Returning 802-1x enterprise secrets with %d fields", len(secretsOnly))
|
||||
default:
|
||||
out[settingName] = sec
|
||||
}
|
||||
@@ -434,63 +512,6 @@ func (a *SecretAgent) GetSecrets(
|
||||
}
|
||||
a.backend.pendingVPNSaveMu.Unlock()
|
||||
log.Infof("[SecretAgent] Queued credentials persist for after connection succeeds")
|
||||
} else if reply.Save && settingName != "vpn" {
|
||||
// Non-VPN save logic
|
||||
go func() {
|
||||
log.Infof("[SecretAgent] Persisting secrets with Update2: path=%s, setting=%s", path, settingName)
|
||||
|
||||
connObj := a.conn.Object("org.freedesktop.NetworkManager", path)
|
||||
var existingSettings map[string]map[string]dbus.Variant
|
||||
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
|
||||
log.Warnf("[SecretAgent] GetSettings failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
settings := make(map[string]map[string]dbus.Variant)
|
||||
if connSection, ok := existingSettings["connection"]; ok {
|
||||
settings["connection"] = connSection
|
||||
}
|
||||
|
||||
switch settingName {
|
||||
case "802-11-wireless-security":
|
||||
wifiSec, ok := existingSettings["802-11-wireless-security"]
|
||||
if !ok {
|
||||
wifiSec = make(map[string]dbus.Variant)
|
||||
}
|
||||
wifiSec["psk-flags"] = dbus.MakeVariant(uint32(0))
|
||||
|
||||
if psk, ok := reply.Secrets["psk"]; ok {
|
||||
wifiSec["psk"] = dbus.MakeVariant(psk)
|
||||
log.Infof("[SecretAgent] Updated WiFi settings: psk-flags=0")
|
||||
}
|
||||
settings["802-11-wireless-security"] = wifiSec
|
||||
|
||||
case "802-1x":
|
||||
dot1x, ok := existingSettings["802-1x"]
|
||||
if !ok {
|
||||
dot1x = make(map[string]dbus.Variant)
|
||||
}
|
||||
dot1x["password-flags"] = dbus.MakeVariant(uint32(0))
|
||||
|
||||
if password, ok := reply.Secrets["password"]; ok {
|
||||
dot1x["password"] = dbus.MakeVariant(password)
|
||||
log.Infof("[SecretAgent] Updated 802.1x settings: password-flags=0")
|
||||
}
|
||||
settings["802-1x"] = dot1x
|
||||
}
|
||||
|
||||
// Call Update2 with correct signature:
|
||||
// Update2(IN settings, IN flags, IN args) -> OUT result
|
||||
// flags: 0x1 = to-disk
|
||||
var result map[string]dbus.Variant
|
||||
err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
|
||||
settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result)
|
||||
if err != nil {
|
||||
log.Warnf("[SecretAgent] Update2(to-disk) failed: %v", err)
|
||||
} else {
|
||||
log.Infof("[SecretAgent] Successfully persisted secrets to disk for %s", settingName)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return out, nil
|
||||
@@ -523,6 +544,35 @@ func (a *SecretAgent) Introspect() (string, *dbus.Error) {
|
||||
return introspectXML, nil
|
||||
}
|
||||
|
||||
func (a *SecretAgent) save8021xIdentity(path dbus.ObjectPath, identity string) {
|
||||
connObj := a.conn.Object("org.freedesktop.NetworkManager", path)
|
||||
var existing map[string]map[string]dbus.Variant
|
||||
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existing); err != nil {
|
||||
log.Warnf("[SecretAgent] Failed to get settings for identity save: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
settings := make(map[string]map[string]dbus.Variant)
|
||||
if connSection, ok := existing["connection"]; ok {
|
||||
settings["connection"] = connSection
|
||||
}
|
||||
|
||||
dot1x, ok := existing["802-1x"]
|
||||
if !ok {
|
||||
dot1x = make(map[string]dbus.Variant)
|
||||
}
|
||||
dot1x["identity"] = dbus.MakeVariant(identity)
|
||||
settings["802-1x"] = dot1x
|
||||
|
||||
var result map[string]dbus.Variant
|
||||
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
|
||||
settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil {
|
||||
log.Warnf("[SecretAgent] Failed to save 802.1x identity: %v", err)
|
||||
return
|
||||
}
|
||||
log.Infof("[SecretAgent] Saved 802.1x identity to connection profile")
|
||||
}
|
||||
|
||||
func readSSID(conn map[string]nmVariantMap) string {
|
||||
if w, ok := conn["802-11-wireless"]; ok {
|
||||
if v, ok := w["ssid"]; ok {
|
||||
@@ -564,12 +614,15 @@ func readConnTypeAndName(conn map[string]nmVariantMap) (string, string, string)
|
||||
return connType, name, svc
|
||||
}
|
||||
|
||||
func fieldsNeeded(setting string, hints []string) []string {
|
||||
func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap) []string {
|
||||
switch setting {
|
||||
case "802-11-wireless-security":
|
||||
return []string{"psk"}
|
||||
case "802-1x":
|
||||
return []string{"identity", "password"}
|
||||
if len(hints) > 0 {
|
||||
return hints
|
||||
}
|
||||
return infer8021xFields(conn)
|
||||
case "vpn":
|
||||
return hints
|
||||
default:
|
||||
@@ -577,6 +630,41 @@ func fieldsNeeded(setting string, hints []string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func infer8021xFields(conn map[string]nmVariantMap) []string {
|
||||
dot1x, ok := conn["802-1x"]
|
||||
if !ok {
|
||||
return []string{"identity", "password"}
|
||||
}
|
||||
|
||||
var fields []string
|
||||
|
||||
if v, ok := dot1x["identity"]; ok {
|
||||
if id, ok := v.Value().(string); ok && id != "" {
|
||||
// identity already stored, don't ask again
|
||||
} else {
|
||||
fields = append(fields, "identity")
|
||||
}
|
||||
} else {
|
||||
fields = append(fields, "identity")
|
||||
}
|
||||
|
||||
var eapMethods []string
|
||||
if v, ok := dot1x["eap"]; ok {
|
||||
if methods, ok := v.Value().([]string); ok {
|
||||
eapMethods = methods
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(eapMethods) > 0 && eapMethods[0] == "tls":
|
||||
fields = append(fields, "private-key-password")
|
||||
default:
|
||||
fields = append(fields, "password")
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func buildFieldsInfo(setting string, fields []string, vpnService string) []FieldInfo {
|
||||
result := make([]FieldInfo, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
@@ -630,12 +718,25 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
|
||||
|
||||
switch {
|
||||
case strings.Contains(vpnService, "openconnect"):
|
||||
protocol := dataMap["protocol"]
|
||||
authType := dataMap["authtype"]
|
||||
userCert := dataMap["usercert"]
|
||||
if authType == "cert" && strings.HasPrefix(userCert, "pkcs11:") {
|
||||
username := dataMap["username"]
|
||||
|
||||
if authType == "cert" && strings.HasPrefix(dataMap["usercert"], "pkcs11:") {
|
||||
return []string{"key_pass"}
|
||||
}
|
||||
if dataMap["username"] == "" {
|
||||
|
||||
if needsExternalBrowserAuth(protocol, authType, username, dataMap) {
|
||||
switch protocol {
|
||||
case "gp":
|
||||
log.Infof("[SecretAgent] GlobalProtect SAML auth detected")
|
||||
return []string{"gp-saml"}
|
||||
default:
|
||||
log.Infof("[SecretAgent] External browser auth detected for protocol '%s' but only GlobalProtect (gp) SAML is currently supported, falling back to credentials", protocol)
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
fields = []string{"username", "password"}
|
||||
}
|
||||
case strings.Contains(vpnService, "openvpn"):
|
||||
@@ -654,8 +755,31 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
|
||||
return fields
|
||||
}
|
||||
|
||||
func needsExternalBrowserAuth(protocol, authType, username string, data map[string]string) bool {
|
||||
if method, ok := data["saml-auth-method"]; ok {
|
||||
if method == "REDIRECT" || method == "POST" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if authType != "" && authType != "password" && authType != "cert" {
|
||||
return true
|
||||
}
|
||||
|
||||
switch protocol {
|
||||
case "gp":
|
||||
if authType == "" && username == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func vpnFieldMeta(field, vpnService string) (label string, isSecret bool) {
|
||||
switch field {
|
||||
case "gp-saml":
|
||||
return "GlobalProtect SAML/SSO", false
|
||||
case "key_pass":
|
||||
return "PIN", true
|
||||
case "password":
|
||||
@@ -756,3 +880,18 @@ func reasonFromFlags(flags uint32) string {
|
||||
}
|
||||
return "required"
|
||||
}
|
||||
|
||||
func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
|
||||
out := nmSettingMap{}
|
||||
vpnSec := nmVariantMap{}
|
||||
|
||||
secrets := map[string]string{
|
||||
"cookie": cookie,
|
||||
"gateway": host,
|
||||
"gwcert": fingerprint,
|
||||
}
|
||||
vpnSec["secrets"] = dbus.MakeVariant(secrets)
|
||||
|
||||
out[settingName] = vpnSec
|
||||
return out
|
||||
}
|
||||
|
||||
355
core/internal/server/network/agent_networkmanager_test.go
Normal file
355
core/internal/server/network/agent_networkmanager_test.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNeedsExternalBrowserAuth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
protocol string
|
||||
authType string
|
||||
username string
|
||||
data map[string]string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "GP with saml-auth-method REDIRECT",
|
||||
protocol: "gp",
|
||||
authType: "password",
|
||||
username: "user",
|
||||
data: map[string]string{"saml-auth-method": "REDIRECT"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GP with saml-auth-method POST",
|
||||
protocol: "gp",
|
||||
authType: "password",
|
||||
username: "user",
|
||||
data: map[string]string{"saml-auth-method": "POST"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GP with no authtype and no username",
|
||||
protocol: "gp",
|
||||
authType: "",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GP with username and password authtype",
|
||||
protocol: "gp",
|
||||
authType: "password",
|
||||
username: "john",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "GP with username but no authtype",
|
||||
protocol: "gp",
|
||||
authType: "",
|
||||
username: "john",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "GP with authtype but no username - should detect SAML",
|
||||
protocol: "gp",
|
||||
authType: "",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "pulse with SAML",
|
||||
protocol: "pulse",
|
||||
authType: "",
|
||||
username: "",
|
||||
data: map[string]string{"saml-auth-method": "REDIRECT"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "fortinet with non-password authtype",
|
||||
protocol: "fortinet",
|
||||
authType: "saml",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "anyconnect with cert",
|
||||
protocol: "anyconnect",
|
||||
authType: "cert",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "anyconnect with password",
|
||||
protocol: "anyconnect",
|
||||
authType: "password",
|
||||
username: "user",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty protocol",
|
||||
protocol: "",
|
||||
authType: "",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "GP with cert authtype",
|
||||
protocol: "gp",
|
||||
authType: "cert",
|
||||
username: "",
|
||||
data: map[string]string{},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := needsExternalBrowserAuth(tt.protocol, tt.authType, tt.username, tt.data)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGPSamlSecretsResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settingName string
|
||||
cookie string
|
||||
host string
|
||||
fingerprint string
|
||||
}{
|
||||
{
|
||||
name: "all fields populated",
|
||||
settingName: "vpn",
|
||||
cookie: "authcookie=abc123&portal=GATE",
|
||||
host: "vpn.example.com",
|
||||
fingerprint: "pin-sha256:ABCD1234",
|
||||
},
|
||||
{
|
||||
name: "empty fingerprint",
|
||||
settingName: "vpn",
|
||||
cookie: "authcookie=xyz",
|
||||
host: "10.0.0.1",
|
||||
fingerprint: "",
|
||||
},
|
||||
{
|
||||
name: "complex cookie with special chars",
|
||||
settingName: "vpn",
|
||||
cookie: "authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100",
|
||||
host: "connect.seclore.com",
|
||||
fingerprint: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildGPSamlSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
|
||||
|
||||
assert.NotNil(t, result)
|
||||
assert.Contains(t, result, tt.settingName)
|
||||
|
||||
vpnSec := result[tt.settingName]
|
||||
assert.NotNil(t, vpnSec)
|
||||
|
||||
secretsVariant, ok := vpnSec["secrets"]
|
||||
assert.True(t, ok, "secrets key should exist")
|
||||
|
||||
secrets, ok := secretsVariant.Value().(map[string]string)
|
||||
assert.True(t, ok, "secrets should be map[string]string")
|
||||
|
||||
assert.Equal(t, tt.cookie, secrets["cookie"])
|
||||
assert.Equal(t, tt.host, secrets["gateway"])
|
||||
assert.Equal(t, tt.fingerprint, secrets["gwcert"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVpnFieldMeta_GPSaml(t *testing.T) {
|
||||
label, isSecret := vpnFieldMeta("gp-saml", "org.freedesktop.NetworkManager.openconnect")
|
||||
|
||||
assert.Equal(t, "GlobalProtect SAML/SSO", label)
|
||||
assert.False(t, isSecret, "gp-saml should not be marked as secret")
|
||||
}
|
||||
|
||||
func TestVpnFieldMeta_StandardFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
field string
|
||||
vpnService string
|
||||
expectedLabel string
|
||||
expectedSecret bool
|
||||
}{
|
||||
{
|
||||
field: "username",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
expectedLabel: "Username",
|
||||
expectedSecret: false,
|
||||
},
|
||||
{
|
||||
field: "password",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
expectedLabel: "Password",
|
||||
expectedSecret: true,
|
||||
},
|
||||
{
|
||||
field: "key_pass",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
expectedLabel: "PIN",
|
||||
expectedSecret: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.field, func(t *testing.T) {
|
||||
label, isSecret := vpnFieldMeta(tt.field, tt.vpnService)
|
||||
assert.Equal(t, tt.expectedLabel, label)
|
||||
assert.Equal(t, tt.expectedSecret, isSecret)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferVPNFields_GPSaml(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vpnService string
|
||||
dataMap map[string]string
|
||||
expectedLen int
|
||||
shouldHave []string
|
||||
}{
|
||||
{
|
||||
name: "GP with no authtype and no username - should require SAML",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"gp-saml"},
|
||||
},
|
||||
{
|
||||
name: "GP with saml-auth-method REDIRECT",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
"saml-auth-method": "REDIRECT",
|
||||
"username": "john",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"gp-saml"},
|
||||
},
|
||||
{
|
||||
name: "GP with saml-auth-method POST",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
"saml-auth-method": "POST",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"gp-saml"},
|
||||
},
|
||||
{
|
||||
name: "GP with username and password authtype - should use credentials",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
"authtype": "password",
|
||||
"username": "john",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"password"},
|
||||
},
|
||||
{
|
||||
name: "GP with username but no authtype - password only",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
"username": "john",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"password"},
|
||||
},
|
||||
{
|
||||
name: "GP with PKCS11 cert",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "gp",
|
||||
"gateway": "vpn.example.com",
|
||||
"authtype": "cert",
|
||||
"usercert": "pkcs11:model=PKCS%2315%20emulated;manufacturer=piv_II",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"key_pass"},
|
||||
},
|
||||
{
|
||||
name: "non-GP protocol (anyconnect)",
|
||||
vpnService: "org.freedesktop.NetworkManager.openconnect",
|
||||
dataMap: map[string]string{
|
||||
"protocol": "anyconnect",
|
||||
"gateway": "vpn.example.com",
|
||||
},
|
||||
expectedLen: 2,
|
||||
shouldHave: []string{"username", "password"},
|
||||
},
|
||||
{
|
||||
name: "OpenVPN with username",
|
||||
vpnService: "org.freedesktop.NetworkManager.openvpn",
|
||||
dataMap: map[string]string{
|
||||
"connection-type": "password",
|
||||
"username": "john",
|
||||
},
|
||||
expectedLen: 1,
|
||||
shouldHave: []string{"password"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Convert dataMap to nmVariantMap
|
||||
vpnSettings := make(nmVariantMap)
|
||||
vpnSettings["data"] = dbus.MakeVariant(tt.dataMap)
|
||||
vpnSettings["service-type"] = dbus.MakeVariant(tt.vpnService)
|
||||
|
||||
conn := make(map[string]nmVariantMap)
|
||||
conn["vpn"] = vpnSettings
|
||||
|
||||
fields := inferVPNFields(conn, tt.vpnService)
|
||||
|
||||
assert.Len(t, fields, tt.expectedLen, "unexpected number of fields")
|
||||
if len(tt.shouldHave) > 0 {
|
||||
for _, expected := range tt.shouldHave {
|
||||
assert.Contains(t, fields, expected, "should contain field: %s", expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNmVariantMap(t *testing.T) {
|
||||
// Test that nmVariantMap and nmSettingMap work correctly
|
||||
settingMap := make(nmSettingMap)
|
||||
variantMap := make(nmVariantMap)
|
||||
|
||||
variantMap["test-key"] = dbus.MakeVariant("test-value")
|
||||
settingMap["test-setting"] = variantMap
|
||||
|
||||
assert.Contains(t, settingMap, "test-setting")
|
||||
assert.Contains(t, settingMap["test-setting"], "test-key")
|
||||
|
||||
value := settingMap["test-setting"]["test-key"].Value()
|
||||
assert.Equal(t, "test-value", value)
|
||||
}
|
||||
@@ -69,12 +69,14 @@ type NetworkManagerBackend struct {
|
||||
lastFailedTime int64
|
||||
failedMutex sync.RWMutex
|
||||
|
||||
pendingVPNSave *pendingVPNCredentials
|
||||
pendingVPNSaveMu sync.Mutex
|
||||
cachedVPNCreds *cachedVPNCredentials
|
||||
cachedVPNCredsMu sync.Mutex
|
||||
cachedPKCS11PIN *cachedPKCS11PIN
|
||||
cachedPKCS11Mu sync.Mutex
|
||||
pendingVPNSave *pendingVPNCredentials
|
||||
pendingVPNSaveMu sync.Mutex
|
||||
cachedVPNCreds *cachedVPNCredentials
|
||||
cachedVPNCredsMu sync.Mutex
|
||||
cachedPKCS11PIN *cachedPKCS11PIN
|
||||
cachedPKCS11Mu sync.Mutex
|
||||
cachedGPSamlCookie *cachedGPSamlCookie
|
||||
cachedGPSamlMu sync.Mutex
|
||||
|
||||
onStateChange func()
|
||||
}
|
||||
@@ -97,6 +99,14 @@ type cachedPKCS11PIN struct {
|
||||
PIN string
|
||||
}
|
||||
|
||||
type cachedGPSamlCookie struct {
|
||||
ConnectionUUID string
|
||||
Cookie string
|
||||
Host string
|
||||
User string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) {
|
||||
var nm gonetworkmanager.NetworkManager
|
||||
var err error
|
||||
|
||||
203
core/internal/server/network/backend_networkmanager_gp_saml.go
Normal file
203
core/internal/server/network/backend_networkmanager_gp_saml.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
type gpSamlAuthResult struct {
|
||||
Cookie string
|
||||
Host string
|
||||
User string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui.
|
||||
// Only supports protocol=gp. Other protocols need their own implementations.
|
||||
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*gpSamlAuthResult, error) {
|
||||
if gateway == "" {
|
||||
return nil, fmt.Errorf("GP SAML auth: gateway is empty")
|
||||
}
|
||||
if protocol != "gp" {
|
||||
return nil, fmt.Errorf("only GlobalProtect (protocol=gp) SAML is supported, got: %s", protocol)
|
||||
}
|
||||
|
||||
log.Infof("[GP-SAML] Starting GlobalProtect SAML authentication with gp-saml-gui for gateway=%s", gateway)
|
||||
|
||||
gpSamlPath, err := exec.LookPath("gp-saml-gui")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GlobalProtect SAML requires gp-saml-gui (install: pip install gp-saml-gui): %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--gateway",
|
||||
"--allow-insecure-crypto",
|
||||
gateway,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, gpSamlPath, args...)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GP SAML auth: failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GP SAML auth: failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("GP SAML auth: failed to start gp-saml-gui: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
for scanner.Scan() {
|
||||
log.Debugf("[GP-SAML] gp-saml-gui: %s", scanner.Text())
|
||||
}
|
||||
}()
|
||||
|
||||
result := &gpSamlAuthResult{Host: gateway}
|
||||
var allOutput []string
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
allOutput = append(allOutput, line)
|
||||
log.Infof("[GP-SAML] stdout: %s", line)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(line, "COOKIE="):
|
||||
result.Cookie = unshellQuote(strings.TrimPrefix(line, "COOKIE="))
|
||||
case strings.HasPrefix(line, "HOST="):
|
||||
result.Host = unshellQuote(strings.TrimPrefix(line, "HOST="))
|
||||
case strings.HasPrefix(line, "USER="):
|
||||
result.User = unshellQuote(strings.TrimPrefix(line, "USER="))
|
||||
case strings.HasPrefix(line, "FINGERPRINT="):
|
||||
result.Fingerprint = unshellQuote(strings.TrimPrefix(line, "FINGERPRINT="))
|
||||
default:
|
||||
parseGPSamlFromCommandLine(line, result)
|
||||
}
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("GP SAML auth timed out or was cancelled: %w", ctx.Err())
|
||||
}
|
||||
if result.Cookie == "" {
|
||||
return nil, fmt.Errorf("GP SAML auth failed: %w (output: %s)", err, strings.Join(allOutput, "\n"))
|
||||
}
|
||||
log.Warnf("[GP-SAML] gp-saml-gui exited with error but cookie was captured: %v", err)
|
||||
}
|
||||
|
||||
if result.Cookie == "" {
|
||||
return nil, fmt.Errorf("GP SAML auth: no cookie in gp-saml-gui output")
|
||||
}
|
||||
|
||||
log.Infof("[GP-SAML] Got prelogin-cookie from gp-saml-gui, converting to openconnect cookie via --authenticate")
|
||||
|
||||
// Convert prelogin-cookie to full openconnect cookie format
|
||||
ocResult, err := convertGPPreloginCookie(ctx, gateway, result.Cookie, result.User)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GP SAML auth: failed to convert prelogin-cookie: %w", err)
|
||||
}
|
||||
|
||||
result.Cookie = ocResult.Cookie
|
||||
result.Host = ocResult.Host
|
||||
result.Fingerprint = ocResult.Fingerprint
|
||||
|
||||
log.Infof("[GP-SAML] Authentication successful: user=%s, host=%s, cookie_len=%d, has_fingerprint=%v",
|
||||
result.User, result.Host, len(result.Cookie), result.Fingerprint != "")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) {
|
||||
ocPath, err := exec.LookPath("openconnect")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openconnect not found: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--protocol=gp",
|
||||
"--usergroup=gateway:prelogin-cookie",
|
||||
"--user=" + user,
|
||||
"--passwd-on-stdin",
|
||||
"--allow-insecure-crypto",
|
||||
"--authenticate",
|
||||
gateway,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ocPath, args...)
|
||||
cmd.Stdin = strings.NewReader(preloginCookie)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
result := &gpSamlAuthResult{}
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "COOKIE="):
|
||||
result.Cookie = unshellQuote(strings.TrimPrefix(line, "COOKIE="))
|
||||
case strings.HasPrefix(line, "HOST="):
|
||||
result.Host = unshellQuote(strings.TrimPrefix(line, "HOST="))
|
||||
case strings.HasPrefix(line, "FINGERPRINT="):
|
||||
result.Fingerprint = unshellQuote(strings.TrimPrefix(line, "FINGERPRINT="))
|
||||
case strings.HasPrefix(line, "CONNECT_URL="):
|
||||
connectURL := unshellQuote(strings.TrimPrefix(line, "CONNECT_URL="))
|
||||
if connectURL != "" && result.Host == "" {
|
||||
result.Host = connectURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.Cookie == "" {
|
||||
return nil, fmt.Errorf("no COOKIE in openconnect --authenticate output: %s", string(output))
|
||||
}
|
||||
|
||||
log.Infof("[GP-SAML] openconnect --authenticate: cookie_len=%d, host=%s, fingerprint=%s",
|
||||
len(result.Cookie), result.Host, result.Fingerprint)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func unshellQuote(s string) string {
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '\'' && s[len(s)-1] == '\'') ||
|
||||
(s[0] == '"' && s[len(s)-1] == '"') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) {
|
||||
if !strings.Contains(line, "openconnect") {
|
||||
return
|
||||
}
|
||||
|
||||
for _, part := range strings.Fields(line) {
|
||||
switch {
|
||||
case strings.HasPrefix(part, "--cookie="):
|
||||
if result.Cookie == "" {
|
||||
result.Cookie = strings.TrimPrefix(part, "--cookie=")
|
||||
}
|
||||
case strings.HasPrefix(part, "--servercert="):
|
||||
if result.Fingerprint == "" {
|
||||
result.Fingerprint = strings.TrimPrefix(part, "--servercert=")
|
||||
}
|
||||
case strings.HasPrefix(part, "--user="):
|
||||
if result.User == "" {
|
||||
result.User = strings.TrimPrefix(part, "--user=")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUnshellQuote(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single quoted",
|
||||
input: "'hello world'",
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "double quoted",
|
||||
input: `"hello world"`,
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "unquoted",
|
||||
input: "hello",
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
name: "empty single quotes",
|
||||
input: "''",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty double quotes",
|
||||
input: `""`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "single quote only",
|
||||
input: "'",
|
||||
expected: "'",
|
||||
},
|
||||
{
|
||||
name: "mismatched quotes",
|
||||
input: "'hello\"",
|
||||
expected: "'hello\"",
|
||||
},
|
||||
{
|
||||
name: "with special chars",
|
||||
input: "'cookie=abc123&user=john'",
|
||||
expected: "cookie=abc123&user=john",
|
||||
},
|
||||
{
|
||||
name: "complex cookie",
|
||||
input: `'authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100'`,
|
||||
expected: "authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := unshellQuote(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGPSamlFromCommandLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
initialResult *gpSamlAuthResult
|
||||
expectedCookie string
|
||||
expectedUser string
|
||||
expectedFP string
|
||||
}{
|
||||
{
|
||||
name: "full openconnect command",
|
||||
line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john",
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "AUTH123",
|
||||
expectedUser: "john",
|
||||
expectedFP: "pin-sha256:ABC",
|
||||
},
|
||||
{
|
||||
name: "with equals signs in cookie",
|
||||
line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane",
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "authcookie=xyz123&portal=GATE",
|
||||
expectedUser: "jane",
|
||||
expectedFP: "",
|
||||
},
|
||||
{
|
||||
name: "non-openconnect line",
|
||||
line: "some other output",
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "",
|
||||
expectedUser: "",
|
||||
expectedFP: "",
|
||||
},
|
||||
{
|
||||
name: "preserves existing values",
|
||||
line: "openconnect --user=newuser",
|
||||
initialResult: &gpSamlAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
|
||||
expectedCookie: "existing",
|
||||
expectedUser: "newuser",
|
||||
expectedFP: "existing-fp",
|
||||
},
|
||||
{
|
||||
name: "only updates empty fields",
|
||||
line: "openconnect --cookie=NEW --user=NEW",
|
||||
initialResult: &gpSamlAuthResult{Cookie: "OLD"},
|
||||
expectedCookie: "OLD",
|
||||
expectedUser: "NEW",
|
||||
expectedFP: "",
|
||||
},
|
||||
{
|
||||
name: "real gp-saml-gui output",
|
||||
line: "openconnect --protocol=gp --user=john.doe@example.com --os=linux-64 --usergroup=gateway:prelogin-cookie --passwd-on-stdin",
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "",
|
||||
expectedUser: "john.doe@example.com",
|
||||
expectedFP: "",
|
||||
},
|
||||
{
|
||||
name: "with server cert flag",
|
||||
line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com",
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "",
|
||||
expectedUser: "",
|
||||
expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.initialResult
|
||||
parseGPSamlFromCommandLine(tt.line, result)
|
||||
|
||||
assert.Equal(t, tt.expectedCookie, result.Cookie, "cookie mismatch")
|
||||
assert.Equal(t, tt.expectedUser, result.User, "user mismatch")
|
||||
assert.Equal(t, tt.expectedFP, result.Fingerprint, "fingerprint mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
|
||||
// Simulate gp-saml-gui output with command line suggestion
|
||||
lines := []string{
|
||||
"",
|
||||
"SAML REDIRECT",
|
||||
"Got SAML Login URL",
|
||||
"POST to ACS endpoint...",
|
||||
"Got 'prelogin-cookie': 'FAKE_cookie_12345'",
|
||||
"openconnect --protocol=gp --user=john.doe@example.com --usergroup=gateway:prelogin-cookie --passwd-on-stdin vpn.example.com",
|
||||
"",
|
||||
}
|
||||
|
||||
result := &gpSamlAuthResult{}
|
||||
for _, line := range lines {
|
||||
parseGPSamlFromCommandLine(line, result)
|
||||
}
|
||||
|
||||
assert.Equal(t, "john.doe@example.com", result.User)
|
||||
assert.Empty(t, result.Cookie, "cookie should not be parsed from command line")
|
||||
assert.Empty(t, result.Fingerprint)
|
||||
}
|
||||
@@ -212,32 +212,28 @@ func (b *NetworkManagerBackend) updateWiFiState() error {
|
||||
}
|
||||
}
|
||||
|
||||
var forgetSSID string
|
||||
|
||||
b.stateMutex.Lock()
|
||||
defer b.stateMutex.Unlock()
|
||||
|
||||
wasConnecting = b.state.IsConnecting
|
||||
connectingSSID = b.state.ConnectingSSID
|
||||
|
||||
if wasConnecting && connectingSSID != "" {
|
||||
if connected && ssid == connectingSSID {
|
||||
switch {
|
||||
case connected && ssid == connectingSSID:
|
||||
log.Infof("[updateWiFiState] Connection successful: %s", ssid)
|
||||
b.state.IsConnecting = false
|
||||
b.state.ConnectingSSID = ""
|
||||
b.state.LastError = ""
|
||||
} else if failed || (disconnected && !connected) {
|
||||
case failed || (disconnected && !connected):
|
||||
log.Warnf("[updateWiFiState] Connection failed: SSID=%s, state=%d", connectingSSID, state)
|
||||
b.state.IsConnecting = false
|
||||
b.state.ConnectingSSID = ""
|
||||
b.state.LastError = reasonCode
|
||||
|
||||
// If user cancelled, delete the connection profile that was just created
|
||||
if reasonCode == errdefs.ErrUserCanceled {
|
||||
log.Infof("[updateWiFiState] User cancelled authentication, removing connection profile for %s", connectingSSID)
|
||||
b.stateMutex.Unlock()
|
||||
if err := b.ForgetWiFiNetwork(connectingSSID); err != nil {
|
||||
log.Warnf("[updateWiFiState] Failed to remove cancelled connection: %v", err)
|
||||
}
|
||||
b.stateMutex.Lock()
|
||||
forgetSSID = connectingSSID
|
||||
}
|
||||
|
||||
b.failedMutex.Lock()
|
||||
@@ -254,6 +250,15 @@ func (b *NetworkManagerBackend) updateWiFiState() error {
|
||||
b.state.WiFiBSSID = bssid
|
||||
b.state.WiFiSignal = signal
|
||||
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
if forgetSSID != "" {
|
||||
log.Infof("[updateWiFiState] User cancelled authentication, removing connection profile for %s", forgetSSID)
|
||||
if err := b.ForgetWiFiNetwork(forgetSSID); err != nil {
|
||||
log.Warnf("[updateWiFiState] Failed to remove cancelled connection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -304,6 +304,51 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
||||
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
|
||||
return err
|
||||
}
|
||||
case "gp_saml":
|
||||
gateway := vpnData["gateway"]
|
||||
protocol := vpnData["protocol"]
|
||||
if protocol != "gp" {
|
||||
return fmt.Errorf("GlobalProtect SAML authentication only supported for protocol=gp, got: %s", protocol)
|
||||
}
|
||||
|
||||
log.Infof("[ConnectVPN] GlobalProtect SAML/SSO authentication required for %s (gateway=%s)", connName, gateway)
|
||||
|
||||
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
authResult, err := b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
|
||||
samlCancel()
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(errMsg, "not installed"):
|
||||
return fmt.Errorf("gp-saml-gui is not installed (required for GlobalProtect SAML/SSO VPN)")
|
||||
case strings.Contains(errMsg, "timed out") || strings.Contains(errMsg, "cancelled"):
|
||||
return fmt.Errorf("GlobalProtect SAML authentication timed out — please try again")
|
||||
case strings.Contains(errMsg, "no cookie"):
|
||||
return fmt.Errorf("GlobalProtect SAML login did not complete — browser was closed before authentication finished")
|
||||
case strings.Contains(errMsg, "convert prelogin-cookie"):
|
||||
return fmt.Errorf("GlobalProtect VPN authentication succeeded but cookie exchange failed: %w", err)
|
||||
default:
|
||||
return fmt.Errorf("GlobalProtect SAML authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = &cachedGPSamlCookie{
|
||||
ConnectionUUID: targetUUID,
|
||||
Cookie: authResult.Cookie,
|
||||
Host: authResult.Host,
|
||||
User: authResult.User,
|
||||
Fingerprint: authResult.Fingerprint,
|
||||
}
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
|
||||
if err := targetConn.ClearSecrets(); err != nil {
|
||||
log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err)
|
||||
} else {
|
||||
log.Infof("[ConnectVPN] Cleared stale stored secrets for %s", connName)
|
||||
}
|
||||
|
||||
log.Infof("[ConnectVPN] GlobalProtect SAML cookie cached for %s, proceeding with activation", connName)
|
||||
}
|
||||
|
||||
b.stateMutex.Lock()
|
||||
@@ -339,6 +384,16 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(serviceType, "openconnect"):
|
||||
protocol := data["protocol"]
|
||||
if needsExternalBrowserAuth(protocol, data["authtype"], data["username"], data) {
|
||||
switch protocol {
|
||||
case "gp":
|
||||
return "gp_saml"
|
||||
default:
|
||||
log.Infof("[VPN] External browser auth detected for protocol '%s' but only GlobalProtect (gp) is currently supported", protocol)
|
||||
}
|
||||
}
|
||||
case strings.Contains(serviceType, "openvpn"):
|
||||
connType := data["connection-type"]
|
||||
username := data["username"]
|
||||
@@ -412,16 +467,6 @@ func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkma
|
||||
}
|
||||
data["username"] = username
|
||||
|
||||
if reply.Save && password != "" {
|
||||
data["password-flags"] = "0"
|
||||
secs := make(map[string]string)
|
||||
secs["password"] = password
|
||||
vpn["secrets"] = dbus.MakeVariant(secs)
|
||||
log.Infof("[ConnectVPN] Saving username and password to vpn.data")
|
||||
} else {
|
||||
log.Infof("[ConnectVPN] Saving username to vpn.data (password will be prompted)")
|
||||
}
|
||||
|
||||
vpn["data"] = dbus.MakeVariant(data)
|
||||
settings["vpn"] = vpn
|
||||
|
||||
@@ -432,7 +477,7 @@ func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkma
|
||||
}
|
||||
log.Infof("[ConnectVPN] Username saved to connection")
|
||||
|
||||
if password != "" && !reply.Save {
|
||||
if password != "" {
|
||||
b.cachedVPNCredsMu.Lock()
|
||||
b.cachedVPNCreds = &cachedVPNCredentials{
|
||||
ConnectionUUID: targetUUID,
|
||||
@@ -614,11 +659,7 @@ func (b *NetworkManagerBackend) ClearVPNCredentials(uuidOrName string) error {
|
||||
dataMap["password-flags"] = "1"
|
||||
vpnSettings["data"] = dataMap
|
||||
}
|
||||
|
||||
vpnSettings["password-flags"] = uint32(1)
|
||||
}
|
||||
|
||||
settings["vpn-secrets"] = make(map[string]any)
|
||||
}
|
||||
|
||||
if err := conn.Update(settings); err != nil {
|
||||
@@ -684,10 +725,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
||||
b.state.LastError = ""
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
// Clear cached PKCS11 PIN on success
|
||||
// Clear cached PKCS11 PIN and SAML cookie on success
|
||||
b.cachedPKCS11Mu.Lock()
|
||||
b.cachedPKCS11PIN = nil
|
||||
b.cachedPKCS11Mu.Unlock()
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = nil
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
|
||||
b.pendingVPNSaveMu.Lock()
|
||||
pending := b.pendingVPNSave
|
||||
@@ -706,10 +750,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
||||
b.state.LastError = "VPN connection failed"
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
// Clear cached PKCS11 PIN on failure
|
||||
// Clear cached PKCS11 PIN and SAML cookie on failure
|
||||
b.cachedPKCS11Mu.Lock()
|
||||
b.cachedPKCS11PIN = nil
|
||||
b.cachedPKCS11Mu.Unlock()
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = nil
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -723,10 +770,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
||||
b.state.LastError = "VPN connection failed"
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
// Clear cached PKCS11 PIN
|
||||
// Clear cached PKCS11 PIN and SAML cookie
|
||||
b.cachedPKCS11Mu.Lock()
|
||||
b.cachedPKCS11PIN = nil
|
||||
b.cachedPKCS11Mu.Unlock()
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = nil
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,21 +92,13 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
registry, err := themes.NewRegistry()
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
allThemes, err := registry.List()
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
themeMap := make(map[string]themes.Theme)
|
||||
for _, t := range allThemes {
|
||||
themeMap[t.ID] = t
|
||||
if registry, err := themes.NewRegistry(); err == nil {
|
||||
if allThemes, err := registry.List(); err == nil {
|
||||
for _, t := range allThemes {
|
||||
themeMap[t.ID] = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]ThemeInfo, 0, len(installedIDs))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
@@ -18,3 +20,18 @@ func IsDBusServiceAvailable(busName string) bool {
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
func IsDBusServiceActivatable(busName string) bool {
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
obj := conn.Object("org.freedesktop.DBus", "/org/freedesktop/DBus")
|
||||
var activatable []string
|
||||
if err := obj.Call("org.freedesktop.DBus.ListActivatableNames", 0).Store(&activatable); err != nil {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(activatable, busName)
|
||||
}
|
||||
|
||||
37
core/internal/utils/group.go
Normal file
37
core/internal/utils/group.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func HasGroup(groupName string) bool {
|
||||
return HasGroupIn(groupName, "/etc/group")
|
||||
}
|
||||
|
||||
func HasGroupIn(groupName, path string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return HasGroupData(groupName, string(data))
|
||||
}
|
||||
|
||||
func HasGroupData(groupName, data string) bool {
|
||||
prefix := groupName + ":"
|
||||
for line := range strings.SplitSeq(data, "\n") {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func FindGroupData(data string, candidates ...string) (string, bool) {
|
||||
for _, candidate := range candidates {
|
||||
if HasGroupData(candidate, data) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
142
core/internal/utils/group_test.go
Normal file
142
core/internal/utils/group_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
const testGroupData = `root:x:0:brltty,root
|
||||
sys:x:3:bin,testuser
|
||||
mem:x:8:
|
||||
ftp:x:11:
|
||||
mail:x:12:
|
||||
log:x:19:
|
||||
smmsp:x:25:
|
||||
proc:x:26:
|
||||
games:x:50:
|
||||
lock:x:54:
|
||||
network:x:90:
|
||||
floppy:x:94:
|
||||
scanner:x:96:
|
||||
power:x:98:
|
||||
nobody:x:65534:
|
||||
adm:x:999:daemon
|
||||
wheel:x:998:testuser
|
||||
utmp:x:997:
|
||||
audio:x:996:brltty
|
||||
disk:x:995:
|
||||
input:x:994:brltty,testuser,greeter
|
||||
kmem:x:993:
|
||||
kvm:x:992:libvirt-qemu,qemu,testuser
|
||||
lp:x:991:cups,testuser
|
||||
optical:x:990:
|
||||
render:x:989:
|
||||
sgx:x:988:
|
||||
storage:x:987:
|
||||
tty:x:5:brltty
|
||||
uucp:x:986:brltty
|
||||
video:x:985:cosmic-greeter,greeter,testuser
|
||||
users:x:984:
|
||||
groups:x:983:
|
||||
systemd-journal:x:982:
|
||||
rfkill:x:981:
|
||||
bin:x:1:daemon
|
||||
daemon:x:2:bin
|
||||
http:x:33:
|
||||
dbus:x:81:
|
||||
systemd-coredump:x:980:
|
||||
systemd-network:x:979:
|
||||
systemd-oom:x:978:
|
||||
systemd-journal-remote:x:977:
|
||||
systemd-resolve:x:976:
|
||||
systemd-timesync:x:975:
|
||||
tss:x:974:
|
||||
uuidd:x:973:
|
||||
alpm:x:972:
|
||||
polkitd:x:102:
|
||||
testuser:x:1000:
|
||||
avahi:x:971:
|
||||
git:x:970:
|
||||
nvidia-persistenced:x:143:
|
||||
i2c:x:969:testuser
|
||||
seat:x:968:
|
||||
rtkit:x:133:
|
||||
brlapi:x:967:brltty
|
||||
gdm:x:120:
|
||||
brltty:x:966:
|
||||
colord:x:965:
|
||||
flatpak:x:964:
|
||||
geoclue:x:963:testuser
|
||||
gnome-remote-desktop:x:962:
|
||||
saned:x:961:
|
||||
usbmux:x:140:
|
||||
cosmic-greeter:x:960:
|
||||
greeter:x:959:testuser
|
||||
openvpn:x:958:
|
||||
nm-openvpn:x:957:
|
||||
named:x:40:
|
||||
_talkd:x:956:
|
||||
keyd:x:955:
|
||||
cups:x:209:testuser
|
||||
docker:x:954:testuser
|
||||
mysql:x:953:
|
||||
radicale:x:952:
|
||||
onepassword:x:1001:
|
||||
nixbld:x:951:nixbld01,nixbld02,nixbld03,nixbld04,nixbld05,nixbld06,nixbld07,nixbld08,nixbld09,nixbld10
|
||||
virtlogin:x:940:
|
||||
libvirt:x:939:testuser
|
||||
libvirt-qemu:x:938:
|
||||
qemu:x:937:
|
||||
dnsmasq:x:936:
|
||||
clock:x:935:
|
||||
dms-greeter:x:1002:greeter,testuser
|
||||
pcscd:x:934:
|
||||
test:x:1003:
|
||||
empower:x:933:
|
||||
`
|
||||
|
||||
func TestHasGroupData(t *testing.T) {
|
||||
tests := []struct {
|
||||
group string
|
||||
want bool
|
||||
}{
|
||||
{"greeter", true},
|
||||
{"root", true},
|
||||
{"docker", true},
|
||||
{"cosmic-greeter", true},
|
||||
{"dms-greeter", true},
|
||||
{"nonexistent", false},
|
||||
{"greet", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := HasGroupData(tt.group, testGroupData); got != tt.want {
|
||||
t.Errorf("HasGroupData(%q) = %v, want %v", tt.group, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindGroupData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
candidates []string
|
||||
wantGroup string
|
||||
wantFound bool
|
||||
}{
|
||||
{"first match wins", []string{"greeter", "greetd", "_greeter"}, "greeter", true},
|
||||
{"fallback to second", []string{"greetd", "greeter"}, "greeter", true},
|
||||
{"none found", []string{"_greetd", "greetd"}, "", false},
|
||||
{"single match", []string{"docker"}, "docker", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, found := FindGroupData(testGroupData, tt.candidates...)
|
||||
if got != tt.wantGroup || found != tt.wantFound {
|
||||
t.Errorf("%s: FindGroupData(%v) = (%q, %v), want (%q, %v)",
|
||||
tt.name, tt.candidates, got, found, tt.wantGroup, tt.wantFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasGroupDataEmpty(t *testing.T) {
|
||||
if HasGroupData("greeter", "") {
|
||||
t.Error("expected false for empty data")
|
||||
}
|
||||
}
|
||||
31
core/internal/utils/gsettings.go
Normal file
31
core/internal/utils/gsettings.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func dconfPath(schema, key string) string {
|
||||
return "/" + strings.ReplaceAll(schema, ".", "/") + "/" + key
|
||||
}
|
||||
|
||||
// GsettingsGet reads a gsettings value, falling back to dconf read.
|
||||
func GsettingsGet(schema, key string) (string, error) {
|
||||
if out, err := exec.Command("gsettings", "get", schema, key).Output(); err == nil {
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("gsettings/dconf get failed for %s %s: %w", schema, key, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// GsettingsSet writes a gsettings value, falling back to dconf write.
|
||||
func GsettingsSet(schema, key, value string) error {
|
||||
if err := exec.Command("gsettings", "set", schema, key, value).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
return exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run()
|
||||
}
|
||||
@@ -71,6 +71,9 @@ in
|
||||
"hyprland"
|
||||
"sway"
|
||||
"labwc"
|
||||
"mango"
|
||||
"scroll"
|
||||
"miracle"
|
||||
];
|
||||
description = "Compositor to run greeter in";
|
||||
};
|
||||
|
||||
@@ -50,5 +50,6 @@ in
|
||||
|
||||
services.power-profiles-daemon.enable = lib.mkDefault true;
|
||||
services.accounts-daemon.enable = lib.mkDefault true;
|
||||
security.polkit.enable = lib.mkDefault true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
sonnet
|
||||
qtmultimedia
|
||||
qtimageformats
|
||||
kimageformats
|
||||
];
|
||||
in
|
||||
{
|
||||
@@ -79,7 +80,7 @@
|
||||
inherit version;
|
||||
pname = "dms-shell";
|
||||
src = ./core;
|
||||
vendorHash = "sha256-vsfCgpilOHzJbTaJjJfMK/cSvtyFYJsPDjY4m3iuoFg=";
|
||||
vendorHash = "sha256-cVUJXgzYMRSM0od1xzDVkMTdxHu3OIQX2bQ8AJbGQ1Q=";
|
||||
|
||||
subPackages = [ "cmd/dms" ];
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ This file provides guidance to AI coding assistants.
|
||||
* ALWAYS prefer editing an existing file to creating a new one.
|
||||
* NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
* When you update or modify core context files, also update markdown documentation and memory bank
|
||||
* When asked to commit changes, exclude CLAUDE.md and CLAUDE-*.md referenced memory bank system files from any commits.
|
||||
* When asked to commit changes, exclude AGENTS.md and AGENTS-*.md referenced memory bank system files from any commits.
|
||||
|
||||
## Memory Bank System
|
||||
|
||||
@@ -20,18 +20,18 @@ This project uses a structured memory bank system with specialized context files
|
||||
|
||||
### Core Context Files
|
||||
|
||||
* **CLAUDE-activeContext.md** - Current session state, goals, and progress (if exists)
|
||||
* **CLAUDE-patterns.md** - Established code patterns and conventions (if exists)
|
||||
* **CLAUDE-decisions.md** - Architecture decisions and rationale (if exists)
|
||||
* **CLAUDE-troubleshooting.md** - Common issues and proven solutions (if exists)
|
||||
* **CLAUDE-config-variables.md** - Configuration variables reference (if exists)
|
||||
* **CLAUDE-temp.md** - Temporary scratch pad (only read when referenced)
|
||||
* **AGENTS-activeContext.md** - Current session state, goals, and progress (if exists)
|
||||
* **AGENTS-patterns.md** - Established code patterns and conventions (if exists)
|
||||
* **AGENTS-decisions.md** - Architecture decisions and rationale (if exists)
|
||||
* **AGENTS-troubleshooting.md** - Common issues and proven solutions (if exists)
|
||||
* **AGENTS-config-variables.md** - Configuration variables reference (if exists)
|
||||
* **AGENTS-temp.md** - Temporary scratch pad (only read when referenced)
|
||||
|
||||
**Important:** Always reference the active context file first to understand what's currently being worked on and maintain session continuity.
|
||||
|
||||
### Memory Bank System Backups
|
||||
|
||||
When asked to backup Memory Bank System files, you will copy the core context files above and @.claude settings directory to directory @/path/to/backup-directory. If files already exist in the backup directory, you will overwrite them.
|
||||
When asked to backup Memory Bank System files, you will copy the core context files above and @.agents settings directory to directory @/path/to/backup-directory. If files already exist in the backup directory, you will overwrite them.
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Singleton {
|
||||
id: root
|
||||
|
||||
property var appUsageRanking: {}
|
||||
property bool _saving: false
|
||||
|
||||
Component.onCompleted: {
|
||||
loadSettings();
|
||||
@@ -59,7 +60,9 @@ Singleton {
|
||||
}
|
||||
|
||||
appUsageRanking = currentRanking;
|
||||
_saving = true;
|
||||
saveSettings();
|
||||
_saving = false;
|
||||
}
|
||||
|
||||
function getRankedApps() {
|
||||
@@ -97,7 +100,9 @@ Singleton {
|
||||
|
||||
if (hasChanges) {
|
||||
appUsageRanking = currentRanking;
|
||||
_saving = true;
|
||||
saveSettings();
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +114,8 @@ Singleton {
|
||||
blockWrites: true
|
||||
watchChanges: true
|
||||
onLoaded: {
|
||||
if (root._saving)
|
||||
return;
|
||||
parseSettings(settingsFile.text());
|
||||
}
|
||||
onLoadFailed: error => {}
|
||||
|
||||
@@ -178,6 +178,33 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function loadLauncherCache() {
|
||||
try {
|
||||
var content = launcherCacheFile.text();
|
||||
if (content && content.trim())
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
console.warn("CacheData: Failed to parse launcher cache:", e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveLauncherCache(sections) {
|
||||
if (_loading)
|
||||
return;
|
||||
launcherCacheFile.setText(JSON.stringify(sections));
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: launcherCacheFile
|
||||
|
||||
path: isGreeterMode ? "" : _stateDir + "/DankMaterialShell/launcher_cache.json"
|
||||
blockLoading: true
|
||||
blockWrites: true
|
||||
atomicWrites: true
|
||||
watchChanges: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: cacheFile
|
||||
|
||||
|
||||
9
quickshell/Common/DankAnim.qml
Normal file
9
quickshell/Common/DankAnim.qml
Normal file
@@ -0,0 +1,9 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
// Reusable NumberAnimation wrapper
|
||||
NumberAnimation {
|
||||
duration: Theme.expressiveDurations.normal
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Theme.expressiveCurves.standard
|
||||
}
|
||||
9
quickshell/Common/DankColorAnim.qml
Normal file
9
quickshell/Common/DankColorAnim.qml
Normal file
@@ -0,0 +1,9 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
// Reusable ColorAnimation wrapper
|
||||
ColorAnimation {
|
||||
duration: Theme.expressiveDurations.normal
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Theme.expressiveCurves.standard
|
||||
}
|
||||
@@ -57,6 +57,8 @@ const DMS_ACTIONS = [
|
||||
{ id: "spawn dms ipc call audio decrement 1", label: "Volume Down (1%)" },
|
||||
{ id: "spawn dms ipc call audio decrement 5", label: "Volume Down (5%)" },
|
||||
{ id: "spawn dms ipc call audio decrement 10", label: "Volume Down (10%)" },
|
||||
{ id: "spawn dms ipc call mpris increment 5", label: "Player Volume Up (5%)" },
|
||||
{ id: "spawn dms ipc call mpris decrement 5", label: "Player Volume Down (5%)" },
|
||||
{ id: "spawn dms ipc call audio mute", label: "Volume Mute Toggle" },
|
||||
{ id: "spawn dms ipc call audio micmute", label: "Microphone Mute Toggle" },
|
||||
{ id: "spawn dms ipc call audio cycleoutput", label: "Audio Output: Cycle" },
|
||||
@@ -121,7 +123,8 @@ const NIRI_ACTIONS = {
|
||||
{ id: "expand-column-to-available-width", label: "Expand to Available Width" },
|
||||
{ id: "consume-or-expel-window-left", label: "Consume/Expel Left" },
|
||||
{ id: "consume-or-expel-window-right", label: "Consume/Expel Right" },
|
||||
{ id: "toggle-column-tabbed-display", label: "Toggle Tabbed" }
|
||||
{ id: "toggle-column-tabbed-display", label: "Toggle Tabbed" },
|
||||
{ id: "toggle-window-rule-opacity", label: "Toggle Window Opacity" }
|
||||
],
|
||||
"Focus": [
|
||||
{ id: "focus-column-left", label: "Focus Left" },
|
||||
@@ -168,6 +171,7 @@ const NIRI_ACTIONS = {
|
||||
"System": [
|
||||
{ id: "toggle-overview", label: "Toggle Overview" },
|
||||
{ id: "show-hotkey-overlay", label: "Show Hotkey Overlay" },
|
||||
{ id: "do-screen-transition", label: "Screen Transition" },
|
||||
{ id: "power-off-monitors", label: "Power Off Monitors" },
|
||||
{ id: "power-on-monitors", label: "Power On Monitors" },
|
||||
{ id: "toggle-keyboard-shortcuts-inhibit", label: "Toggle Shortcuts Inhibit" },
|
||||
@@ -420,6 +424,12 @@ const COMPOSITOR_ACTIONS = {
|
||||
const CATEGORY_ORDER = ["DMS", "Execute", "Workspace", "Tags", "Window", "Move/Resize", "Focus", "Move", "Layout", "Groups", "Monitor", "Scratchpad", "Screenshot", "System", "Pass-through", "Overview", "Alt-Tab", "Other"];
|
||||
|
||||
const NIRI_ACTION_ARGS = {
|
||||
"quit": {
|
||||
args: [{ name: "skip-confirmation", type: "bool", label: "Skip confirmation" }]
|
||||
},
|
||||
"do-screen-transition": {
|
||||
args: [{ name: "delay-ms", type: "number", label: "Delay (ms)", placeholder: "250" }]
|
||||
},
|
||||
"set-column-width": {
|
||||
args: [{ name: "value", type: "text", label: "Width", placeholder: "+10%, -10%, 50%" }]
|
||||
},
|
||||
@@ -711,6 +721,14 @@ const DMS_ACTION_ARGS = {
|
||||
base: "spawn dms ipc call audio decrement",
|
||||
args: [{ name: "amount", type: "number", label: "Amount %", placeholder: "5", default: "5" }]
|
||||
},
|
||||
"player increment": {
|
||||
base: "spawn dms ipc call mpris increment",
|
||||
args: [{ name: "amount", type: "number", label: "Amount %", placeholder: "5", default: "5" }]
|
||||
},
|
||||
"player decrement": {
|
||||
base: "spawn dms ipc call mpris decrement",
|
||||
args: [{ name: "amount", type: "number", label: "Amount %", placeholder: "5", default: "5" }]
|
||||
},
|
||||
"brightness increment": {
|
||||
base: "spawn dms ipc call brightness increment",
|
||||
args: [
|
||||
@@ -756,14 +774,14 @@ function getDmsActions(isNiri, isHyprland) {
|
||||
continue;
|
||||
}
|
||||
switch (action.compositor) {
|
||||
case "niri":
|
||||
if (isNiri)
|
||||
result.push(action);
|
||||
break;
|
||||
case "hyprland":
|
||||
if (isHyprland)
|
||||
result.push(action);
|
||||
break;
|
||||
case "niri":
|
||||
if (isNiri)
|
||||
result.push(action);
|
||||
break;
|
||||
case "hyprland":
|
||||
if (isHyprland)
|
||||
result.push(action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -856,13 +874,13 @@ function isValidAction(action) {
|
||||
if (!action)
|
||||
return false;
|
||||
switch (action) {
|
||||
case "spawn":
|
||||
case "spawn ":
|
||||
case "spawn sh -c \"\"":
|
||||
case "spawn sh -c ''":
|
||||
case "spawn_shell":
|
||||
case "spawn_shell ":
|
||||
return false;
|
||||
case "spawn":
|
||||
case "spawn ":
|
||||
case "spawn sh -c \"\"":
|
||||
case "spawn sh -c ''":
|
||||
case "spawn_shell":
|
||||
case "spawn_shell ":
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -882,7 +900,7 @@ function buildSpawnAction(command, args) {
|
||||
return "";
|
||||
let parts = [command];
|
||||
if (args && args.length > 0)
|
||||
parts = parts.concat(args.filter(function(a) { return a; }));
|
||||
parts = parts.concat(args.filter(function (a) { return a; }));
|
||||
return "spawn " + parts.join(" ");
|
||||
}
|
||||
|
||||
@@ -899,7 +917,7 @@ function parseSpawnCommand(action) {
|
||||
if (!action || !action.startsWith("spawn "))
|
||||
return { command: "", args: [] };
|
||||
const rest = action.slice(6);
|
||||
const parts = rest.split(" ").filter(function(p) { return p; });
|
||||
const parts = rest.split(" ").filter(function (p) { return p; });
|
||||
return {
|
||||
command: parts[0] || "",
|
||||
args: parts.slice(1)
|
||||
@@ -961,130 +979,138 @@ function parseCompositorActionArgs(compositor, action) {
|
||||
var argParts = parts.slice(1);
|
||||
|
||||
switch (compositor) {
|
||||
case "niri":
|
||||
switch (base) {
|
||||
case "move-column-to-workspace":
|
||||
for (var i = 0; i < argParts.length; i++) {
|
||||
if (argParts[i] === "focus=true" || argParts[i] === "focus=false") {
|
||||
args.focus = argParts[i] === "focus=true";
|
||||
} else if (!args.index) {
|
||||
args.index = argParts[i];
|
||||
case "niri":
|
||||
switch (base) {
|
||||
case "move-column-to-workspace":
|
||||
for (var i = 0; i < argParts.length; i++) {
|
||||
if (argParts[i] === "focus=true" || argParts[i] === "focus=false") {
|
||||
args.focus = argParts[i] === "focus=true";
|
||||
} else if (!args.index) {
|
||||
args.index = argParts[i];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "move-column-to-workspace-down":
|
||||
case "move-column-to-workspace-up":
|
||||
for (var k = 0; k < argParts.length; k++) {
|
||||
if (argParts[k] === "focus=true" || argParts[k] === "focus=false")
|
||||
args.focus = argParts[k] === "focus=true";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
for (var j = 0; j < argParts.length; j++) {
|
||||
var kv = argParts[j].split("=");
|
||||
if (kv.length === 2) {
|
||||
switch (kv[1]) {
|
||||
case "true":
|
||||
args[kv[0]] = true;
|
||||
break;
|
||||
case "false":
|
||||
args[kv[0]] = false;
|
||||
break;
|
||||
default:
|
||||
args[kv[0]] = kv[1];
|
||||
}
|
||||
} else {
|
||||
args.value = args.value ? (args.value + " " + argParts[j]) : argParts[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "mangowc":
|
||||
if (argConfig.args && argConfig.args.length > 0 && argParts.length > 0) {
|
||||
var paramStr = argParts.join(" ");
|
||||
var paramValues = paramStr.split(",");
|
||||
for (var m = 0; m < argConfig.args.length && m < paramValues.length; m++) {
|
||||
args[argConfig.args[m].name] = paramValues[m];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "move-column-to-workspace-down":
|
||||
case "move-column-to-workspace-up":
|
||||
for (var k = 0; k < argParts.length; k++) {
|
||||
if (argParts[k] === "focus=true" || argParts[k] === "focus=false")
|
||||
args.focus = argParts[k] === "focus=true";
|
||||
case "hyprland":
|
||||
if (argConfig.args && argConfig.args.length > 0) {
|
||||
switch (base) {
|
||||
case "resizewindowpixel":
|
||||
case "movewindowpixel":
|
||||
var commaIdx = argParts.join(" ").indexOf(",");
|
||||
if (commaIdx !== -1) {
|
||||
var fullStr = argParts.join(" ");
|
||||
args[argConfig.args[0].name] = fullStr.substring(0, commaIdx);
|
||||
args[argConfig.args[1].name] = fullStr.substring(commaIdx + 1);
|
||||
} else if (argParts.length > 0) {
|
||||
args[argConfig.args[0].name] = argParts.join(" ");
|
||||
}
|
||||
break;
|
||||
case "movetoworkspace":
|
||||
case "movetoworkspacesilent":
|
||||
case "tagwindow":
|
||||
case "alterzorder":
|
||||
if (argParts.length >= 2) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
args[argConfig.args[1].name] = argParts.slice(1).join(" ");
|
||||
} else if (argParts.length === 1) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
}
|
||||
break;
|
||||
case "moveworkspacetomonitor":
|
||||
case "swapactiveworkspaces":
|
||||
case "renameworkspace":
|
||||
case "fullscreenstate":
|
||||
case "movecursor":
|
||||
if (argParts.length >= 2) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
args[argConfig.args[1].name] = argParts[1];
|
||||
} else if (argParts.length === 1) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
}
|
||||
break;
|
||||
case "setprop":
|
||||
if (argParts.length >= 3) {
|
||||
args.window = argParts[0];
|
||||
args.property = argParts[1];
|
||||
args.value = argParts.slice(2).join(" ");
|
||||
} else if (argParts.length === 2) {
|
||||
args.window = argParts[0];
|
||||
args.property = argParts[1];
|
||||
}
|
||||
break;
|
||||
case "sendshortcut":
|
||||
if (argParts.length >= 3) {
|
||||
args.mod = argParts[0];
|
||||
args.key = argParts[1];
|
||||
args.window = argParts.slice(2).join(" ");
|
||||
} else if (argParts.length >= 2) {
|
||||
args.mod = argParts[0];
|
||||
args.key = argParts[1];
|
||||
}
|
||||
break;
|
||||
case "sendkeystate":
|
||||
if (argParts.length >= 4) {
|
||||
args.mod = argParts[0];
|
||||
args.key = argParts[1];
|
||||
args.state = argParts[2];
|
||||
args.window = argParts.slice(3).join(" ");
|
||||
}
|
||||
break;
|
||||
case "signalwindow":
|
||||
if (argParts.length >= 2) {
|
||||
args.window = argParts[0];
|
||||
args.signal = argParts[1];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (argParts.length > 0) {
|
||||
if (argConfig.args.length === 1) {
|
||||
args[argConfig.args[0].name] = argParts.join(" ");
|
||||
} else {
|
||||
args.value = argParts.join(" ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (base.startsWith("screenshot")) {
|
||||
for (var j = 0; j < argParts.length; j++) {
|
||||
var kv = argParts[j].split("=");
|
||||
if (kv.length === 2)
|
||||
args[kv[0]] = kv[1] === "true";
|
||||
}
|
||||
} else if (argParts.length > 0) {
|
||||
if (argParts.length > 0)
|
||||
args.value = argParts.join(" ");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "mangowc":
|
||||
if (argConfig.args && argConfig.args.length > 0 && argParts.length > 0) {
|
||||
var paramStr = argParts.join(" ");
|
||||
var paramValues = paramStr.split(",");
|
||||
for (var m = 0; m < argConfig.args.length && m < paramValues.length; m++) {
|
||||
args[argConfig.args[m].name] = paramValues[m];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "hyprland":
|
||||
if (argConfig.args && argConfig.args.length > 0) {
|
||||
switch (base) {
|
||||
case "resizewindowpixel":
|
||||
case "movewindowpixel":
|
||||
var commaIdx = argParts.join(" ").indexOf(",");
|
||||
if (commaIdx !== -1) {
|
||||
var fullStr = argParts.join(" ");
|
||||
args[argConfig.args[0].name] = fullStr.substring(0, commaIdx);
|
||||
args[argConfig.args[1].name] = fullStr.substring(commaIdx + 1);
|
||||
} else if (argParts.length > 0) {
|
||||
args[argConfig.args[0].name] = argParts.join(" ");
|
||||
}
|
||||
break;
|
||||
case "movetoworkspace":
|
||||
case "movetoworkspacesilent":
|
||||
case "tagwindow":
|
||||
case "alterzorder":
|
||||
if (argParts.length >= 2) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
args[argConfig.args[1].name] = argParts.slice(1).join(" ");
|
||||
} else if (argParts.length === 1) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
}
|
||||
break;
|
||||
case "moveworkspacetomonitor":
|
||||
case "swapactiveworkspaces":
|
||||
case "renameworkspace":
|
||||
case "fullscreenstate":
|
||||
case "movecursor":
|
||||
if (argParts.length >= 2) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
args[argConfig.args[1].name] = argParts[1];
|
||||
} else if (argParts.length === 1) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
}
|
||||
break;
|
||||
case "setprop":
|
||||
if (argParts.length >= 3) {
|
||||
args.window = argParts[0];
|
||||
args.property = argParts[1];
|
||||
args.value = argParts.slice(2).join(" ");
|
||||
} else if (argParts.length === 2) {
|
||||
args.window = argParts[0];
|
||||
args.property = argParts[1];
|
||||
}
|
||||
break;
|
||||
case "sendshortcut":
|
||||
if (argParts.length >= 3) {
|
||||
args.mod = argParts[0];
|
||||
args.key = argParts[1];
|
||||
args.window = argParts.slice(2).join(" ");
|
||||
} else if (argParts.length >= 2) {
|
||||
args.mod = argParts[0];
|
||||
args.key = argParts[1];
|
||||
}
|
||||
break;
|
||||
case "sendkeystate":
|
||||
if (argParts.length >= 4) {
|
||||
args.mod = argParts[0];
|
||||
args.key = argParts[1];
|
||||
args.state = argParts[2];
|
||||
args.window = argParts.slice(3).join(" ");
|
||||
}
|
||||
break;
|
||||
case "signalwindow":
|
||||
if (argParts.length >= 2) {
|
||||
args.window = argParts[0];
|
||||
args.signal = argParts[1];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (argParts.length > 0) {
|
||||
if (argConfig.args.length === 1) {
|
||||
args[argConfig.args[0].name] = argParts.join(" ");
|
||||
} else {
|
||||
args.value = argParts.join(" ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (argParts.length > 0)
|
||||
args.value = argParts.join(" ");
|
||||
}
|
||||
|
||||
return { base: base, args: args };
|
||||
@@ -1100,125 +1126,118 @@ function buildCompositorAction(compositor, base, args) {
|
||||
return base;
|
||||
|
||||
switch (compositor) {
|
||||
case "niri":
|
||||
switch (base) {
|
||||
case "move-column-to-workspace":
|
||||
if (args.index)
|
||||
parts.push(args.index);
|
||||
if (args.focus === false)
|
||||
parts.push("focus=false");
|
||||
case "niri":
|
||||
switch (base) {
|
||||
case "move-column-to-workspace":
|
||||
if (args.index)
|
||||
parts.push(args.index);
|
||||
if (args.focus === false)
|
||||
parts.push("focus=false");
|
||||
break;
|
||||
case "move-column-to-workspace-down":
|
||||
case "move-column-to-workspace-up":
|
||||
if (args.focus === false)
|
||||
parts.push("focus=false");
|
||||
break;
|
||||
default:
|
||||
if (args.value)
|
||||
parts.push(args.value);
|
||||
else if (args.index)
|
||||
parts.push(args.index);
|
||||
for (var prop in args) {
|
||||
switch (prop) {
|
||||
case "value":
|
||||
case "index":
|
||||
continue;
|
||||
}
|
||||
var val = args[prop];
|
||||
if (val === true)
|
||||
parts.push(prop + "=true");
|
||||
else if (val === false)
|
||||
parts.push(prop + "=false");
|
||||
else if (val !== undefined && val !== null && val !== "")
|
||||
parts.push(prop + "=" + val);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "move-column-to-workspace-down":
|
||||
case "move-column-to-workspace-up":
|
||||
if (args.focus === false)
|
||||
parts.push("focus=false");
|
||||
case "mangowc":
|
||||
var compositorArgs = ACTION_ARGS.mangowc;
|
||||
if (compositorArgs && compositorArgs[base] && compositorArgs[base].args) {
|
||||
var argConfig = compositorArgs[base].args;
|
||||
var argValues = [];
|
||||
for (var i = 0; i < argConfig.length; i++) {
|
||||
var argDef = argConfig[i];
|
||||
var val = args[argDef.name];
|
||||
if (val === undefined || val === "")
|
||||
val = argDef.default || "";
|
||||
if (val === "" && argValues.length === 0)
|
||||
continue;
|
||||
argValues.push(val);
|
||||
}
|
||||
if (argValues.length > 0)
|
||||
parts.push(argValues.join(","));
|
||||
} else if (args.value) {
|
||||
parts.push(args.value);
|
||||
}
|
||||
break;
|
||||
case "hyprland":
|
||||
var hyprArgs = ACTION_ARGS.hyprland;
|
||||
if (hyprArgs && hyprArgs[base] && hyprArgs[base].args) {
|
||||
var hyprConfig = hyprArgs[base].args;
|
||||
switch (base) {
|
||||
case "resizewindowpixel":
|
||||
case "movewindowpixel":
|
||||
if (args[hyprConfig[0].name])
|
||||
parts.push(args[hyprConfig[0].name]);
|
||||
if (args[hyprConfig[1].name])
|
||||
parts[parts.length - 1] += "," + args[hyprConfig[1].name];
|
||||
break;
|
||||
case "setprop":
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
if (args.property)
|
||||
parts.push(args.property);
|
||||
if (args.value)
|
||||
parts.push(args.value);
|
||||
break;
|
||||
case "sendshortcut":
|
||||
if (args.mod)
|
||||
parts.push(args.mod);
|
||||
if (args.key)
|
||||
parts.push(args.key);
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
break;
|
||||
case "sendkeystate":
|
||||
if (args.mod)
|
||||
parts.push(args.mod);
|
||||
if (args.key)
|
||||
parts.push(args.key);
|
||||
if (args.state)
|
||||
parts.push(args.state);
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
break;
|
||||
case "signalwindow":
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
if (args.signal)
|
||||
parts.push(args.signal);
|
||||
break;
|
||||
default:
|
||||
for (var j = 0; j < hyprConfig.length; j++) {
|
||||
var hVal = args[hyprConfig[j].name];
|
||||
if (hVal !== undefined && hVal !== "")
|
||||
parts.push(hVal);
|
||||
}
|
||||
}
|
||||
} else if (args.value) {
|
||||
parts.push(args.value);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
switch (base) {
|
||||
case "screenshot":
|
||||
if (args["show-pointer"] === true)
|
||||
parts.push("show-pointer=true");
|
||||
else if (args["show-pointer"] === false)
|
||||
parts.push("show-pointer=false");
|
||||
break;
|
||||
case "screenshot-screen":
|
||||
if (args["show-pointer"] === true)
|
||||
parts.push("show-pointer=true");
|
||||
else if (args["show-pointer"] === false)
|
||||
parts.push("show-pointer=false");
|
||||
if (args["write-to-disk"] === true)
|
||||
parts.push("write-to-disk=true");
|
||||
break;
|
||||
case "screenshot-window":
|
||||
if (args["write-to-disk"] === true)
|
||||
parts.push("write-to-disk=true");
|
||||
break;
|
||||
}
|
||||
if (args.value) {
|
||||
if (args.value)
|
||||
parts.push(args.value);
|
||||
} else if (args.index) {
|
||||
parts.push(args.index);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "mangowc":
|
||||
var compositorArgs = ACTION_ARGS.mangowc;
|
||||
if (compositorArgs && compositorArgs[base] && compositorArgs[base].args) {
|
||||
var argConfig = compositorArgs[base].args;
|
||||
var argValues = [];
|
||||
for (var i = 0; i < argConfig.length; i++) {
|
||||
var argDef = argConfig[i];
|
||||
var val = args[argDef.name];
|
||||
if (val === undefined || val === "")
|
||||
val = argDef.default || "";
|
||||
if (val === "" && argValues.length === 0)
|
||||
continue;
|
||||
argValues.push(val);
|
||||
}
|
||||
if (argValues.length > 0)
|
||||
parts.push(argValues.join(","));
|
||||
} else if (args.value) {
|
||||
parts.push(args.value);
|
||||
}
|
||||
break;
|
||||
case "hyprland":
|
||||
var hyprArgs = ACTION_ARGS.hyprland;
|
||||
if (hyprArgs && hyprArgs[base] && hyprArgs[base].args) {
|
||||
var hyprConfig = hyprArgs[base].args;
|
||||
switch (base) {
|
||||
case "resizewindowpixel":
|
||||
case "movewindowpixel":
|
||||
if (args[hyprConfig[0].name])
|
||||
parts.push(args[hyprConfig[0].name]);
|
||||
if (args[hyprConfig[1].name])
|
||||
parts[parts.length - 1] += "," + args[hyprConfig[1].name];
|
||||
break;
|
||||
case "setprop":
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
if (args.property)
|
||||
parts.push(args.property);
|
||||
if (args.value)
|
||||
parts.push(args.value);
|
||||
break;
|
||||
case "sendshortcut":
|
||||
if (args.mod)
|
||||
parts.push(args.mod);
|
||||
if (args.key)
|
||||
parts.push(args.key);
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
break;
|
||||
case "sendkeystate":
|
||||
if (args.mod)
|
||||
parts.push(args.mod);
|
||||
if (args.key)
|
||||
parts.push(args.key);
|
||||
if (args.state)
|
||||
parts.push(args.state);
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
break;
|
||||
case "signalwindow":
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
if (args.signal)
|
||||
parts.push(args.signal);
|
||||
break;
|
||||
default:
|
||||
for (var j = 0; j < hyprConfig.length; j++) {
|
||||
var hVal = args[hyprConfig[j].name];
|
||||
if (hVal !== undefined && hVal !== "")
|
||||
parts.push(hVal);
|
||||
}
|
||||
}
|
||||
} else if (args.value) {
|
||||
parts.push(args.value);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (args.value)
|
||||
parts.push(args.value);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
@@ -1246,22 +1265,22 @@ function parseDmsActionArgs(action) {
|
||||
for (var i = 0; i < rest.length; i++) {
|
||||
var c = rest[i];
|
||||
switch (c) {
|
||||
case '"':
|
||||
inQuotes = !inQuotes;
|
||||
hadQuotes = true;
|
||||
break;
|
||||
case ' ':
|
||||
if (inQuotes) {
|
||||
case '"':
|
||||
inQuotes = !inQuotes;
|
||||
hadQuotes = true;
|
||||
break;
|
||||
case ' ':
|
||||
if (inQuotes) {
|
||||
current += c;
|
||||
} else if (current || hadQuotes) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
hadQuotes = false;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
current += c;
|
||||
} else if (current || hadQuotes) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
hadQuotes = false;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
current += c;
|
||||
break;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (current || hadQuotes)
|
||||
|
||||
46
quickshell/Common/ListViewTransitions.qml
Normal file
46
quickshell/Common/ListViewTransitions.qml
Normal file
@@ -0,0 +1,46 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
|
||||
// Reusable ListView/GridView transitions
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property Transition add: Transition {
|
||||
DankAnim {
|
||||
property: "opacity"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: Theme.expressiveDurations.expressiveEffects
|
||||
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
||||
}
|
||||
}
|
||||
|
||||
readonly property Transition remove: Transition {
|
||||
DankAnim {
|
||||
property: "opacity"
|
||||
to: 0
|
||||
duration: Theme.expressiveDurations.fast
|
||||
easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel
|
||||
}
|
||||
}
|
||||
|
||||
readonly property Transition displaced: Transition {
|
||||
DankAnim {
|
||||
property: "y"
|
||||
duration: Theme.expressiveDurations.normal
|
||||
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
||||
}
|
||||
}
|
||||
|
||||
readonly property Transition move: Transition {
|
||||
DankAnim {
|
||||
property: "y"
|
||||
duration: Theme.expressiveDurations.normal
|
||||
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,20 @@ Singleton {
|
||||
|
||||
property var currentOSDsByScreen: ({})
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
const activeNames = {};
|
||||
for (let i = 0; i < Quickshell.screens.length; i++)
|
||||
activeNames[Quickshell.screens[i].name] = true;
|
||||
for (const screenName in osdManager.currentOSDsByScreen) {
|
||||
if (activeNames[screenName])
|
||||
continue;
|
||||
osdManager.currentOSDsByScreen[screenName] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showOSD(osd) {
|
||||
if (!osd || !osd.screen)
|
||||
return;
|
||||
|
||||
@@ -80,7 +80,10 @@ Singleton {
|
||||
return Quickshell.iconPath(moddedId, true);
|
||||
}
|
||||
|
||||
return desktopEntry && desktopEntry.icon ? Quickshell.iconPath(desktopEntry.icon, true) : "";
|
||||
if (desktopEntry && desktopEntry.icon) {
|
||||
return Quickshell.iconPath(desktopEntry.icon, true);
|
||||
}
|
||||
return Quickshell.iconPath(appId, true);
|
||||
}
|
||||
|
||||
function getAppName(appId: string, desktopEntry: var): string {
|
||||
|
||||
@@ -58,6 +58,7 @@ Singleton {
|
||||
property string wallpaperPathDark: ""
|
||||
property var monitorWallpapersLight: ({})
|
||||
property var monitorWallpapersDark: ({})
|
||||
property var monitorWallpaperFillModes: ({})
|
||||
property string wallpaperTransition: "fade"
|
||||
readonly property var availableWallpaperTransitions: ["none", "fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"]
|
||||
property var includedTransitions: availableWallpaperTransitions.filter(t => t !== "none")
|
||||
@@ -121,6 +122,10 @@ Singleton {
|
||||
|
||||
property string vpnLastConnected: ""
|
||||
|
||||
property var deviceMaxVolumes: ({})
|
||||
property var hiddenOutputDeviceNames: []
|
||||
property var hiddenInputDeviceNames: []
|
||||
|
||||
Component.onCompleted: {
|
||||
if (!isGreeterMode) {
|
||||
loadSettings();
|
||||
@@ -1052,6 +1057,49 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setDeviceMaxVolume(nodeName, maxPercent) {
|
||||
if (!nodeName)
|
||||
return;
|
||||
const updated = Object.assign({}, deviceMaxVolumes);
|
||||
const clamped = Math.max(100, Math.min(200, Math.round(maxPercent)));
|
||||
if (clamped === 100) {
|
||||
delete updated[nodeName];
|
||||
} else {
|
||||
updated[nodeName] = clamped;
|
||||
}
|
||||
deviceMaxVolumes = updated;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setHiddenOutputDeviceNames(deviceNames) {
|
||||
if (!Array.isArray(deviceNames))
|
||||
return;
|
||||
hiddenOutputDeviceNames = deviceNames;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setHiddenInputDeviceNames(deviceNames) {
|
||||
if (!Array.isArray(deviceNames))
|
||||
return;
|
||||
hiddenInputDeviceNames = deviceNames;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getDeviceMaxVolume(nodeName) {
|
||||
if (!nodeName)
|
||||
return 100;
|
||||
return deviceMaxVolumes[nodeName] ?? 100;
|
||||
}
|
||||
|
||||
function removeDeviceMaxVolume(nodeName) {
|
||||
if (!nodeName)
|
||||
return;
|
||||
const updated = Object.assign({}, deviceMaxVolumes);
|
||||
delete updated[nodeName];
|
||||
deviceMaxVolumes = updated;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function syncWallpaperForCurrentMode() {
|
||||
if (!perModeWallpaper)
|
||||
return;
|
||||
@@ -1063,11 +1111,7 @@ Singleton {
|
||||
wallpaperPath = isLightMode ? wallpaperPathLight : wallpaperPathDark;
|
||||
}
|
||||
|
||||
function getMonitorWallpaper(screenName) {
|
||||
if (!perMonitorWallpaper) {
|
||||
return wallpaperPath;
|
||||
}
|
||||
|
||||
function _findMonitorValue(map, screenName) {
|
||||
var screen = null;
|
||||
var screens = Quickshell.screens;
|
||||
for (var i = 0; i < screens.length; i++) {
|
||||
@@ -1077,52 +1121,72 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
if (!screen) {
|
||||
return monitorWallpapers[screenName] || wallpaperPath;
|
||||
if (!screen)
|
||||
return map[screenName];
|
||||
|
||||
if (map[screen.name] !== undefined)
|
||||
return map[screen.name];
|
||||
if (screen.model && map[screen.model] !== undefined)
|
||||
return map[screen.model];
|
||||
if (typeof SettingsData !== "undefined") {
|
||||
var displayName = SettingsData.getScreenDisplayName(screen);
|
||||
if (displayName && map[displayName] !== undefined)
|
||||
return map[displayName];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMonitorWallpaper(screenName) {
|
||||
if (!perMonitorWallpaper)
|
||||
return wallpaperPath;
|
||||
var value = _findMonitorValue(monitorWallpapers, screenName);
|
||||
return value !== undefined ? value : wallpaperPath;
|
||||
}
|
||||
|
||||
function getMonitorWallpaperFillMode(screenName) {
|
||||
var globalFillMode = (typeof SettingsData !== "undefined") ? SettingsData.wallpaperFillMode : "Fill";
|
||||
if (!perMonitorWallpaper)
|
||||
return globalFillMode;
|
||||
var value = _findMonitorValue(monitorWallpaperFillModes, screenName);
|
||||
return value !== undefined ? value : globalFillMode;
|
||||
}
|
||||
|
||||
function setMonitorWallpaperFillMode(screenName, mode) {
|
||||
var screen = null;
|
||||
var screens = Quickshell.screens;
|
||||
for (var i = 0; i < screens.length; i++) {
|
||||
if (screens[i].name === screenName) {
|
||||
screen = screens[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (monitorWallpapers[screen.name]) {
|
||||
return monitorWallpapers[screen.name];
|
||||
}
|
||||
if (screen.model && monitorWallpapers[screen.model]) {
|
||||
return monitorWallpapers[screen.model];
|
||||
if (!screen)
|
||||
return;
|
||||
|
||||
var identifier = typeof SettingsData !== "undefined" ? SettingsData.getScreenDisplayName(screen) : screen.name;
|
||||
|
||||
var newModes = {};
|
||||
for (var key in monitorWallpaperFillModes) {
|
||||
var isThisScreen = key === screen.name || (screen.model && key === screen.model);
|
||||
if (!isThisScreen)
|
||||
newModes[key] = monitorWallpaperFillModes[key];
|
||||
}
|
||||
|
||||
return wallpaperPath;
|
||||
newModes[identifier] = mode;
|
||||
monitorWallpaperFillModes = newModes;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getMonitorCyclingSettings(screenName) {
|
||||
var screen = null;
|
||||
var screens = Quickshell.screens;
|
||||
for (var i = 0; i < screens.length; i++) {
|
||||
if (screens[i].name === screenName) {
|
||||
screen = screens[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!screen) {
|
||||
return monitorCyclingSettings[screenName] || {
|
||||
"enabled": false,
|
||||
"mode": "interval",
|
||||
"interval": 300,
|
||||
"time": "06:00"
|
||||
};
|
||||
}
|
||||
|
||||
if (monitorCyclingSettings[screen.name]) {
|
||||
return monitorCyclingSettings[screen.name];
|
||||
}
|
||||
if (screen.model && monitorCyclingSettings[screen.model]) {
|
||||
return monitorCyclingSettings[screen.model];
|
||||
}
|
||||
|
||||
return {
|
||||
var defaults = {
|
||||
"enabled": false,
|
||||
"mode": "interval",
|
||||
"interval": 300,
|
||||
"time": "06:00"
|
||||
};
|
||||
var value = _findMonitorValue(monitorCyclingSettings, screenName);
|
||||
return value !== undefined ? value : defaults;
|
||||
}
|
||||
|
||||
FileView {
|
||||
|
||||
@@ -79,6 +79,8 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
property bool clipboardEnterToPaste: false
|
||||
|
||||
property var launcherPluginVisibility: ({})
|
||||
|
||||
function getPluginAllowWithoutTrigger(pluginId) {
|
||||
@@ -134,6 +136,7 @@ Singleton {
|
||||
property string widgetBackgroundColor: "sch"
|
||||
property string widgetColorMode: "default"
|
||||
property string controlCenterTileColorMode: "primary"
|
||||
property string buttonColorMode: "primary"
|
||||
property real cornerRadius: 12
|
||||
property int niriLayoutGapsOverride: -1
|
||||
property int niriLayoutRadiusOverride: -1
|
||||
@@ -153,6 +156,14 @@ Singleton {
|
||||
property bool nightModeEnabled: false
|
||||
property int animationSpeed: SettingsData.AnimationSpeed.Short
|
||||
property int customAnimationDuration: 500
|
||||
property bool syncComponentAnimationSpeeds: true
|
||||
onSyncComponentAnimationSpeedsChanged: saveSettings()
|
||||
property int popoutAnimationSpeed: SettingsData.AnimationSpeed.Short
|
||||
property int popoutCustomAnimationDuration: 150
|
||||
property int modalAnimationSpeed: SettingsData.AnimationSpeed.Short
|
||||
property int modalCustomAnimationDuration: 150
|
||||
property bool enableRippleEffects: true
|
||||
onEnableRippleEffectsChanged: saveSettings()
|
||||
property string wallpaperFillMode: "Fill"
|
||||
property bool blurredWallpaperLayer: false
|
||||
property bool blurWallpaperOnOverview: false
|
||||
@@ -241,6 +252,7 @@ Singleton {
|
||||
property bool showWorkspacePadding: false
|
||||
property bool workspaceScrolling: false
|
||||
property bool showWorkspaceApps: false
|
||||
property bool workspaceDragReorder: true
|
||||
property bool groupWorkspaceApps: true
|
||||
property int maxWorkspaceIcons: 3
|
||||
property int workspaceAppIconSizeOffset: 0
|
||||
@@ -260,6 +272,7 @@ Singleton {
|
||||
property bool scrollTitleEnabled: true
|
||||
property bool audioVisualizerEnabled: true
|
||||
property string audioScrollMode: "volume"
|
||||
property int audioWheelScrollAmount: 5
|
||||
property bool clockCompactMode: false
|
||||
property bool focusedWindowCompactMode: false
|
||||
property bool runningAppsCompactMode: true
|
||||
@@ -273,8 +286,9 @@ Singleton {
|
||||
property int appsDockEnlargePercentage: 125
|
||||
property int appsDockIconSizePercentage: 100
|
||||
property bool keyboardLayoutNameCompactMode: false
|
||||
property bool runningAppsCurrentWorkspace: false
|
||||
property bool runningAppsCurrentWorkspace: true
|
||||
property bool runningAppsGroupByApp: false
|
||||
property bool runningAppsCurrentMonitor: false
|
||||
property var appIdSubstitutions: []
|
||||
property string centeringMode: "index"
|
||||
property string clockDateFormat: ""
|
||||
@@ -300,6 +314,7 @@ Singleton {
|
||||
property int dankLauncherV2BorderThickness: 2
|
||||
property string dankLauncherV2BorderColor: "primary"
|
||||
property bool dankLauncherV2ShowFooter: true
|
||||
property bool dankLauncherV2UnloadOnClose: false
|
||||
|
||||
property string _legacyWeatherLocation: "New York, NY"
|
||||
property string _legacyWeatherCoordinates: "40.7128,-74.0060"
|
||||
@@ -457,6 +472,8 @@ Singleton {
|
||||
property bool dockShowOverflowBadge: true
|
||||
|
||||
property bool notificationOverlayEnabled: false
|
||||
property bool notificationPopupShadowEnabled: true
|
||||
property bool notificationPopupPrivacyMode: false
|
||||
property int overviewRows: 2
|
||||
property int overviewColumns: 5
|
||||
property real overviewScale: 0.16
|
||||
@@ -471,6 +488,7 @@ Singleton {
|
||||
property bool lockScreenShowPasswordField: true
|
||||
property bool lockScreenShowMediaPlayer: true
|
||||
property bool lockScreenPowerOffMonitorsOnLock: false
|
||||
property bool lockAtStartup: false
|
||||
|
||||
property bool enableFprint: false
|
||||
property int maxFprintTries: 15
|
||||
@@ -485,17 +503,21 @@ Singleton {
|
||||
property int notificationTimeoutCritical: 0
|
||||
property bool notificationCompactMode: false
|
||||
property int notificationPopupPosition: SettingsData.Position.Top
|
||||
property int notificationAnimationSpeed: SettingsData.AnimationSpeed.Short
|
||||
property int notificationCustomAnimationDuration: 400
|
||||
property bool notificationHistoryEnabled: true
|
||||
property int notificationHistoryMaxCount: 50
|
||||
property int notificationHistoryMaxAgeDays: 7
|
||||
property bool notificationHistorySaveLow: true
|
||||
property bool notificationHistorySaveNormal: true
|
||||
property bool notificationHistorySaveCritical: true
|
||||
property var notificationRules: []
|
||||
|
||||
property bool osdAlwaysShowValue: false
|
||||
property int osdPosition: SettingsData.Position.BottomCenter
|
||||
property bool osdVolumeEnabled: true
|
||||
property bool osdMediaVolumeEnabled: true
|
||||
property bool osdMediaPlaybackEnabled: true
|
||||
property bool osdBrightnessEnabled: true
|
||||
property bool osdIdleInhibitorEnabled: true
|
||||
property bool osdMicMuteEnabled: true
|
||||
@@ -992,6 +1014,42 @@ Singleton {
|
||||
function applyStoredIconTheme() {
|
||||
updateGtkIconTheme();
|
||||
updateQtIconTheme();
|
||||
updateCosmicIconTheme();
|
||||
}
|
||||
|
||||
function updateCosmicIconTheme() {
|
||||
let cosmicThemeName = (iconTheme === "System Default") ? systemDefaultIconTheme : iconTheme;
|
||||
if (!cosmicThemeName || cosmicThemeName === "System Default") {
|
||||
const detectScript = `if command -v gsettings >/dev/null 2>&1; then
|
||||
gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g"
|
||||
elif command -v dconf >/dev/null 2>&1; then
|
||||
dconf read /org/gnome/desktop/interface/icon-theme 2>/dev/null | sed "s/'//g"
|
||||
fi`;
|
||||
Proc.runCommand("detectCosmicIconTheme", ["sh", "-c", detectScript], (output, exitCode) => {
|
||||
if (exitCode !== 0)
|
||||
return;
|
||||
const detected = (output || "").trim();
|
||||
if (!detected || detected === "System Default")
|
||||
return;
|
||||
const detectedEscaped = detected.replace(/'/g, "'\\''");
|
||||
const writeScript = `mkdir -p ${_configDir}/cosmic/com.system76.CosmicTk/v1
|
||||
printf '"%s"\\n' '${detectedEscaped}' > ${_configDir}/cosmic/com.system76.CosmicTk/v1/icon_theme 2>/dev/null || true`;
|
||||
Quickshell.execDetached(["sh", "-lc", writeScript]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cosmicThemeNameEscaped = cosmicThemeName.replace(/'/g, "'\\''");
|
||||
const script = `mkdir -p ${_configDir}/cosmic/com.system76.CosmicTk/v1
|
||||
printf '"%s"\\n' '${cosmicThemeNameEscaped}' > ${_configDir}/cosmic/com.system76.CosmicTk/v1/icon_theme 2>/dev/null || true`;
|
||||
Quickshell.execDetached(["sh", "-lc", script]);
|
||||
}
|
||||
|
||||
function updateCosmicThemeMode(isLightMode) {
|
||||
const isDark = isLightMode ? "false" : "true";
|
||||
const script = `mkdir -p ${_configDir}/cosmic/com.system76.CosmicTheme.Mode/v1
|
||||
printf '%s\\n' ${isDark} > ${_configDir}/cosmic/com.system76.CosmicTheme.Mode/v1/is_dark 2>/dev/null || true`;
|
||||
Quickshell.execDetached(["sh", "-lc", script]);
|
||||
}
|
||||
|
||||
function updateGtkIconTheme() {
|
||||
@@ -1006,6 +1064,7 @@ Singleton {
|
||||
|
||||
for config_dir in ${_configDir}/gtk-3.0 ${_configDir}/gtk-4.0; do
|
||||
settings_file="$config_dir/settings.ini"
|
||||
[ -f "$settings_file" ] && [ ! -w "$settings_file" ] && continue
|
||||
if [ -f "$settings_file" ]; then
|
||||
if grep -q "^gtk-icon-theme-name=" "$settings_file"; then
|
||||
sed -i 's/^gtk-icon-theme-name=.*/gtk-icon-theme-name=${gtkThemeName}/' "$settings_file"
|
||||
@@ -1797,6 +1856,7 @@ Singleton {
|
||||
iconTheme = themeName;
|
||||
updateGtkIconTheme();
|
||||
updateQtIconTheme();
|
||||
updateCosmicIconTheme();
|
||||
saveSettings();
|
||||
if (typeof Theme !== "undefined" && Theme.currentTheme === Theme.dynamic)
|
||||
Theme.generateSystemThemesFromCurrentTheme();
|
||||
@@ -1855,6 +1915,7 @@ Singleton {
|
||||
|
||||
const script = `
|
||||
xresources_file="${xresourcesPath}"
|
||||
[ -f "$xresources_file" ] && [ ! -w "$xresources_file" ] && exit 0
|
||||
theme_name="${themeName}"
|
||||
cursor_size="${size}"
|
||||
|
||||
@@ -2118,6 +2179,143 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
property bool _pendingExpandNotificationRules: false
|
||||
property int _pendingNotificationRuleIndex: -1
|
||||
|
||||
function addNotificationRule() {
|
||||
var rules = JSON.parse(JSON.stringify(notificationRules || []));
|
||||
rules.push({
|
||||
enabled: true,
|
||||
field: "appName",
|
||||
pattern: "",
|
||||
matchType: "contains",
|
||||
action: "default",
|
||||
urgency: "default"
|
||||
});
|
||||
notificationRules = rules;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function addNotificationRuleForNotification(appName, desktopEntry) {
|
||||
var rules = JSON.parse(JSON.stringify(notificationRules || []));
|
||||
var pattern = (desktopEntry && desktopEntry !== "") ? desktopEntry : (appName || "");
|
||||
var field = (desktopEntry && desktopEntry !== "") ? "desktopEntry" : "appName";
|
||||
var rule = {
|
||||
enabled: true,
|
||||
field: pattern ? field : "appName",
|
||||
pattern: pattern || "",
|
||||
matchType: pattern ? "exact" : "contains",
|
||||
action: "default",
|
||||
urgency: "default"
|
||||
};
|
||||
rules.push(rule);
|
||||
notificationRules = rules;
|
||||
saveSettings();
|
||||
var index = rules.length - 1;
|
||||
_pendingExpandNotificationRules = true;
|
||||
_pendingNotificationRuleIndex = index;
|
||||
return index;
|
||||
}
|
||||
|
||||
function addMuteRuleForApp(appName, desktopEntry) {
|
||||
var rules = JSON.parse(JSON.stringify(notificationRules || []));
|
||||
var pattern = (desktopEntry && desktopEntry !== "") ? desktopEntry : (appName || "");
|
||||
var field = (desktopEntry && desktopEntry !== "") ? "desktopEntry" : "appName";
|
||||
if (pattern === "")
|
||||
return;
|
||||
rules.push({
|
||||
enabled: true,
|
||||
field: field,
|
||||
pattern: pattern,
|
||||
matchType: "exact",
|
||||
action: "mute",
|
||||
urgency: "default"
|
||||
});
|
||||
notificationRules = rules;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function isAppMuted(appName, desktopEntry) {
|
||||
const rules = notificationRules || [];
|
||||
const pat = (desktopEntry && desktopEntry !== "" ? desktopEntry : appName || "").toString().toLowerCase();
|
||||
if (!pat)
|
||||
return false;
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const r = rules[i];
|
||||
if ((r.action || "").toString().toLowerCase() !== "mute" || r.enabled === false)
|
||||
continue;
|
||||
const field = (r.field || "appName").toString().toLowerCase();
|
||||
const rulePat = (r.pattern || "").toString().toLowerCase();
|
||||
if (!rulePat)
|
||||
continue;
|
||||
const useDesktop = field === "desktopentry";
|
||||
const matches = (useDesktop && desktopEntry) ? (desktopEntry.toString().toLowerCase() === rulePat) : (appName && appName.toString().toLowerCase() === rulePat);
|
||||
if (matches)
|
||||
return true;
|
||||
if (rulePat === pat)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function removeMuteRuleForApp(appName, desktopEntry) {
|
||||
var rules = JSON.parse(JSON.stringify(notificationRules || []));
|
||||
const app = (appName || "").toString().toLowerCase();
|
||||
const desktop = (desktopEntry || "").toString().toLowerCase();
|
||||
if (!app && !desktop)
|
||||
return;
|
||||
for (let i = rules.length - 1; i >= 0; i--) {
|
||||
const r = rules[i];
|
||||
if ((r.action || "").toString().toLowerCase() !== "mute")
|
||||
continue;
|
||||
const rulePat = (r.pattern || "").toString().toLowerCase();
|
||||
if (!rulePat)
|
||||
continue;
|
||||
if (rulePat === app || rulePat === desktop) {
|
||||
rules.splice(i, 1);
|
||||
notificationRules = rules;
|
||||
saveSettings();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateNotificationRule(index, ruleData) {
|
||||
var rules = JSON.parse(JSON.stringify(notificationRules || []));
|
||||
if (index < 0 || index >= rules.length)
|
||||
return;
|
||||
var existing = rules[index] || {};
|
||||
rules[index] = Object.assign({}, existing, ruleData || {});
|
||||
notificationRules = rules;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function updateNotificationRuleField(index, key, value) {
|
||||
if (key === undefined || key === null || key === "")
|
||||
return;
|
||||
var patch = {};
|
||||
patch[key] = value;
|
||||
updateNotificationRule(index, patch);
|
||||
}
|
||||
|
||||
function removeNotificationRule(index) {
|
||||
var rules = JSON.parse(JSON.stringify(notificationRules || []));
|
||||
if (index < 0 || index >= rules.length)
|
||||
return;
|
||||
rules.splice(index, 1);
|
||||
notificationRules = rules;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getDefaultNotificationRules() {
|
||||
return Spec.SPEC.notificationRules.def;
|
||||
}
|
||||
|
||||
function resetNotificationRules() {
|
||||
notificationRules = JSON.parse(JSON.stringify(Spec.SPEC.notificationRules.def));
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getDefaultAppIdSubstitutions() {
|
||||
return Spec.SPEC.appIdSubstitutions.def;
|
||||
}
|
||||
@@ -2143,19 +2341,40 @@ Singleton {
|
||||
Theme.reloadCustomThemeVariant();
|
||||
}
|
||||
|
||||
function getRegistryThemeMultiVariant(themeId, defaults) {
|
||||
function getRegistryThemeMultiVariant(themeId, defaults, mode) {
|
||||
var stored = registryThemeVariants[themeId];
|
||||
if (stored && typeof stored === "object")
|
||||
return stored;
|
||||
return defaults || {};
|
||||
if (!stored || typeof stored !== "object")
|
||||
return defaults || {};
|
||||
if ((stored.dark && typeof stored.dark === "object") || (stored.light && typeof stored.light === "object")) {
|
||||
if (!mode)
|
||||
return stored.dark || stored.light || defaults || {};
|
||||
var modeData = stored[mode];
|
||||
if (modeData && typeof modeData === "object")
|
||||
return modeData;
|
||||
return defaults || {};
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
function setRegistryThemeMultiVariant(themeId, flavor, accent) {
|
||||
function setRegistryThemeMultiVariant(themeId, flavor, accent, mode) {
|
||||
var variants = JSON.parse(JSON.stringify(registryThemeVariants));
|
||||
variants[themeId] = {
|
||||
var existing = variants[themeId];
|
||||
var perMode = {};
|
||||
if (existing && typeof existing === "object") {
|
||||
if ((existing.dark && typeof existing.dark === "object") || (existing.light && typeof existing.light === "object")) {
|
||||
perMode = existing;
|
||||
} else if (typeof existing.flavor === "string") {
|
||||
perMode.dark = {
|
||||
flavor: existing.flavor,
|
||||
accent: existing.accent || ""
|
||||
};
|
||||
}
|
||||
}
|
||||
perMode[mode || "dark"] = {
|
||||
flavor: flavor,
|
||||
accent: accent
|
||||
};
|
||||
variants[themeId] = perMode;
|
||||
registryThemeVariants = variants;
|
||||
saveSettings();
|
||||
if (typeof Theme !== "undefined")
|
||||
@@ -2355,6 +2574,13 @@ Singleton {
|
||||
|
||||
property alias settingsFile: settingsFile
|
||||
|
||||
Timer {
|
||||
id: settingsFileReloadDebounce
|
||||
interval: 50
|
||||
onTriggered: settingsFile.reload()
|
||||
repeat: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: settingsFile
|
||||
|
||||
@@ -2362,7 +2588,8 @@ Singleton {
|
||||
blockLoading: true
|
||||
blockWrites: true
|
||||
atomicWrites: true
|
||||
watchChanges: !isGreeterMode
|
||||
watchChanges: true
|
||||
onFileChanged: settingsFileReloadDebounce.restart()
|
||||
onLoaded: {
|
||||
if (isGreeterMode)
|
||||
return;
|
||||
|
||||
@@ -45,11 +45,12 @@ Singleton {
|
||||
if (typeof SessionData === "undefined")
|
||||
return "";
|
||||
|
||||
var monitors = SessionData.monitorWallpapers;
|
||||
if (SessionData.perMonitorWallpaper) {
|
||||
var screens = Quickshell.screens;
|
||||
if (screens.length > 0) {
|
||||
var firstMonitorWallpaper = SessionData.getMonitorWallpaper(screens[0].name);
|
||||
return firstMonitorWallpaper || SessionData.wallpaperPath;
|
||||
var s = screens[0];
|
||||
return monitors[s.name] || (s.model ? monitors[s.model] : "") || SessionData.wallpaperPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +60,7 @@ Singleton {
|
||||
if (typeof SessionData === "undefined")
|
||||
return "";
|
||||
|
||||
var monitors = SessionData.monitorWallpapers;
|
||||
if (SessionData.perMonitorWallpaper) {
|
||||
var screens = Quickshell.screens;
|
||||
if (screens.length > 0) {
|
||||
@@ -72,12 +74,20 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetMonitorExists) {
|
||||
if (!targetMonitorExists)
|
||||
targetMonitor = screens[0].name;
|
||||
|
||||
var s = null;
|
||||
for (var j = 0; j < screens.length; j++) {
|
||||
if (screens[j].name === targetMonitor) {
|
||||
s = screens[j];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var targetMonitorWallpaper = SessionData.getMonitorWallpaper(targetMonitor);
|
||||
return targetMonitorWallpaper || SessionData.wallpaperPath;
|
||||
if (s)
|
||||
return monitors[s.name] || (s.model ? monitors[s.model] : "") || SessionData.wallpaperPath;
|
||||
return monitors[targetMonitor] || SessionData.wallpaperPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +188,8 @@ Singleton {
|
||||
|
||||
if (typeof SettingsData !== "undefined" && SettingsData.currentThemeName) {
|
||||
switchTheme(SettingsData.currentThemeName, false, false);
|
||||
const currentIsLight = (typeof SessionData !== "undefined") ? SessionData.isLightMode : false;
|
||||
SettingsData.updateCosmicThemeMode(currentIsLight);
|
||||
}
|
||||
|
||||
if (typeof SessionData !== "undefined" && SessionData.themeModeAutoEnabled) {
|
||||
@@ -606,6 +618,58 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color buttonBg: {
|
||||
switch (SettingsData.buttonColorMode) {
|
||||
case "primaryContainer":
|
||||
return primaryContainer;
|
||||
case "secondary":
|
||||
return secondary;
|
||||
case "surfaceVariant":
|
||||
return surfaceVariant;
|
||||
default:
|
||||
return primary;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color buttonText: {
|
||||
switch (SettingsData.buttonColorMode) {
|
||||
case "primaryContainer":
|
||||
return primary;
|
||||
case "secondary":
|
||||
return surfaceText;
|
||||
case "surfaceVariant":
|
||||
return surfaceText;
|
||||
default:
|
||||
return primaryText;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color buttonHover: {
|
||||
switch (SettingsData.buttonColorMode) {
|
||||
case "primaryContainer":
|
||||
return Qt.rgba(primary.r, primary.g, primary.b, 0.12);
|
||||
case "secondary":
|
||||
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.12);
|
||||
case "surfaceVariant":
|
||||
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.12);
|
||||
default:
|
||||
return primaryHover;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color buttonPressed: {
|
||||
switch (SettingsData.buttonColorMode) {
|
||||
case "primaryContainer":
|
||||
return Qt.rgba(primary.r, primary.g, primary.b, 0.16);
|
||||
case "secondary":
|
||||
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.16);
|
||||
case "surfaceVariant":
|
||||
return Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.16);
|
||||
default:
|
||||
return primaryPressed;
|
||||
}
|
||||
}
|
||||
|
||||
property color shadowMedium: Qt.rgba(0, 0, 0, 0.08)
|
||||
property color shadowStrong: Qt.rgba(0, 0, 0, 0.3)
|
||||
|
||||
@@ -650,13 +714,13 @@ Singleton {
|
||||
readonly property int currentAnimationSpeed: typeof SettingsData !== "undefined" ? SettingsData.animationSpeed : SettingsData.AnimationSpeed.Short
|
||||
readonly property var currentDurations: animationDurations[currentAnimationSpeed] || animationDurations[SettingsData.AnimationSpeed.Short]
|
||||
|
||||
property int shorterDuration: currentDurations.shorter
|
||||
property int shortDuration: currentDurations.short
|
||||
property int mediumDuration: currentDurations.medium
|
||||
property int longDuration: currentDurations.long
|
||||
property int extraLongDuration: currentDurations.extraLong
|
||||
property int standardEasing: Easing.OutCubic
|
||||
property int emphasizedEasing: Easing.OutQuart
|
||||
readonly property int shorterDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.shorter
|
||||
readonly property int shortDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.short
|
||||
readonly property int mediumDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.medium
|
||||
readonly property int longDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.long
|
||||
readonly property int extraLongDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.extraLong
|
||||
readonly property int standardEasing: Easing.OutCubic
|
||||
readonly property int emphasizedEasing: Easing.OutQuart
|
||||
|
||||
readonly property var expressiveCurves: {
|
||||
"emphasized": [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1],
|
||||
@@ -714,6 +778,77 @@ Singleton {
|
||||
};
|
||||
}
|
||||
|
||||
readonly property int notificationAnimationBaseDuration: {
|
||||
if (typeof SettingsData === "undefined")
|
||||
return 200;
|
||||
if (SettingsData.notificationAnimationSpeed === SettingsData.AnimationSpeed.None)
|
||||
return 0;
|
||||
if (SettingsData.notificationAnimationSpeed === SettingsData.AnimationSpeed.Custom)
|
||||
return SettingsData.notificationCustomAnimationDuration;
|
||||
const presetMap = [0, 200, 400, 600];
|
||||
return presetMap[SettingsData.notificationAnimationSpeed] ?? 200;
|
||||
}
|
||||
|
||||
readonly property int notificationEnterDuration: {
|
||||
const base = notificationAnimationBaseDuration;
|
||||
return base === 0 ? 0 : Math.round(base * 0.875);
|
||||
}
|
||||
|
||||
readonly property int notificationExitDuration: {
|
||||
const base = notificationAnimationBaseDuration;
|
||||
return base === 0 ? 0 : Math.round(base * 0.75);
|
||||
}
|
||||
|
||||
readonly property int notificationExpandDuration: {
|
||||
const base = notificationAnimationBaseDuration;
|
||||
return base === 0 ? 0 : Math.round(base * 1.0);
|
||||
}
|
||||
|
||||
readonly property int notificationCollapseDuration: {
|
||||
const base = notificationAnimationBaseDuration;
|
||||
return base === 0 ? 0 : Math.round(base * 0.85);
|
||||
}
|
||||
|
||||
readonly property real notificationIconSizeNormal: 56
|
||||
readonly property real notificationIconSizeCompact: 48
|
||||
readonly property real notificationExpandedIconSizeNormal: 48
|
||||
readonly property real notificationExpandedIconSizeCompact: 40
|
||||
readonly property real notificationActionMinWidth: 48
|
||||
readonly property real notificationButtonCornerRadius: cornerRadius / 2
|
||||
readonly property real notificationHoverRevealMargin: spacingXL
|
||||
readonly property real notificationContentSpacing: spacingXS
|
||||
readonly property real notificationCardPadding: spacingM
|
||||
readonly property real notificationCardPaddingCompact: spacingS
|
||||
|
||||
readonly property real stateLayerHover: 0.08
|
||||
readonly property real stateLayerFocus: 0.12
|
||||
readonly property real stateLayerPressed: 0.12
|
||||
readonly property real stateLayerDrag: 0.16
|
||||
|
||||
readonly property int popoutAnimationDuration: {
|
||||
if (typeof SettingsData === "undefined")
|
||||
return 150;
|
||||
if (SettingsData.syncComponentAnimationSpeeds) {
|
||||
return Math.min(currentAnimationBaseDuration, 1000);
|
||||
}
|
||||
const presetMap = [0, 150, 300, 500];
|
||||
if (SettingsData.popoutAnimationSpeed === SettingsData.AnimationSpeed.Custom)
|
||||
return SettingsData.popoutCustomAnimationDuration;
|
||||
return presetMap[SettingsData.popoutAnimationSpeed] ?? 150;
|
||||
}
|
||||
|
||||
readonly property int modalAnimationDuration: {
|
||||
if (typeof SettingsData === "undefined")
|
||||
return 150;
|
||||
if (SettingsData.syncComponentAnimationSpeeds) {
|
||||
return Math.min(currentAnimationBaseDuration, 1000);
|
||||
}
|
||||
const presetMap = [0, 150, 300, 500];
|
||||
if (SettingsData.modalAnimationSpeed === SettingsData.AnimationSpeed.Custom)
|
||||
return SettingsData.modalCustomAnimationDuration;
|
||||
return presetMap[SettingsData.modalAnimationSpeed] ?? 150;
|
||||
}
|
||||
|
||||
property real cornerRadius: {
|
||||
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") {
|
||||
return GreetdSettings.cornerRadius;
|
||||
@@ -830,6 +965,9 @@ Singleton {
|
||||
if (!matugenAvailable) {
|
||||
PortalService.setLightMode(light);
|
||||
}
|
||||
if (typeof SettingsData !== "undefined") {
|
||||
SettingsData.updateCosmicThemeMode(light);
|
||||
}
|
||||
generateSystemThemesFromCurrentTheme();
|
||||
}
|
||||
}
|
||||
@@ -885,7 +1023,7 @@ Singleton {
|
||||
if (themeData.variants.type === "multi" && themeData.variants.flavors && themeData.variants.accents) {
|
||||
const defaults = themeData.variants.defaults || {};
|
||||
const modeDefaults = defaults[colorMode] || defaults.dark || {};
|
||||
const stored = typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, modeDefaults) : modeDefaults;
|
||||
const stored = typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, modeDefaults, colorMode) : modeDefaults;
|
||||
var flavorId = stored.flavor || modeDefaults.flavor || "";
|
||||
const accentId = stored.accent || modeDefaults.accent || "";
|
||||
var flavor = findVariant(themeData.variants.flavors, flavorId);
|
||||
@@ -1279,8 +1417,8 @@ Singleton {
|
||||
const defaults = customThemeRawData.variants.defaults || {};
|
||||
const darkDefaults = defaults.dark || {};
|
||||
const lightDefaults = defaults.light || defaults.dark || {};
|
||||
const storedDark = typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, darkDefaults) : darkDefaults;
|
||||
const storedLight = typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, lightDefaults) : lightDefaults;
|
||||
const storedDark = typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, darkDefaults, "dark") : darkDefaults;
|
||||
const storedLight = 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 || "";
|
||||
|
||||
@@ -32,8 +32,15 @@ function markdownToHtml(text) {
|
||||
return `\x00INLINECODE${inlineIndex++}\x00`;
|
||||
});
|
||||
|
||||
// Now process everything else
|
||||
// Escape HTML entities (but not in code blocks)
|
||||
// Extract plain URLs before escaping so & in query strings is preserved
|
||||
const urls = [];
|
||||
let urlIndex = 0;
|
||||
html = html.replace(/(^|[\s])((?:https?|file):\/\/[^\s]+)/gm, (match, prefix, url) => {
|
||||
urls.push(url);
|
||||
return prefix + `\x00URL${urlIndex++}\x00`;
|
||||
});
|
||||
|
||||
// Escape HTML entities (but not in code blocks or URLs)
|
||||
html = html.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
@@ -64,8 +71,12 @@ function markdownToHtml(text) {
|
||||
return '<ul>' + match + '</ul>';
|
||||
});
|
||||
|
||||
// Detect plain URLs and wrap them in anchor tags (but not inside existing <a> or markdown links)
|
||||
html = html.replace(/(^|[^"'>])((https?|file):\/\/[^\s<]+)/g, '$1<a href="$2">$2</a>');
|
||||
// Restore extracted URLs as anchor tags (preserves raw & in href)
|
||||
html = html.replace(/\x00URL(\d+)\x00/g, (_, index) => {
|
||||
const url = urls[parseInt(index)];
|
||||
const display = url.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<a href="${url}">${display}</a>`;
|
||||
});
|
||||
|
||||
// Restore code blocks and inline code BEFORE line break processing
|
||||
html = html.replace(/\x00CODEBLOCK(\d+)\x00/g, (match, index) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ var SPEC = {
|
||||
wallpaperPathDark: { def: "" },
|
||||
monitorWallpapersLight: { def: {} },
|
||||
monitorWallpapersDark: { def: {} },
|
||||
monitorWallpaperFillModes: { def: {} },
|
||||
wallpaperTransition: { def: "fade" },
|
||||
includedTransitions: { def: ["fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"] },
|
||||
|
||||
@@ -72,7 +73,11 @@ var SPEC = {
|
||||
appOverrides: { def: {} },
|
||||
searchAppActions: { def: true },
|
||||
|
||||
vpnLastConnected: { def: "" }
|
||||
vpnLastConnected: { def: "" },
|
||||
|
||||
deviceMaxVolumes: { def: {} },
|
||||
hiddenOutputDeviceNames: { def: [] },
|
||||
hiddenInputDeviceNames: { def: [] }
|
||||
};
|
||||
|
||||
function getValidKeys() {
|
||||
|
||||
@@ -20,6 +20,7 @@ var SPEC = {
|
||||
widgetBackgroundColor: { def: "sch" },
|
||||
widgetColorMode: { def: "default" },
|
||||
controlCenterTileColorMode: { def: "primary" },
|
||||
buttonColorMode: { def: "primary" },
|
||||
cornerRadius: { def: 12, onChange: "updateCompositorLayout" },
|
||||
niriLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
|
||||
niriLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
|
||||
@@ -39,6 +40,12 @@ var SPEC = {
|
||||
nightModeEnabled: { def: false },
|
||||
animationSpeed: { def: 1 },
|
||||
customAnimationDuration: { def: 500 },
|
||||
syncComponentAnimationSpeeds: { def: true },
|
||||
popoutAnimationSpeed: { def: 1 },
|
||||
popoutCustomAnimationDuration: { def: 150 },
|
||||
modalAnimationSpeed: { def: 1 },
|
||||
modalCustomAnimationDuration: { def: 150 },
|
||||
enableRippleEffects: { def: true },
|
||||
wallpaperFillMode: { def: "Fill" },
|
||||
blurredWallpaperLayer: { def: false },
|
||||
blurWallpaperOnOverview: { def: false },
|
||||
@@ -98,6 +105,7 @@ var SPEC = {
|
||||
showWorkspacePadding: { def: false },
|
||||
workspaceScrolling: { def: false },
|
||||
showWorkspaceApps: { def: false },
|
||||
workspaceDragReorder: { def: true },
|
||||
maxWorkspaceIcons: { def: 3 },
|
||||
workspaceAppIconSizeOffset: { def: 0 },
|
||||
groupWorkspaceApps: { def: true },
|
||||
@@ -117,6 +125,7 @@ var SPEC = {
|
||||
scrollTitleEnabled: { def: true },
|
||||
audioVisualizerEnabled: { def: true },
|
||||
audioScrollMode: { def: "volume" },
|
||||
audioWheelScrollAmount: { def: 5 },
|
||||
clockCompactMode: { def: false },
|
||||
focusedWindowCompactMode: { def: false },
|
||||
runningAppsCompactMode: { def: true },
|
||||
@@ -130,8 +139,9 @@ var SPEC = {
|
||||
appsDockEnlargePercentage: { def: 125 },
|
||||
appsDockIconSizePercentage: { def: 100 },
|
||||
keyboardLayoutNameCompactMode: { def: false },
|
||||
runningAppsCurrentWorkspace: { def: false },
|
||||
runningAppsCurrentWorkspace: { def: true },
|
||||
runningAppsGroupByApp: { def: false },
|
||||
runningAppsCurrentMonitor: { def: false },
|
||||
appIdSubstitutions: {
|
||||
def: [
|
||||
{ pattern: "Spotify", replacement: "spotify", type: "exact" },
|
||||
@@ -163,6 +173,7 @@ var SPEC = {
|
||||
dankLauncherV2BorderThickness: { def: 2 },
|
||||
dankLauncherV2BorderColor: { def: "primary" },
|
||||
dankLauncherV2ShowFooter: { def: true },
|
||||
dankLauncherV2UnloadOnClose: { def: false },
|
||||
|
||||
useAutoLocation: { def: false },
|
||||
weatherEnabled: { def: true },
|
||||
@@ -286,6 +297,8 @@ var SPEC = {
|
||||
dockShowOverflowBadge: { def: true },
|
||||
|
||||
notificationOverlayEnabled: { def: false },
|
||||
notificationPopupShadowEnabled: { def: true },
|
||||
notificationPopupPrivacyMode: { def: false },
|
||||
overviewRows: { def: 2, persist: false },
|
||||
overviewColumns: { def: 5, persist: false },
|
||||
overviewScale: { def: 0.16, persist: false },
|
||||
@@ -300,6 +313,7 @@ var SPEC = {
|
||||
lockScreenShowPasswordField: { def: true },
|
||||
lockScreenShowMediaPlayer: { def: true },
|
||||
lockScreenPowerOffMonitorsOnLock: { def: false },
|
||||
lockAtStartup: { def: false },
|
||||
enableFprint: { def: false },
|
||||
maxFprintTries: { def: 15 },
|
||||
fprintdAvailable: { def: false, persist: false },
|
||||
@@ -313,17 +327,21 @@ var SPEC = {
|
||||
notificationTimeoutCritical: { def: 0 },
|
||||
notificationCompactMode: { def: false },
|
||||
notificationPopupPosition: { def: 0 },
|
||||
notificationAnimationSpeed: { def: 1 },
|
||||
notificationCustomAnimationDuration: { def: 400 },
|
||||
notificationHistoryEnabled: { def: true },
|
||||
notificationHistoryMaxCount: { def: 50 },
|
||||
notificationHistoryMaxAgeDays: { def: 7 },
|
||||
notificationHistorySaveLow: { def: true },
|
||||
notificationHistorySaveNormal: { def: true },
|
||||
notificationHistorySaveCritical: { def: true },
|
||||
notificationRules: { def: [] },
|
||||
|
||||
osdAlwaysShowValue: { def: false },
|
||||
osdPosition: { def: 5 },
|
||||
osdVolumeEnabled: { def: true },
|
||||
osdMediaVolumeEnabled: { def: true },
|
||||
osdMediaPlaybackEnabled: { def: true },
|
||||
osdBrightnessEnabled: { def: true },
|
||||
osdIdleInhibitorEnabled: { def: true },
|
||||
osdMicMuteEnabled: { def: true },
|
||||
@@ -456,6 +474,8 @@ var SPEC = {
|
||||
desktopWidgetGroups: { def: [] },
|
||||
|
||||
builtInPluginSettings: { def: {} },
|
||||
clipboardEnterToPaste: { def: false },
|
||||
|
||||
launcherPluginVisibility: { def: {} },
|
||||
launcherPluginOrder: { def: [] }
|
||||
};
|
||||
|
||||
@@ -251,13 +251,20 @@ Item {
|
||||
active: false
|
||||
asynchronous: false
|
||||
|
||||
Component.onCompleted: {
|
||||
PopoutService.dankDashPopoutLoader = dankDashPopoutLoader;
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
if (item) {
|
||||
PopoutService.dankDashPopout = item;
|
||||
PopoutService._onDankDashPopoutLoaded();
|
||||
}
|
||||
}
|
||||
|
||||
sourceComponent: Component {
|
||||
DankDashPopout {
|
||||
id: dankDashPopout
|
||||
|
||||
Component.onCompleted: {
|
||||
PopoutService.dankDashPopout = dankDashPopout;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -841,6 +848,14 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: SettingsData.getFilteredScreens("osd")
|
||||
|
||||
delegate: MediaPlaybackOSD {
|
||||
modelData: item
|
||||
}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: SettingsData.getFilteredScreens("osd")
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import QtQuick
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Services.SystemTray
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Modules.Settings.DisplayConfig
|
||||
@@ -196,7 +197,7 @@ Item {
|
||||
if (CompositorService.isNiri && NiriService.currentOutput) {
|
||||
return NiriService.currentOutput;
|
||||
}
|
||||
if ((CompositorService.isSway || CompositorService.isScroll) && I3.workspaces?.values) {
|
||||
if ((CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) && I3.workspaces?.values) {
|
||||
const focusedWs = I3.workspaces.values.find(ws => ws.focused === true);
|
||||
return focusedWs?.monitor?.name || "";
|
||||
}
|
||||
@@ -338,6 +339,36 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
function increment(step: string): string {
|
||||
if (MprisController.activePlayer && MprisController.activePlayer.volumeSupported) {
|
||||
const currentVolume = Math.round(MprisController.activePlayer.volume * 100);
|
||||
const stepValue = parseInt(step || "5");
|
||||
const newVolume = Math.max(0, Math.min(100, currentVolume + stepValue));
|
||||
|
||||
MprisController.activePlayer.volume = newVolume / 100;
|
||||
return `Player volume increased to ${newVolume}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function decrement(step: string): string {
|
||||
if (MprisController.activePlayer && MprisController.activePlayer.volumeSupported) {
|
||||
const currentVolume = Math.round(MprisController.activePlayer.volume * 100);
|
||||
const stepValue = parseInt(step || "5");
|
||||
const newVolume = Math.max(0, Math.min(100, currentVolume - stepValue));
|
||||
|
||||
MprisController.activePlayer.volume = newVolume / 100;
|
||||
return `Player volume decreased to ${newVolume}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function setvolume(percentage: string): string {
|
||||
if (MprisController.activePlayer && MprisController.activePlayer.volumeSupported) {
|
||||
const clampedVolume = Math.max(0, Math.min(100, percentage));
|
||||
MprisController.activePlayer.volume = clampedVolume / 100;
|
||||
return `Player volume set to ${clampedVolume}%`;
|
||||
}
|
||||
}
|
||||
|
||||
target: "mpris"
|
||||
}
|
||||
|
||||
@@ -941,8 +972,10 @@ Item {
|
||||
if (!PluginService.availablePlugins[pluginId])
|
||||
return `PLUGIN_NOT_FOUND: ${pluginId}`;
|
||||
|
||||
if (!PluginService.isPluginLoaded(pluginId))
|
||||
return `PLUGIN_NOT_LOADED: ${pluginId}`;
|
||||
if (!PluginService.isPluginLoaded(pluginId)) {
|
||||
const success = PluginService.enablePlugin(pluginId);
|
||||
return success ? `PLUGIN_RELOAD_SUCCESS: ${pluginId}` : `PLUGIN_RELOAD_FAILED: ${pluginId}`;
|
||||
}
|
||||
|
||||
const success = PluginService.reloadPlugin(pluginId);
|
||||
return success ? `PLUGIN_RELOAD_SUCCESS: ${pluginId}` : `PLUGIN_RELOAD_FAILED: ${pluginId}`;
|
||||
@@ -1530,4 +1563,56 @@ Item {
|
||||
|
||||
target: "outputs"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function findTrayItem(itemId: string): var {
|
||||
if (!itemId)
|
||||
return null;
|
||||
|
||||
return SystemTray.items.values.find(item => {
|
||||
const id = item?.id || "";
|
||||
const title = item?.tooltipTitle || "";
|
||||
const fullKey = title ? `${id}::${title}` : id;
|
||||
return fullKey === itemId || id === itemId;
|
||||
});
|
||||
}
|
||||
|
||||
function list(): string {
|
||||
const items = SystemTray.items.values;
|
||||
if (items.length === 0)
|
||||
return "No tray items available";
|
||||
|
||||
return items.map(item => {
|
||||
const id = item?.id || "";
|
||||
const title = item?.tooltipTitle || "";
|
||||
const fullKey = title ? `${id}::${title}` : id;
|
||||
const hasMenu = item?.hasMenu ? " [menu]" : "";
|
||||
return fullKey + hasMenu;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function activate(itemId: string): string {
|
||||
const item = findTrayItem(itemId);
|
||||
if (!item)
|
||||
return `ERROR: Tray item not found: ${itemId}`;
|
||||
|
||||
item.activate();
|
||||
return `SUCCESS: Activated ${itemId}`;
|
||||
}
|
||||
|
||||
function status(itemId: string): string {
|
||||
const item = findTrayItem(itemId);
|
||||
if (!item)
|
||||
return `ERROR: Tray item not found: ${itemId}`;
|
||||
|
||||
const id = item?.id || "";
|
||||
const title = item?.tooltipTitle || "";
|
||||
const hasMenu = item?.hasMenu || false;
|
||||
const onlyMenu = item?.onlyMenu || false;
|
||||
|
||||
return `id: ${id}\ntitle: ${title}\nhasMenu: ${hasMenu}\nonlyMenu: ${onlyMenu}`;
|
||||
}
|
||||
|
||||
target: "tray"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ Column {
|
||||
StyledText {
|
||||
id: codenameText
|
||||
anchors.centerIn: parent
|
||||
text: "Spicy Miso"
|
||||
text: "Saffron Bloom"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.primary
|
||||
@@ -74,7 +74,7 @@ Column {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: "Desktop widgets, theme registry, native clipboard & more"
|
||||
text: "New launcher, enhanced plugin system, KDE Connect, & more"
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
@@ -108,67 +108,76 @@ Column {
|
||||
|
||||
ChangelogFeatureCard {
|
||||
width: (parent.width - Theme.spacingS) / 2
|
||||
iconName: "widgets"
|
||||
title: "Desktop Widgets"
|
||||
description: "Widgets on your desktop"
|
||||
onClicked: PopoutService.openSettingsWithTab("desktop_widgets")
|
||||
iconName: "space_dashboard"
|
||||
title: "Dank Launcher V2"
|
||||
description: "New capabilities & plugins"
|
||||
onClicked: PopoutService.openDankLauncherV2()
|
||||
}
|
||||
|
||||
ChangelogFeatureCard {
|
||||
width: (parent.width - Theme.spacingS) / 2
|
||||
iconName: "palette"
|
||||
title: "Theme Registry"
|
||||
description: "Community themes"
|
||||
onClicked: PopoutService.openSettingsWithTab("theme")
|
||||
iconName: "smartphone"
|
||||
title: "Phone Connect"
|
||||
description: "KDE Connect & Valent"
|
||||
onClicked: Qt.openUrlExternally("https://github.com/AvengeMedia/dms-plugins/tree/master/DankKDEConnect")
|
||||
}
|
||||
|
||||
ChangelogFeatureCard {
|
||||
width: (parent.width - Theme.spacingS) / 2
|
||||
iconName: "content_paste"
|
||||
title: "Native Clipboard"
|
||||
description: "Zero-dependency history"
|
||||
onClicked: PopoutService.openSettingsWithTab("clipboard")
|
||||
iconName: "monitor_heart"
|
||||
title: "System Monitor"
|
||||
description: "Redesigned process list"
|
||||
onClicked: PopoutService.showProcessListModal()
|
||||
}
|
||||
|
||||
ChangelogFeatureCard {
|
||||
width: (parent.width - Theme.spacingS) / 2
|
||||
iconName: "display_settings"
|
||||
title: "Monitor Config"
|
||||
description: "Full display setup"
|
||||
onClicked: PopoutService.openSettingsWithTab("display_config")
|
||||
iconName: "window"
|
||||
title: "Window Rules"
|
||||
description: "niri window rule manager"
|
||||
visible: CompositorService.isNiri
|
||||
onClicked: PopoutService.openSettingsWithTab("window_rules")
|
||||
}
|
||||
|
||||
ChangelogFeatureCard {
|
||||
width: (parent.width - Theme.spacingS) / 2
|
||||
iconName: "notifications_active"
|
||||
title: "Notifications"
|
||||
description: "History & gestures"
|
||||
title: "Enhanced Notifications"
|
||||
description: "Configurable rules & styling"
|
||||
visible: !CompositorService.isNiri
|
||||
onClicked: PopoutService.openSettingsWithTab("notifications")
|
||||
}
|
||||
|
||||
ChangelogFeatureCard {
|
||||
width: (parent.width - Theme.spacingS) / 2
|
||||
iconName: "healing"
|
||||
title: "DMS Doctor"
|
||||
description: "Diagnose issues"
|
||||
onClicked: FirstLaunchService.showDoctor()
|
||||
iconName: "dock_to_bottom"
|
||||
title: "Dock Enhancements"
|
||||
description: "Bar dock widget & more"
|
||||
onClicked: PopoutService.openSettingsWithTab("dock")
|
||||
}
|
||||
|
||||
ChangelogFeatureCard {
|
||||
width: (parent.width - Theme.spacingS) / 2
|
||||
iconName: "keyboard"
|
||||
title: "Keybinds Editor"
|
||||
description: "niri, Hyprland, & MangoWC"
|
||||
visible: KeybindsService.available
|
||||
onClicked: PopoutService.openSettingsWithTab("keybinds")
|
||||
iconName: "volume_up"
|
||||
title: "Audio Aliases"
|
||||
description: "Custom device names"
|
||||
onClicked: PopoutService.openSettingsWithTab("audio")
|
||||
}
|
||||
|
||||
ChangelogFeatureCard {
|
||||
width: (parent.width - Theme.spacingS) / 2
|
||||
iconName: "search"
|
||||
title: "Settings Search"
|
||||
description: "Find settings fast"
|
||||
onClicked: PopoutService.openSettings()
|
||||
iconName: "extension"
|
||||
title: "Enhanced Plugin System"
|
||||
description: "Enables new types of plugins"
|
||||
onClicked: PopoutService.openSettingsWithTab("plugins")
|
||||
}
|
||||
|
||||
ChangelogFeatureCard {
|
||||
width: (parent.width - Theme.spacingS) / 2
|
||||
iconName: "light_mode"
|
||||
title: "Auto Light/Dark"
|
||||
description: "Automatic mode switching"
|
||||
onClicked: PopoutService.openSettingsWithTab("theme")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,26 +230,21 @@ Column {
|
||||
|
||||
ChangelogUpgradeNote {
|
||||
width: parent.width
|
||||
text: "Ghostty theme path changed to ~/.config/ghostty/themes/danktheme"
|
||||
text: "Spotlight replaced by Dank Launcher V2 — check settings for new options"
|
||||
}
|
||||
|
||||
ChangelogUpgradeNote {
|
||||
width: parent.width
|
||||
text: "VS Code theme reinstall required"
|
||||
}
|
||||
|
||||
ChangelogUpgradeNote {
|
||||
width: parent.width
|
||||
text: "Clipboard history migration available from cliphist"
|
||||
text: "Plugin API updated — third-party plugins may need updates"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: "See full release notes for migration steps"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
}
|
||||
// StyledText {
|
||||
// text: "See full release notes for migration steps"
|
||||
// font.pixelSize: Theme.fontSizeSmall
|
||||
// color: Theme.surfaceVariantText
|
||||
// width: parent.width
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import qs.Widgets
|
||||
FloatingWindow {
|
||||
id: root
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
readonly property int modalWidth: 680
|
||||
readonly property int modalHeight: screen ? Math.min(720, screen.height - 80) : 720
|
||||
|
||||
@@ -128,7 +129,7 @@ FloatingWindow {
|
||||
iconName: "open_in_new"
|
||||
backgroundColor: Theme.surfaceContainerHighest
|
||||
textColor: Theme.surfaceText
|
||||
onClicked: Qt.openUrlExternally("https://danklinux.com/blog/v1-2-release")
|
||||
onClicked: Qt.openUrlExternally("https://danklinux.com/blog/v1-4-release")
|
||||
}
|
||||
|
||||
DankButton {
|
||||
|
||||
@@ -164,6 +164,7 @@ Item {
|
||||
}
|
||||
visible: modal.activeTab === "saved"
|
||||
|
||||
currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0
|
||||
spacing: Theme.spacingXS
|
||||
interactive: true
|
||||
flickDeceleration: 1500
|
||||
@@ -173,6 +174,26 @@ Item {
|
||||
pressDelay: 0
|
||||
flickableDirection: Flickable.VerticalFlick
|
||||
|
||||
function ensureVisible(index) {
|
||||
if (index < 0 || index >= count) {
|
||||
return;
|
||||
}
|
||||
const itemHeight = ClipboardConstants.itemHeight + spacing;
|
||||
const itemY = index * itemHeight;
|
||||
const itemBottom = itemY + itemHeight;
|
||||
if (itemY < contentY) {
|
||||
contentY = itemY;
|
||||
} else if (itemBottom > contentY + height) {
|
||||
contentY = itemBottom - height;
|
||||
}
|
||||
}
|
||||
|
||||
onCurrentIndexChanged: {
|
||||
if (clipboardContent.modal?.keyboardNavigationActive && currentIndex >= 0) {
|
||||
ensureVisible(currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("No saved clipboard entries")
|
||||
anchors.centerIn: parent
|
||||
@@ -190,7 +211,7 @@ Item {
|
||||
entry: modelData
|
||||
entryIndex: index + 1
|
||||
itemIndex: index
|
||||
isSelected: false
|
||||
isSelected: clipboardContent.modal?.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex
|
||||
modal: clipboardContent.modal
|
||||
listView: savedListView
|
||||
onCopyRequested: clipboardContent.modal.copyEntry(modelData)
|
||||
@@ -247,6 +268,7 @@ Item {
|
||||
|
||||
sourceComponent: ClipboardKeyboardHints {
|
||||
wtypeAvailable: modal.wtypeAvailable
|
||||
enterToPaste: SettingsData.clipboardEnterToPaste
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Rectangle {
|
||||
@@ -19,6 +20,7 @@ Rectangle {
|
||||
|
||||
readonly property string entryType: modal ? modal.getEntryType(entry) : "text"
|
||||
readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : ""
|
||||
readonly property bool hasPinnedDuplicate: !entry.pinned && ClipboardService.hashedPinnedEntry(entry.hash)
|
||||
|
||||
radius: Theme.cornerRadius
|
||||
color: {
|
||||
@@ -28,91 +30,43 @@ Rectangle {
|
||||
return mouseArea.containsMouse ? Theme.primaryHoverLight : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency);
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
spacing: Theme.spacingL
|
||||
DankRipple {
|
||||
id: rippleLayer
|
||||
rippleColor: Theme.surfaceText
|
||||
cornerRadius: root.radius
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 24
|
||||
height: 24
|
||||
radius: 12
|
||||
color: Theme.primarySelected
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
Rectangle {
|
||||
id: indexBadge
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 24
|
||||
height: 24
|
||||
radius: 12
|
||||
color: Theme.primarySelected
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: entryIndex.toString()
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Bold
|
||||
color: Theme.primary
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 110
|
||||
spacing: Theme.spacingM
|
||||
|
||||
ClipboardThumbnail {
|
||||
width: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
|
||||
height: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
entry: root.entry
|
||||
entryType: root.entryType
|
||||
modal: root.modal
|
||||
listView: root.listView
|
||||
itemIndex: root.itemIndex
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - (entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize) - Theme.spacingM
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
switch (entryType) {
|
||||
case "image":
|
||||
return I18n.tr("Image") + " • " + entryPreview;
|
||||
case "long_text":
|
||||
return I18n.tr("Long Text");
|
||||
default:
|
||||
return I18n.tr("Text");
|
||||
}
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
font.weight: Font.Medium
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: entryPreview
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
maximumLineCount: entryType === "long_text" ? 3 : 1
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: entryIndex.toString()
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Bold
|
||||
color: Theme.primary
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: actionButtons
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankActionButton {
|
||||
iconName: "push_pin"
|
||||
iconSize: Theme.iconSize - 6
|
||||
iconColor: entry.pinned ? Theme.primary : Theme.surfaceText
|
||||
backgroundColor: entry.pinned ? Theme.primarySelected : "transparent"
|
||||
iconColor: (entry.pinned || hasPinnedDuplicate) ? Theme.primary : Theme.surfaceText
|
||||
backgroundColor: (entry.pinned || hasPinnedDuplicate) ? Theme.primarySelected : "transparent"
|
||||
onClicked: entry.pinned ? unpinRequested() : pinRequested()
|
||||
}
|
||||
|
||||
@@ -124,12 +78,76 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.left: indexBadge.right
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.right: actionButtons.left
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: contentColumn.implicitHeight
|
||||
clip: true
|
||||
|
||||
ClipboardThumbnail {
|
||||
id: thumbnail
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
|
||||
height: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
|
||||
entry: root.entry
|
||||
entryType: root.entryType
|
||||
modal: root.modal
|
||||
listView: root.listView
|
||||
itemIndex: root.itemIndex
|
||||
}
|
||||
|
||||
Column {
|
||||
id: contentColumn
|
||||
anchors.left: thumbnail.right
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
switch (entryType) {
|
||||
case "image":
|
||||
return I18n.tr("Image") + " • " + entryPreview;
|
||||
case "long_text":
|
||||
return I18n.tr("Long Text");
|
||||
default:
|
||||
return I18n.tr("Text");
|
||||
}
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
font.weight: Font.Medium
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: entryPreview
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
maximumLineCount: entryType === "long_text" ? 3 : 1
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: 80
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPressed: mouse => {
|
||||
const pos = mouseArea.mapToItem(root, mouse.x, mouse.y);
|
||||
rippleLayer.trigger(pos.x, pos.y);
|
||||
}
|
||||
onClicked: copyRequested()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ DankModal {
|
||||
}
|
||||
|
||||
property string activeTab: "recents"
|
||||
onActiveTabChanged: {
|
||||
ClipboardService.selectedIndex = 0;
|
||||
ClipboardService.keyboardNavigationActive = false;
|
||||
}
|
||||
property bool showKeyboardHints: false
|
||||
property Component clipboardContent
|
||||
property int activeImageLoads: 0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
QtObject {
|
||||
@@ -13,15 +14,17 @@ QtObject {
|
||||
}
|
||||
|
||||
function selectNext() {
|
||||
if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0) {
|
||||
const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries;
|
||||
if (!entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
ClipboardService.keyboardNavigationActive = true;
|
||||
ClipboardService.selectedIndex = Math.min(ClipboardService.selectedIndex + 1, ClipboardService.clipboardEntries.length - 1);
|
||||
ClipboardService.selectedIndex = Math.min(ClipboardService.selectedIndex + 1, entries.length - 1);
|
||||
}
|
||||
|
||||
function selectPrevious() {
|
||||
if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0) {
|
||||
const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries;
|
||||
if (!entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
ClipboardService.keyboardNavigationActive = true;
|
||||
@@ -29,19 +32,25 @@ QtObject {
|
||||
}
|
||||
|
||||
function copySelected() {
|
||||
if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0 || ClipboardService.selectedIndex < 0 || ClipboardService.selectedIndex >= ClipboardService.clipboardEntries.length) {
|
||||
const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries;
|
||||
if (!entries || entries.length === 0 || ClipboardService.selectedIndex < 0 || ClipboardService.selectedIndex >= entries.length) {
|
||||
return;
|
||||
}
|
||||
const selectedEntry = ClipboardService.clipboardEntries[ClipboardService.selectedIndex];
|
||||
const selectedEntry = entries[ClipboardService.selectedIndex];
|
||||
modal.copyEntry(selectedEntry);
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
if (!ClipboardService.clipboardEntries || ClipboardService.clipboardEntries.length === 0 || ClipboardService.selectedIndex < 0 || ClipboardService.selectedIndex >= ClipboardService.clipboardEntries.length) {
|
||||
const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries;
|
||||
if (!entries || entries.length === 0 || ClipboardService.selectedIndex < 0 || ClipboardService.selectedIndex >= entries.length) {
|
||||
return;
|
||||
}
|
||||
const selectedEntry = ClipboardService.clipboardEntries[ClipboardService.selectedIndex];
|
||||
modal.deleteEntry(selectedEntry);
|
||||
const selectedEntry = entries[ClipboardService.selectedIndex];
|
||||
if (modal.activeTab === "saved") {
|
||||
modal.deletePinnedEntry(selectedEntry);
|
||||
} else {
|
||||
modal.deleteEntry(selectedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKey(event) {
|
||||
@@ -125,7 +134,11 @@ QtObject {
|
||||
case Qt.Key_Return:
|
||||
case Qt.Key_Enter:
|
||||
if (ClipboardService.keyboardNavigationActive) {
|
||||
modal.pasteSelected();
|
||||
if (SettingsData.clipboardEnterToPaste) {
|
||||
copySelected();
|
||||
} else {
|
||||
modal.pasteSelected();
|
||||
}
|
||||
event.accepted = true;
|
||||
}
|
||||
return;
|
||||
@@ -136,7 +149,11 @@ QtObject {
|
||||
switch (event.key) {
|
||||
case Qt.Key_Return:
|
||||
case Qt.Key_Enter:
|
||||
copySelected();
|
||||
if (SettingsData.clipboardEnterToPaste) {
|
||||
modal.pasteSelected();
|
||||
} else {
|
||||
copySelected();
|
||||
}
|
||||
event.accepted = true;
|
||||
return;
|
||||
case Qt.Key_Delete:
|
||||
|
||||
@@ -6,7 +6,12 @@ Rectangle {
|
||||
id: keyboardHints
|
||||
|
||||
property bool wtypeAvailable: false
|
||||
readonly property string hintsText: wtypeAvailable ? I18n.tr("Shift+Enter: Paste • Shift+Del: Clear All • Esc: Close") : I18n.tr("Shift+Del: Clear All • Esc: Close")
|
||||
property bool enterToPaste: false
|
||||
readonly property string hintsText: {
|
||||
if (!wtypeAvailable)
|
||||
return I18n.tr("Shift+Del: Clear All • Esc: Close");
|
||||
return enterToPaste ? I18n.tr("Shift+Enter: Copy • Shift+Del: Clear All • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Shift+Enter: Paste • Shift+Del: Clear All • Esc: Close");
|
||||
}
|
||||
|
||||
height: ClipboardConstants.keyboardHintsHeight
|
||||
radius: Theme.cornerRadius
|
||||
@@ -21,7 +26,7 @@ Rectangle {
|
||||
spacing: 2
|
||||
|
||||
StyledText {
|
||||
text: "↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help"
|
||||
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : "↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
@@ -3,7 +3,6 @@ import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -26,7 +25,7 @@ Item {
|
||||
property bool closeOnEscapeKey: true
|
||||
property bool closeOnBackgroundClick: true
|
||||
property string animationType: "scale"
|
||||
property int animationDuration: Theme.expressiveDurations.expressiveDefaultSpatial
|
||||
property int animationDuration: Theme.modalAnimationDuration
|
||||
property real animationScaleCollapsed: 0.96
|
||||
property real animationOffset: Theme.spacingL
|
||||
property list<real> animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial
|
||||
@@ -132,7 +131,7 @@ Item {
|
||||
|
||||
Timer {
|
||||
id: closeTimer
|
||||
interval: animationDuration + 120
|
||||
interval: animationDuration + 50
|
||||
onTriggered: {
|
||||
if (shouldBeVisible)
|
||||
return;
|
||||
@@ -284,9 +283,8 @@ Item {
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: root.animationsEnabled
|
||||
NumberAnimation {
|
||||
DankAnim {
|
||||
duration: root.animationDuration
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
@@ -332,27 +330,24 @@ Item {
|
||||
|
||||
Behavior on animX {
|
||||
enabled: root.animationsEnabled
|
||||
NumberAnimation {
|
||||
DankAnim {
|
||||
duration: root.animationDuration
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on animY {
|
||||
enabled: root.animationsEnabled
|
||||
NumberAnimation {
|
||||
DankAnim {
|
||||
duration: root.animationDuration
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on scaleValue {
|
||||
enabled: root.animationsEnabled
|
||||
NumberAnimation {
|
||||
DankAnim {
|
||||
duration: root.animationDuration
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
@@ -382,11 +377,11 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
DankRectangle {
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.backgroundColor
|
||||
borderColor: root.borderColor
|
||||
borderWidth: root.borderWidth
|
||||
border.color: root.borderColor
|
||||
border.width: root.borderWidth
|
||||
radius: root.cornerRadius
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ Item {
|
||||
property string activePluginId: ""
|
||||
property var collapsedSections: ({})
|
||||
property bool keyboardNavigationActive: false
|
||||
property bool active: false
|
||||
property var _modeSectionsCache: ({})
|
||||
property bool _queryDrivenSearch: false
|
||||
property bool _diskCacheConsumed: false
|
||||
property var sectionViewModes: ({})
|
||||
property var pluginViewPreferences: ({})
|
||||
property int gridColumns: SettingsData.appLauncherGridColumns
|
||||
@@ -38,16 +42,39 @@ Item {
|
||||
signal viewModeChanged(string sectionId, string mode)
|
||||
signal searchQueryRequested(string query)
|
||||
|
||||
onActiveChanged: {
|
||||
if (!active) {
|
||||
sections = [];
|
||||
flatModel = [];
|
||||
selectedItem = null;
|
||||
_clearModeCache();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onSortAppsAlphabeticallyChanged() {
|
||||
AppSearchService.invalidateLauncherCache();
|
||||
_clearModeCache();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: AppSearchService
|
||||
function onCacheVersionChanged() {
|
||||
if (!active)
|
||||
return;
|
||||
_clearModeCache();
|
||||
if (!searchQuery && searchMode === "all")
|
||||
performSearch();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: PluginService
|
||||
function onRequestLauncherUpdate(pluginId) {
|
||||
if (!active)
|
||||
return;
|
||||
if (activePluginId === pluginId) {
|
||||
if (activePluginCategories.length <= 1)
|
||||
loadPluginCategories(pluginId);
|
||||
@@ -204,7 +231,7 @@ Item {
|
||||
}
|
||||
|
||||
function setPluginViewPreference(pluginId, mode, enforced) {
|
||||
var prefs = pluginViewPreferences;
|
||||
var prefs = Object.assign({}, pluginViewPreferences);
|
||||
prefs[pluginId] = {
|
||||
mode: mode,
|
||||
enforced: enforced || false
|
||||
@@ -230,7 +257,7 @@ Item {
|
||||
if (pref && pref.mode) {
|
||||
setPluginViewPreference(sectionId, pref.mode, pref.enforced);
|
||||
} else {
|
||||
var prefs = pluginViewPreferences;
|
||||
var prefs = Object.assign({}, pluginViewPreferences);
|
||||
delete prefs[sectionId];
|
||||
pluginViewPreferences = prefs;
|
||||
}
|
||||
@@ -247,13 +274,22 @@ Item {
|
||||
}
|
||||
|
||||
property int _searchVersion: 0
|
||||
property bool _pluginPhasePending: false
|
||||
property bool _pluginPhaseForceFirst: false
|
||||
property var _phase1Items: []
|
||||
|
||||
Timer {
|
||||
id: searchDebounce
|
||||
interval: searchMode === "all" && searchQuery.length > 0 ? 90 : 60
|
||||
interval: 60
|
||||
onTriggered: root.performSearch()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: pluginPhaseTimer
|
||||
interval: 1
|
||||
onTriggered: root._performPluginPhase()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: fileSearchDebounce
|
||||
interval: 200
|
||||
@@ -266,6 +302,10 @@ Item {
|
||||
|
||||
function setSearchQuery(query) {
|
||||
_searchVersion++;
|
||||
_queryDrivenSearch = true;
|
||||
_pluginPhasePending = false;
|
||||
_phase1Items = [];
|
||||
pluginPhaseTimer.stop();
|
||||
searchQuery = query;
|
||||
searchDebounce.restart();
|
||||
|
||||
@@ -324,6 +364,12 @@ Item {
|
||||
activePluginCategory = "";
|
||||
pluginFilter = "";
|
||||
collapsedSections = {};
|
||||
_clearModeCache();
|
||||
_queryDrivenSearch = false;
|
||||
_pluginPhasePending = false;
|
||||
_pluginPhaseForceFirst = false;
|
||||
_phase1Items = [];
|
||||
pluginPhaseTimer.stop();
|
||||
}
|
||||
|
||||
function loadPluginCategories(pluginId) {
|
||||
@@ -369,7 +415,11 @@ Item {
|
||||
return false;
|
||||
}
|
||||
|
||||
function preserveSelectionAfterUpdate() {
|
||||
function preserveSelectionAfterUpdate(forceFirst) {
|
||||
if (forceFirst)
|
||||
return function () {
|
||||
return getFirstItemIndex();
|
||||
};
|
||||
var previousSelectedId = selectedItem?.id || "";
|
||||
return function (newFlatModel) {
|
||||
if (!previousSelectedId)
|
||||
@@ -385,24 +435,60 @@ Item {
|
||||
function performSearch() {
|
||||
var currentVersion = _searchVersion;
|
||||
isSearching = true;
|
||||
var restoreSelection = preserveSelectionAfterUpdate();
|
||||
var shouldResetSelection = _queryDrivenSearch;
|
||||
_queryDrivenSearch = false;
|
||||
var restoreSelection = preserveSelectionAfterUpdate(shouldResetSelection);
|
||||
|
||||
var cachedSections = AppSearchService.getCachedDefaultSections();
|
||||
if (!cachedSections && !_diskCacheConsumed && !searchQuery && searchMode === "all" && !pluginFilter) {
|
||||
_diskCacheConsumed = true;
|
||||
var diskSections = _loadDiskCache();
|
||||
if (diskSections) {
|
||||
activePluginId = "";
|
||||
activePluginName = "";
|
||||
activePluginCategories = [];
|
||||
activePluginCategory = "";
|
||||
clearActivePluginViewPreference();
|
||||
for (var i = 0; i < diskSections.length; i++) {
|
||||
if (collapsedSections[diskSections[i].id] !== undefined)
|
||||
diskSections[i].collapsed = collapsedSections[diskSections[i].id];
|
||||
}
|
||||
_applyHighlights(diskSections, "");
|
||||
flatModel = Scorer.flattenSections(diskSections);
|
||||
sections = diskSections;
|
||||
selectedFlatIndex = restoreSelection(flatModel);
|
||||
updateSelectedItem();
|
||||
isSearching = false;
|
||||
searchCompleted();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedSections && !searchQuery && searchMode === "all" && !pluginFilter) {
|
||||
activePluginId = "";
|
||||
activePluginName = "";
|
||||
activePluginCategories = [];
|
||||
activePluginCategory = "";
|
||||
clearActivePluginViewPreference();
|
||||
sections = cachedSections.map(function (s) {
|
||||
var copy = Object.assign({}, s, {
|
||||
items: s.items ? s.items.slice() : []
|
||||
var modeCache = _getCachedModeData("all");
|
||||
if (modeCache) {
|
||||
_applyHighlights(modeCache.sections, "");
|
||||
sections = modeCache.sections;
|
||||
flatModel = modeCache.flatModel;
|
||||
} else {
|
||||
var newSections = cachedSections.map(function (s) {
|
||||
var copy = Object.assign({}, s, {
|
||||
items: s.items ? s.items.slice() : []
|
||||
});
|
||||
if (collapsedSections[s.id] !== undefined)
|
||||
copy.collapsed = collapsedSections[s.id];
|
||||
return copy;
|
||||
});
|
||||
if (collapsedSections[s.id] !== undefined)
|
||||
copy.collapsed = collapsedSections[s.id];
|
||||
return copy;
|
||||
});
|
||||
flatModel = Scorer.flattenSections(sections);
|
||||
_applyHighlights(newSections, "");
|
||||
flatModel = Scorer.flattenSections(newSections);
|
||||
sections = newSections;
|
||||
_setCachedModeData("all", sections, flatModel);
|
||||
}
|
||||
selectedFlatIndex = restoreSelection(flatModel);
|
||||
updateSelectedItem();
|
||||
isSearching = false;
|
||||
@@ -423,7 +509,8 @@ Item {
|
||||
loadPluginCategories(triggerMatch.pluginId);
|
||||
|
||||
var pluginItems = getPluginItems(triggerMatch.pluginId, triggerMatch.query);
|
||||
allItems = allItems.concat(pluginItems);
|
||||
for (var k = 0; k < pluginItems.length; k++)
|
||||
allItems.push(pluginItems[k]);
|
||||
|
||||
if (triggerMatch.isBuiltIn) {
|
||||
var builtInItems = AppSearchService.getBuiltInLauncherItems(triggerMatch.pluginId, triggerMatch.query);
|
||||
@@ -435,17 +522,19 @@ Item {
|
||||
var dynamicDefs = buildDynamicSectionDefs(allItems);
|
||||
var scoredItems = Scorer.scoreItems(allItems, triggerMatch.query, getFrecencyForItem);
|
||||
var sortAlpha = !triggerMatch.query && SettingsData.sortAppsAlphabetically;
|
||||
sections = Scorer.groupBySection(scoredItems, dynamicDefs, sortAlpha, 500);
|
||||
var newSections = Scorer.groupBySection(scoredItems, dynamicDefs, sortAlpha, 500);
|
||||
|
||||
for (var sid in collapsedSections) {
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
if (sections[i].id === sid) {
|
||||
sections[i].collapsed = collapsedSections[sid];
|
||||
for (var i = 0; i < newSections.length; i++) {
|
||||
if (newSections[i].id === sid) {
|
||||
newSections[i].collapsed = collapsedSections[sid];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flatModel = Scorer.flattenSections(sections);
|
||||
_applyHighlights(newSections, triggerMatch.query);
|
||||
flatModel = Scorer.flattenSections(newSections);
|
||||
sections = newSections;
|
||||
selectedFlatIndex = restoreSelection(flatModel);
|
||||
updateSelectedItem();
|
||||
|
||||
@@ -475,18 +564,28 @@ Item {
|
||||
if (searchMode === "apps") {
|
||||
var cachedSections = AppSearchService.getCachedDefaultSections();
|
||||
if (cachedSections && !searchQuery) {
|
||||
var appSectionIds = ["favorites", "apps"];
|
||||
sections = cachedSections.filter(function (s) {
|
||||
return appSectionIds.indexOf(s.id) !== -1;
|
||||
}).map(function (s) {
|
||||
var copy = Object.assign({}, s, {
|
||||
items: s.items ? s.items.slice() : []
|
||||
var modeCache = _getCachedModeData("apps");
|
||||
if (modeCache) {
|
||||
_applyHighlights(modeCache.sections, "");
|
||||
sections = modeCache.sections;
|
||||
flatModel = modeCache.flatModel;
|
||||
} else {
|
||||
var appSectionIds = ["favorites", "apps"];
|
||||
var newSections = cachedSections.filter(function (s) {
|
||||
return appSectionIds.indexOf(s.id) !== -1;
|
||||
}).map(function (s) {
|
||||
var copy = Object.assign({}, s, {
|
||||
items: s.items ? s.items.slice() : []
|
||||
});
|
||||
if (collapsedSections[s.id] !== undefined)
|
||||
copy.collapsed = collapsedSections[s.id];
|
||||
return copy;
|
||||
});
|
||||
if (collapsedSections[s.id] !== undefined)
|
||||
copy.collapsed = collapsedSections[s.id];
|
||||
return copy;
|
||||
});
|
||||
flatModel = Scorer.flattenSections(sections);
|
||||
_applyHighlights(newSections, "");
|
||||
flatModel = Scorer.flattenSections(newSections);
|
||||
sections = newSections;
|
||||
_setCachedModeData("apps", sections, flatModel);
|
||||
}
|
||||
selectedFlatIndex = restoreSelection(flatModel);
|
||||
updateSelectedItem();
|
||||
isSearching = false;
|
||||
@@ -501,17 +600,19 @@ Item {
|
||||
|
||||
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
|
||||
var sortAlpha = !searchQuery && SettingsData.sortAppsAlphabetically;
|
||||
sections = Scorer.groupBySection(scoredItems, sectionDefinitions, sortAlpha, searchQuery ? 50 : 500);
|
||||
var newSections = Scorer.groupBySection(scoredItems, sectionDefinitions, sortAlpha, searchQuery ? 50 : 500);
|
||||
|
||||
for (var sid in collapsedSections) {
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
if (sections[i].id === sid) {
|
||||
sections[i].collapsed = collapsedSections[sid];
|
||||
for (var i = 0; i < newSections.length; i++) {
|
||||
if (newSections[i].id === sid) {
|
||||
newSections[i].collapsed = collapsedSections[sid];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flatModel = Scorer.flattenSections(sections);
|
||||
_applyHighlights(newSections, searchQuery);
|
||||
flatModel = Scorer.flattenSections(newSections);
|
||||
sections = newSections;
|
||||
selectedFlatIndex = restoreSelection(flatModel);
|
||||
updateSelectedItem();
|
||||
|
||||
@@ -523,13 +624,15 @@ Item {
|
||||
if (searchMode === "plugins") {
|
||||
if (!searchQuery && !pluginFilter) {
|
||||
var browseItems = getPluginBrowseItems();
|
||||
allItems = allItems.concat(browseItems);
|
||||
for (var k = 0; k < browseItems.length; k++)
|
||||
allItems.push(browseItems[k]);
|
||||
} else if (pluginFilter) {
|
||||
var isBuiltInFilter = !!AppSearchService.builtInPlugins[pluginFilter];
|
||||
applyActivePluginViewPreference(pluginFilter, isBuiltInFilter);
|
||||
|
||||
var filterItems = getPluginItems(pluginFilter, searchQuery);
|
||||
allItems = allItems.concat(filterItems);
|
||||
for (var k = 0; k < filterItems.length; k++)
|
||||
allItems.push(filterItems[k]);
|
||||
|
||||
var builtInItems = AppSearchService.getBuiltInLauncherItems(pluginFilter, searchQuery);
|
||||
for (var j = 0; j < builtInItems.length; j++) {
|
||||
@@ -540,7 +643,8 @@ Item {
|
||||
for (var i = 0; i < emptyTriggerPlugins.length; i++) {
|
||||
var pluginId = emptyTriggerPlugins[i];
|
||||
var pItems = getPluginItems(pluginId, searchQuery);
|
||||
allItems = allItems.concat(pItems);
|
||||
for (var k = 0; k < pItems.length; k++)
|
||||
allItems.push(pItems[k]);
|
||||
}
|
||||
|
||||
var builtInLauncherPlugins = getBuiltInEmptyTriggerLaunchers();
|
||||
@@ -556,17 +660,19 @@ Item {
|
||||
var dynamicDefs = buildDynamicSectionDefs(allItems);
|
||||
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
|
||||
var sortAlpha = !searchQuery && SettingsData.sortAppsAlphabetically;
|
||||
sections = Scorer.groupBySection(scoredItems, dynamicDefs, sortAlpha, 500);
|
||||
var newSections = Scorer.groupBySection(scoredItems, dynamicDefs, sortAlpha, 500);
|
||||
|
||||
for (var sid in collapsedSections) {
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
if (sections[i].id === sid) {
|
||||
sections[i].collapsed = collapsedSections[sid];
|
||||
for (var i = 0; i < newSections.length; i++) {
|
||||
if (newSections[i].id === sid) {
|
||||
newSections[i].collapsed = collapsedSections[sid];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flatModel = Scorer.flattenSections(sections);
|
||||
_applyHighlights(newSections, searchQuery);
|
||||
flatModel = Scorer.flattenSections(newSections);
|
||||
sections = newSections;
|
||||
selectedFlatIndex = restoreSelection(flatModel);
|
||||
updateSelectedItem();
|
||||
|
||||
@@ -577,31 +683,26 @@ Item {
|
||||
|
||||
var calculatorResult = evaluateCalculator(searchQuery);
|
||||
if (calculatorResult) {
|
||||
calculatorResult._preScored = 12000;
|
||||
allItems.push(calculatorResult);
|
||||
}
|
||||
|
||||
var apps = searchApps(searchQuery);
|
||||
allItems = allItems.concat(apps);
|
||||
for (var i = 0; i < apps.length; i++) {
|
||||
if (searchQuery)
|
||||
apps[i]._preScored = 11000 - i;
|
||||
allItems.push(apps[i]);
|
||||
}
|
||||
|
||||
if (searchMode === "all") {
|
||||
var includePlugins = !searchQuery || searchQuery.length >= 2;
|
||||
if (searchQuery && includePlugins) {
|
||||
var allPluginsOrdered = getAllVisiblePluginsOrdered();
|
||||
var maxPerPlugin = 10;
|
||||
for (var i = 0; i < allPluginsOrdered.length; i++) {
|
||||
var plugin = allPluginsOrdered[i];
|
||||
if (plugin.isBuiltIn) {
|
||||
var blItems = AppSearchService.getBuiltInLauncherItems(plugin.id, searchQuery);
|
||||
var blLimit = Math.min(blItems.length, maxPerPlugin);
|
||||
for (var j = 0; j < blLimit; j++)
|
||||
allItems.push(transformBuiltInLauncherItem(blItems[j], plugin.id));
|
||||
} else {
|
||||
var pItems = getPluginItems(plugin.id, searchQuery);
|
||||
if (pItems.length > maxPerPlugin)
|
||||
pItems = pItems.slice(0, maxPerPlugin);
|
||||
allItems = allItems.concat(pItems);
|
||||
}
|
||||
}
|
||||
if (searchQuery && searchQuery.length >= 2) {
|
||||
_pluginPhasePending = true;
|
||||
_pluginPhaseForceFirst = shouldResetSelection;
|
||||
_phase1Items = allItems;
|
||||
pluginPhaseTimer.restart();
|
||||
isSearching = true;
|
||||
searchCompleted();
|
||||
return;
|
||||
} else if (!searchQuery) {
|
||||
var emptyTriggerOrdered = getEmptyTriggerPluginsOrdered();
|
||||
for (var i = 0; i < emptyTriggerOrdered.length; i++) {
|
||||
@@ -612,12 +713,14 @@ Item {
|
||||
allItems.push(transformBuiltInLauncherItem(blItems[j], plugin.id));
|
||||
} else {
|
||||
var pItems = getPluginItems(plugin.id, searchQuery);
|
||||
allItems = allItems.concat(pItems);
|
||||
for (var j = 0; j < pItems.length; j++)
|
||||
allItems.push(pItems[j]);
|
||||
}
|
||||
}
|
||||
|
||||
var browseItems = getPluginBrowseItems();
|
||||
allItems = allItems.concat(browseItems);
|
||||
for (var i = 0; i < browseItems.length; i++)
|
||||
allItems.push(browseItems[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,16 +747,76 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
_applyHighlights(newSections, searchQuery);
|
||||
flatModel = Scorer.flattenSections(newSections);
|
||||
sections = newSections;
|
||||
flatModel = Scorer.flattenSections(sections);
|
||||
|
||||
if (!AppSearchService.isCacheValid() && !searchQuery && searchMode === "all" && !pluginFilter) {
|
||||
AppSearchService.setCachedDefaultSections(sections, flatModel);
|
||||
_saveDiskCache(sections);
|
||||
}
|
||||
|
||||
selectedFlatIndex = restoreSelection(flatModel);
|
||||
updateSelectedItem();
|
||||
|
||||
isSearching = _pluginPhasePending;
|
||||
searchCompleted();
|
||||
}
|
||||
|
||||
function _performPluginPhase() {
|
||||
_pluginPhasePending = false;
|
||||
if (!searchQuery || searchQuery.length < 2 || searchMode !== "all")
|
||||
return;
|
||||
|
||||
var currentVersion = _searchVersion;
|
||||
var restoreSelection = preserveSelectionAfterUpdate(_pluginPhaseForceFirst);
|
||||
var allItems = _phase1Items;
|
||||
_phase1Items = [];
|
||||
|
||||
var allPluginsOrdered = getAllVisiblePluginsOrdered();
|
||||
var maxPerPlugin = 10;
|
||||
for (var i = 0; i < allPluginsOrdered.length; i++) {
|
||||
if (currentVersion !== _searchVersion)
|
||||
return;
|
||||
var plugin = allPluginsOrdered[i];
|
||||
if (plugin.isBuiltIn) {
|
||||
var blItems = AppSearchService.getBuiltInLauncherItems(plugin.id, searchQuery);
|
||||
var blLimit = Math.min(blItems.length, maxPerPlugin);
|
||||
for (var j = 0; j < blLimit; j++) {
|
||||
var item = transformBuiltInLauncherItem(blItems[j], plugin.id);
|
||||
item._preScored = 900 - j;
|
||||
allItems.push(item);
|
||||
}
|
||||
} else {
|
||||
var pItems = getPluginItems(plugin.id, searchQuery, maxPerPlugin);
|
||||
for (var j = 0; j < pItems.length; j++) {
|
||||
pItems[j]._preScored = 900 - j;
|
||||
allItems.push(pItems[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentVersion !== _searchVersion)
|
||||
return;
|
||||
|
||||
var dynamicDefs = buildDynamicSectionDefs(allItems);
|
||||
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
|
||||
var newSections = Scorer.groupBySection(scoredItems, dynamicDefs, false, 50);
|
||||
|
||||
if (currentVersion !== _searchVersion)
|
||||
return;
|
||||
|
||||
for (var i = 0; i < newSections.length; i++) {
|
||||
var sid = newSections[i].id;
|
||||
if (collapsedSections[sid] !== undefined)
|
||||
newSections[i].collapsed = collapsedSections[sid];
|
||||
}
|
||||
|
||||
_applyHighlights(newSections, searchQuery);
|
||||
flatModel = Scorer.flattenSections(newSections);
|
||||
sections = newSections;
|
||||
selectedFlatIndex = restoreSelection(flatModel);
|
||||
updateSelectedItem();
|
||||
isSearching = false;
|
||||
searchCompleted();
|
||||
}
|
||||
@@ -704,7 +867,8 @@ Item {
|
||||
icon: "folder",
|
||||
priority: 4,
|
||||
items: fileItems,
|
||||
collapsed: collapsedSections["files"] || false
|
||||
collapsed: collapsedSections["files"] || false,
|
||||
flatStartIndex: 0
|
||||
};
|
||||
|
||||
var newSections;
|
||||
@@ -723,9 +887,9 @@ Item {
|
||||
newSections.sort(function (a, b) {
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
_applyHighlights(newSections, searchQuery);
|
||||
flatModel = Scorer.flattenSections(newSections);
|
||||
sections = newSections;
|
||||
|
||||
flatModel = Scorer.flattenSections(sections);
|
||||
if (selectedFlatIndex >= flatModel.length) {
|
||||
selectedFlatIndex = getFirstItemIndex();
|
||||
}
|
||||
@@ -921,11 +1085,12 @@ Item {
|
||||
return sortPluginIdsByOrder(visible);
|
||||
}
|
||||
|
||||
function getPluginItems(pluginId, query) {
|
||||
function getPluginItems(pluginId, query, limit) {
|
||||
var items = AppSearchService.getPluginItemsForPlugin(pluginId, query);
|
||||
var count = limit > 0 && limit < items.length ? limit : items.length;
|
||||
var transformed = [];
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
for (var i = 0; i < count; i++) {
|
||||
transformed.push(transformPluginItem(items[i], pluginId));
|
||||
}
|
||||
|
||||
@@ -1055,6 +1220,105 @@ Item {
|
||||
return Nav.getFirstItemIndex(flatModel);
|
||||
}
|
||||
|
||||
function _getCachedModeData(mode) {
|
||||
return _modeSectionsCache[mode] || null;
|
||||
}
|
||||
|
||||
function _setCachedModeData(mode, sectionsData, flatModelData) {
|
||||
var cache = Object.assign({}, _modeSectionsCache);
|
||||
cache[mode] = {
|
||||
sections: sectionsData,
|
||||
flatModel: flatModelData
|
||||
};
|
||||
_modeSectionsCache = cache;
|
||||
}
|
||||
|
||||
function _clearModeCache() {
|
||||
_modeSectionsCache = {};
|
||||
}
|
||||
|
||||
function _saveDiskCache(sectionsData) {
|
||||
var serializable = [];
|
||||
for (var i = 0; i < sectionsData.length; i++) {
|
||||
var s = sectionsData[i];
|
||||
var items = [];
|
||||
var srcItems = s.items || [];
|
||||
for (var j = 0; j < srcItems.length; j++) {
|
||||
var it = srcItems[j];
|
||||
items.push({
|
||||
id: it.id,
|
||||
type: it.type,
|
||||
name: it.name || "",
|
||||
subtitle: it.subtitle || "",
|
||||
icon: it.icon || "",
|
||||
iconType: it.iconType || "image",
|
||||
iconFull: it.iconFull || "",
|
||||
section: it.section || "",
|
||||
isCore: it.isCore || false,
|
||||
isBuiltInLauncher: it.isBuiltInLauncher || false,
|
||||
pluginId: it.pluginId || ""
|
||||
});
|
||||
}
|
||||
serializable.push({
|
||||
id: s.id,
|
||||
title: s.title || "",
|
||||
icon: s.icon || "",
|
||||
priority: s.priority || 0,
|
||||
items: items
|
||||
});
|
||||
}
|
||||
CacheData.saveLauncherCache(serializable);
|
||||
}
|
||||
|
||||
function _loadDiskCache() {
|
||||
var cached = CacheData.loadLauncherCache();
|
||||
if (!cached || !Array.isArray(cached) || cached.length === 0)
|
||||
return null;
|
||||
|
||||
var sectionsData = [];
|
||||
for (var i = 0; i < cached.length; i++) {
|
||||
var s = cached[i];
|
||||
var items = [];
|
||||
var srcItems = s.items || [];
|
||||
for (var j = 0; j < srcItems.length; j++) {
|
||||
var it = srcItems[j];
|
||||
items.push({
|
||||
id: it.id || "",
|
||||
type: it.type || "app",
|
||||
name: it.name || "",
|
||||
subtitle: it.subtitle || "",
|
||||
icon: it.icon || "",
|
||||
iconType: it.iconType || "image",
|
||||
iconFull: it.iconFull || "",
|
||||
section: it.section || "",
|
||||
isCore: it.isCore || false,
|
||||
isBuiltInLauncher: it.isBuiltInLauncher || false,
|
||||
pluginId: it.pluginId || "",
|
||||
data: {
|
||||
id: it.id
|
||||
},
|
||||
actions: [],
|
||||
primaryAction: null,
|
||||
_diskCached: true,
|
||||
_hName: "",
|
||||
_hSub: "",
|
||||
_hRich: false,
|
||||
_preScored: undefined
|
||||
});
|
||||
}
|
||||
sectionsData.push({
|
||||
id: s.id || "",
|
||||
title: s.title || "",
|
||||
icon: s.icon || "",
|
||||
priority: s.priority || 0,
|
||||
items: items,
|
||||
collapsed: false,
|
||||
flatStartIndex: 0
|
||||
});
|
||||
}
|
||||
return sectionsData;
|
||||
}
|
||||
|
||||
function updateSelectedItem() {
|
||||
if (selectedFlatIndex >= 0 && selectedFlatIndex < flatModel.length) {
|
||||
var entry = flatModel[selectedFlatIndex];
|
||||
@@ -1064,6 +1328,48 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
function _applyHighlights(sectionsData, query) {
|
||||
if (!query || query.length === 0) {
|
||||
for (var i = 0; i < sectionsData.length; i++) {
|
||||
var items = sectionsData[i].items;
|
||||
for (var j = 0; j < items.length; j++) {
|
||||
var item = items[j];
|
||||
item._hName = item.name || "";
|
||||
item._hSub = item.subtitle || "";
|
||||
item._hRich = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var highlightColor = Theme.primary;
|
||||
var nameColor = Theme.surfaceText;
|
||||
var subColor = Theme.surfaceVariantText;
|
||||
var lowerQuery = query.toLowerCase();
|
||||
|
||||
for (var i = 0; i < sectionsData.length; i++) {
|
||||
var items = sectionsData[i].items;
|
||||
for (var j = 0; j < items.length; j++) {
|
||||
var item = items[j];
|
||||
item._hName = _highlightField(item.name || "", lowerQuery, query.length, nameColor, highlightColor);
|
||||
item._hSub = _highlightField(item.subtitle || "", lowerQuery, query.length, subColor, highlightColor);
|
||||
item._hRich = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _highlightField(text, lowerQuery, queryLen, baseColor, highlightColor) {
|
||||
if (!text)
|
||||
return "";
|
||||
var idx = text.toLowerCase().indexOf(lowerQuery);
|
||||
if (idx === -1)
|
||||
return text;
|
||||
var before = text.substring(0, idx);
|
||||
var match = text.substring(idx, idx + queryLen);
|
||||
var after = text.substring(idx + queryLen);
|
||||
return '<span style="color:' + baseColor + '">' + before + '</span><span style="color:' + highlightColor + '; font-weight:600">' + match + '</span><span style="color:' + baseColor + '">' + after + '</span>';
|
||||
}
|
||||
|
||||
function getCurrentSectionViewMode() {
|
||||
if (selectedFlatIndex < 0 || selectedFlatIndex >= flatModel.length)
|
||||
return "list";
|
||||
@@ -1077,8 +1383,14 @@ Item {
|
||||
return Nav.getGridColumns(getSectionViewMode(sectionId), gridColumns);
|
||||
}
|
||||
|
||||
function _cancelPendingSelectionReset() {
|
||||
_queryDrivenSearch = false;
|
||||
_pluginPhaseForceFirst = false;
|
||||
}
|
||||
|
||||
function selectNext() {
|
||||
keyboardNavigationActive = true;
|
||||
_cancelPendingSelectionReset();
|
||||
var newIndex = Nav.calculateNextIndex(flatModel, selectedFlatIndex, null, null, gridColumns, getSectionViewMode);
|
||||
if (newIndex !== selectedFlatIndex) {
|
||||
selectedFlatIndex = newIndex;
|
||||
@@ -1088,6 +1400,7 @@ Item {
|
||||
|
||||
function selectPrevious() {
|
||||
keyboardNavigationActive = true;
|
||||
_cancelPendingSelectionReset();
|
||||
var newIndex = Nav.calculatePrevIndex(flatModel, selectedFlatIndex, null, null, gridColumns, getSectionViewMode);
|
||||
if (newIndex !== selectedFlatIndex) {
|
||||
selectedFlatIndex = newIndex;
|
||||
@@ -1097,6 +1410,7 @@ Item {
|
||||
|
||||
function selectRight() {
|
||||
keyboardNavigationActive = true;
|
||||
_cancelPendingSelectionReset();
|
||||
var newIndex = Nav.calculateRightIndex(flatModel, selectedFlatIndex, getSectionViewMode);
|
||||
if (newIndex !== selectedFlatIndex) {
|
||||
selectedFlatIndex = newIndex;
|
||||
@@ -1106,6 +1420,7 @@ Item {
|
||||
|
||||
function selectLeft() {
|
||||
keyboardNavigationActive = true;
|
||||
_cancelPendingSelectionReset();
|
||||
var newIndex = Nav.calculateLeftIndex(flatModel, selectedFlatIndex, getSectionViewMode);
|
||||
if (newIndex !== selectedFlatIndex) {
|
||||
selectedFlatIndex = newIndex;
|
||||
@@ -1115,6 +1430,7 @@ Item {
|
||||
|
||||
function selectNextSection() {
|
||||
keyboardNavigationActive = true;
|
||||
_cancelPendingSelectionReset();
|
||||
var newIndex = Nav.calculateNextSectionIndex(flatModel, selectedFlatIndex);
|
||||
if (newIndex !== selectedFlatIndex) {
|
||||
selectedFlatIndex = newIndex;
|
||||
@@ -1124,6 +1440,7 @@ Item {
|
||||
|
||||
function selectPreviousSection() {
|
||||
keyboardNavigationActive = true;
|
||||
_cancelPendingSelectionReset();
|
||||
var newIndex = Nav.calculatePrevSectionIndex(flatModel, selectedFlatIndex);
|
||||
if (newIndex !== selectedFlatIndex) {
|
||||
selectedFlatIndex = newIndex;
|
||||
@@ -1133,6 +1450,7 @@ Item {
|
||||
|
||||
function selectPageDown(visibleItems) {
|
||||
keyboardNavigationActive = true;
|
||||
_cancelPendingSelectionReset();
|
||||
var newIndex = Nav.calculatePageDownIndex(flatModel, selectedFlatIndex, visibleItems);
|
||||
if (newIndex !== selectedFlatIndex) {
|
||||
selectedFlatIndex = newIndex;
|
||||
@@ -1142,6 +1460,7 @@ Item {
|
||||
|
||||
function selectPageUp(visibleItems) {
|
||||
keyboardNavigationActive = true;
|
||||
_cancelPendingSelectionReset();
|
||||
var newIndex = Nav.calculatePageUpIndex(flatModel, selectedFlatIndex, visibleItems);
|
||||
if (newIndex !== selectedFlatIndex) {
|
||||
selectedFlatIndex = newIndex;
|
||||
@@ -1158,6 +1477,7 @@ Item {
|
||||
}
|
||||
|
||||
function toggleSection(sectionId) {
|
||||
_clearModeCache();
|
||||
var newCollapsed = Object.assign({}, collapsedSections);
|
||||
var currentState = newCollapsed[sectionId];
|
||||
|
||||
@@ -1181,10 +1501,9 @@ Item {
|
||||
});
|
||||
}
|
||||
}
|
||||
flatModel = Scorer.flattenSections(newSections);
|
||||
sections = newSections;
|
||||
|
||||
flatModel = Scorer.flattenSections(sections);
|
||||
|
||||
if (selectedFlatIndex >= flatModel.length) {
|
||||
selectedFlatIndex = getFirstItemIndex();
|
||||
}
|
||||
@@ -1192,6 +1511,10 @@ Item {
|
||||
}
|
||||
|
||||
function executeSelected() {
|
||||
if (searchDebounce.running) {
|
||||
searchDebounce.stop();
|
||||
performSearch();
|
||||
}
|
||||
if (!selectedItem)
|
||||
return;
|
||||
executeItem(selectedItem);
|
||||
|
||||
@@ -4,7 +4,6 @@ import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -14,10 +13,14 @@ Item {
|
||||
property bool spotlightOpen: false
|
||||
property bool keyboardActive: false
|
||||
property bool contentVisible: false
|
||||
property alias spotlightContent: launcherContent
|
||||
property var spotlightContent: launcherContentLoader.item
|
||||
property bool openedFromOverview: false
|
||||
property bool isClosing: false
|
||||
property bool _windowEnabled: true
|
||||
property bool _pendingInitialize: false
|
||||
property string _pendingQuery: ""
|
||||
property string _pendingMode: ""
|
||||
readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose
|
||||
|
||||
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
|
||||
readonly property var effectiveScreen: launcherWindow.screen
|
||||
@@ -76,7 +79,22 @@ Item {
|
||||
|
||||
signal dialogClosed
|
||||
|
||||
function _ensureContentLoadedAndInitialize(query, mode) {
|
||||
_pendingQuery = query || "";
|
||||
_pendingMode = mode || "";
|
||||
_pendingInitialize = true;
|
||||
contentVisible = true;
|
||||
launcherContentLoader.active = true;
|
||||
|
||||
if (spotlightContent) {
|
||||
_initializeAndShow(_pendingQuery, _pendingMode);
|
||||
_pendingInitialize = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _initializeAndShow(query, mode) {
|
||||
if (!spotlightContent)
|
||||
return;
|
||||
contentVisible = true;
|
||||
spotlightContent.searchField.forceActiveFocus();
|
||||
|
||||
@@ -90,6 +108,8 @@ Item {
|
||||
spotlightContent.controller.activePluginName = "";
|
||||
spotlightContent.controller.pluginFilter = "";
|
||||
spotlightContent.controller.collapsedSections = {};
|
||||
spotlightContent.controller.selectedFlatIndex = 0;
|
||||
spotlightContent.controller.selectedItem = null;
|
||||
if (query) {
|
||||
spotlightContent.controller.setSearchQuery(query);
|
||||
} else {
|
||||
@@ -120,7 +140,7 @@ Item {
|
||||
if (useHyprlandFocusGrab)
|
||||
focusGrab.active = true;
|
||||
|
||||
_initializeAndShow("");
|
||||
_ensureContentLoadedAndInitialize("", "");
|
||||
}
|
||||
|
||||
function showWithQuery(query) {
|
||||
@@ -138,7 +158,7 @@ Item {
|
||||
if (useHyprlandFocusGrab)
|
||||
focusGrab.active = true;
|
||||
|
||||
_initializeAndShow(query);
|
||||
_ensureContentLoadedAndInitialize(query, "");
|
||||
}
|
||||
|
||||
function hide() {
|
||||
@@ -175,7 +195,7 @@ Item {
|
||||
if (useHyprlandFocusGrab)
|
||||
focusGrab.active = true;
|
||||
|
||||
_initializeAndShow("", mode);
|
||||
_ensureContentLoadedAndInitialize("", mode);
|
||||
}
|
||||
|
||||
function toggleWithMode(mode) {
|
||||
@@ -196,10 +216,12 @@ Item {
|
||||
|
||||
Timer {
|
||||
id: closeCleanupTimer
|
||||
interval: Theme.expressiveDurations.expressiveFastSpatial + 50
|
||||
interval: Theme.modalAnimationDuration + 50
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
isClosing = false;
|
||||
if (root.unloadContentOnClose)
|
||||
launcherContentLoader.active = false;
|
||||
dialogClosed();
|
||||
}
|
||||
}
|
||||
@@ -262,7 +284,7 @@ Item {
|
||||
|
||||
PanelWindow {
|
||||
id: launcherWindow
|
||||
visible: root._windowEnabled
|
||||
visible: root._windowEnabled && (!root.unloadContentOnClose || spotlightOpen || isClosing)
|
||||
color: "transparent"
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
@@ -307,10 +329,9 @@ Item {
|
||||
visible: contentVisible || opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.expressiveDurations.expressiveFastSpatial
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveFastSpatial : Theme.expressiveCurves.emphasized
|
||||
DankAnim {
|
||||
duration: Theme.modalAnimationDuration
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,26 +364,24 @@ Item {
|
||||
transformOrigin: Item.Center
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.expressiveDurations.fast
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveFastSpatial : Theme.expressiveCurves.standardAccel
|
||||
DankAnim {
|
||||
duration: Theme.modalAnimationDuration
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Theme.expressiveDurations.fast
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveFastSpatial : Theme.expressiveCurves.standardAccel
|
||||
DankAnim {
|
||||
duration: Theme.modalAnimationDuration
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
|
||||
}
|
||||
}
|
||||
|
||||
DankRectangle {
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.backgroundColor
|
||||
borderColor: root.borderColor
|
||||
borderWidth: root.borderWidth
|
||||
border.color: root.borderColor
|
||||
border.width: root.borderWidth
|
||||
radius: root.cornerRadius
|
||||
}
|
||||
|
||||
@@ -375,10 +394,22 @@ Item {
|
||||
anchors.fill: parent
|
||||
focus: keyboardActive
|
||||
|
||||
LauncherContent {
|
||||
id: launcherContent
|
||||
Loader {
|
||||
id: launcherContentLoader
|
||||
anchors.fill: parent
|
||||
parentModal: root
|
||||
active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize
|
||||
asynchronous: false
|
||||
sourceComponent: LauncherContent {
|
||||
focus: true
|
||||
parentModal: root
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
if (root._pendingInitialize) {
|
||||
root._initializeAndShow(root._pendingQuery, root._pendingMode);
|
||||
root._pendingInitialize = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onEscapePressed: event => {
|
||||
|
||||
@@ -38,6 +38,12 @@ Rectangle {
|
||||
radius: Theme.cornerRadius
|
||||
color: isSelected ? Theme.primaryPressed : isHovered ? Theme.primaryHoverLight : "transparent"
|
||||
|
||||
DankRipple {
|
||||
id: rippleLayer
|
||||
rippleColor: Theme.surfaceText
|
||||
cornerRadius: root.radius
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
anchors.margins: Theme.spacingS
|
||||
@@ -55,11 +61,13 @@ Rectangle {
|
||||
materialIconSizeAdjustment: root.computedIconSize * 0.3
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.item?.name ?? ""
|
||||
text: root.item?._hName ?? root.item?.name ?? ""
|
||||
textFormat: root.item?._hRich ? Text.RichText : Text.PlainText
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.fontFamily
|
||||
color: root.isSelected ? Theme.primary : Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
@@ -75,6 +83,10 @@ Rectangle {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
|
||||
onPressed: mouse => {
|
||||
if (mouse.button === Qt.LeftButton)
|
||||
rippleLayer.trigger(mouse.x, mouse.y);
|
||||
}
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
var scenePos = mapToItem(null, mouse.x, mouse.y);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.pragma library
|
||||
|
||||
.import "ControllerUtils.js" as Utils
|
||||
.import "ControllerUtils.js" as Utils
|
||||
|
||||
function transformApp(app, override, defaultActions, primaryActionLabel) {
|
||||
var appId = app.id || app.execString || app.exec || "";
|
||||
@@ -31,7 +31,11 @@ function transformApp(app, override, defaultActions, primaryActionLabel) {
|
||||
name: primaryActionLabel,
|
||||
icon: "open_in_new",
|
||||
action: "launch"
|
||||
}
|
||||
},
|
||||
_hName: "",
|
||||
_hSub: "",
|
||||
_hRich: false,
|
||||
_preScored: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,7 +70,11 @@ function transformCoreApp(app, openLabel) {
|
||||
name: openLabel,
|
||||
icon: "open_in_new",
|
||||
action: "launch"
|
||||
}
|
||||
},
|
||||
_hName: "",
|
||||
_hSub: "",
|
||||
_hRich: false,
|
||||
_preScored: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +108,11 @@ function transformBuiltInLauncherItem(item, pluginId, openLabel) {
|
||||
name: openLabel,
|
||||
icon: "open_in_new",
|
||||
action: "execute"
|
||||
}
|
||||
},
|
||||
_hName: "",
|
||||
_hSub: "",
|
||||
_hRich: false,
|
||||
_preScored: item._preScored
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,7 +145,11 @@ function transformFileResult(file, openLabel, openFolderLabel, copyPathLabel) {
|
||||
name: openLabel,
|
||||
icon: "open_in_new",
|
||||
action: "open"
|
||||
}
|
||||
},
|
||||
_hName: "",
|
||||
_hSub: "",
|
||||
_hRich: false,
|
||||
_preScored: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,7 +182,11 @@ function transformPluginItem(item, pluginId, selectLabel) {
|
||||
name: selectLabel,
|
||||
icon: "check",
|
||||
action: "execute"
|
||||
}
|
||||
},
|
||||
_hName: "",
|
||||
_hSub: "",
|
||||
_hRich: false,
|
||||
_preScored: item._preScored
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,7 +208,11 @@ function createCalculatorItem(calc, query, copyLabel) {
|
||||
name: copyLabel,
|
||||
icon: "content_copy",
|
||||
action: "copy"
|
||||
}
|
||||
},
|
||||
_hName: "",
|
||||
_hSub: "",
|
||||
_hRich: false,
|
||||
_preScored: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,6 +242,10 @@ function createPluginBrowseItem(pluginId, plugin, trigger, isBuiltIn, isAllowed,
|
||||
name: browseLabel,
|
||||
icon: "arrow_forward",
|
||||
action: "browse_plugin"
|
||||
}
|
||||
},
|
||||
_hName: "",
|
||||
_hSub: "",
|
||||
_hRich: false,
|
||||
_preScored: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ FocusScope {
|
||||
|
||||
Controller {
|
||||
id: controller
|
||||
active: root.parentModal?.spotlightOpen ?? true
|
||||
viewModeContext: root.viewModeContext
|
||||
|
||||
onItemExecuted: {
|
||||
@@ -287,7 +288,7 @@ FocusScope {
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: -Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
radius: Theme.cornerRadius
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Popup {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly property bool isCoreApp: item?.type === "app" && item?.isCore
|
||||
readonly property bool isCoreApp: item?.type === "app" && !!item?.isCore
|
||||
readonly property var coreAppData: isCoreApp ? item?.data ?? null : null
|
||||
readonly property var desktopEntry: !isCoreApp ? (item?.data ?? null) : null
|
||||
readonly property string appId: {
|
||||
@@ -475,6 +475,12 @@ Popup {
|
||||
}
|
||||
}
|
||||
|
||||
DankRipple {
|
||||
id: menuItemRipple
|
||||
rippleColor: Theme.surfaceText
|
||||
cornerRadius: Theme.cornerRadius
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: itemMouseArea
|
||||
anchors.fill: parent
|
||||
@@ -484,6 +490,7 @@ Popup {
|
||||
root.keyboardNavigation = false;
|
||||
root.selectedMenuIndex = menuItemDelegate.itemIndex;
|
||||
}
|
||||
onPressed: mouse => menuItemRipple.trigger(mouse.x, mouse.y)
|
||||
onClicked: {
|
||||
var menuItem = menuItemDelegate.modelData;
|
||||
if (menuItem.action)
|
||||
|
||||
@@ -25,6 +25,9 @@ function findPrevNonHeaderIndex(flatModel, startIndex) {
|
||||
}
|
||||
|
||||
function getSectionBounds(flatModel, sectionId) {
|
||||
if (flatModel._sectionBounds && flatModel._sectionBounds[sectionId])
|
||||
return flatModel._sectionBounds[sectionId];
|
||||
|
||||
var start = -1, end = -1;
|
||||
for (var i = 0; i < flatModel.length; i++) {
|
||||
if (flatModel[i].isHeader && flatModel[i].section?.id === sectionId) {
|
||||
|
||||
@@ -38,14 +38,25 @@ Rectangle {
|
||||
color: isSelected ? Theme.primaryPressed : isHovered ? Theme.primaryHoverLight : "transparent"
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
DankRipple {
|
||||
id: rippleLayer
|
||||
rippleColor: Theme.surfaceText
|
||||
cornerRadius: root.radius
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: itemArea
|
||||
z: 1
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: root.item?.type === "plugin_browse" ? 40 : 0
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
|
||||
onPressed: mouse => {
|
||||
if (mouse.button === Qt.LeftButton)
|
||||
rippleLayer.trigger(mouse.x, mouse.y);
|
||||
}
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
var scenePos = mapToItem(null, mouse.x, mouse.y);
|
||||
@@ -82,23 +93,27 @@ Rectangle {
|
||||
width: parent.width - 36 - Theme.spacingM * 3 - rightContent.width
|
||||
spacing: 2
|
||||
|
||||
StyledText {
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.item?.name ?? ""
|
||||
text: root.item?._hName ?? root.item?.name ?? ""
|
||||
textFormat: root.item?._hRich ? Text.RichText : Text.PlainText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.fontFamily
|
||||
color: Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.item?.subtitle ?? ""
|
||||
text: root.item?._hSub ?? root.item?.subtitle ?? ""
|
||||
textFormat: root.item?._hRich ? Text.RichText : Text.PlainText
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.family: Theme.fontFamily
|
||||
color: Theme.surfaceVariantText
|
||||
elide: Text.ElideRight
|
||||
visible: text.length > 0
|
||||
visible: (root.item?.subtitle ?? "").length > 0
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
}
|
||||
@@ -149,7 +164,7 @@ Rectangle {
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: root.item?.type && root.item.type !== "app" && root.item.type !== "plugin_browse"
|
||||
visible: !!root.item?.type && root.item.type !== "app" && root.item.type !== "plugin_browse"
|
||||
width: typeBadge.implicitWidth + Theme.spacingS * 2
|
||||
height: 20
|
||||
radius: 10
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
@@ -10,11 +11,116 @@ Item {
|
||||
|
||||
property var controller: null
|
||||
property int gridColumns: controller?.gridColumns ?? 4
|
||||
property var _visualRows: []
|
||||
property var _flatIndexToRowMap: ({})
|
||||
property var _cumulativeHeights: []
|
||||
|
||||
signal itemRightClicked(int index, var item, real mouseX, real mouseY)
|
||||
|
||||
function _rebuildVisualModel() {
|
||||
var sections = root.controller?.sections ?? [];
|
||||
var rows = [];
|
||||
var indexMap = {};
|
||||
var cumHeights = [];
|
||||
var cumY = 0;
|
||||
|
||||
for (var s = 0; s < sections.length; s++) {
|
||||
var section = sections[s];
|
||||
var sectionId = section.id;
|
||||
|
||||
cumHeights.push(cumY);
|
||||
rows.push({
|
||||
_rowId: "h_" + sectionId,
|
||||
type: "header",
|
||||
section: section,
|
||||
sectionId: sectionId,
|
||||
height: 32
|
||||
});
|
||||
cumY += 32;
|
||||
|
||||
if (section.collapsed)
|
||||
continue;
|
||||
|
||||
var versionTrigger = root.controller?.viewModeVersion ?? 0;
|
||||
void (versionTrigger);
|
||||
var mode = root.controller?.getSectionViewMode(sectionId) ?? "list";
|
||||
var items = section.items ?? [];
|
||||
var flatStartIndex = section.flatStartIndex ?? 0;
|
||||
|
||||
if (mode === "list") {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var flatIdx = flatStartIndex + i;
|
||||
indexMap[flatIdx] = rows.length;
|
||||
cumHeights.push(cumY);
|
||||
rows.push({
|
||||
_rowId: items[i].id,
|
||||
type: "list_item",
|
||||
item: items[i],
|
||||
flatIndex: flatIdx,
|
||||
sectionId: sectionId,
|
||||
height: 52
|
||||
});
|
||||
cumY += 52;
|
||||
}
|
||||
} else {
|
||||
var cols = root.controller?.getGridColumns(sectionId) ?? root.gridColumns;
|
||||
var cellWidth = mode === "tile" ? Math.floor(root.width / 3) : Math.floor(root.width / root.gridColumns);
|
||||
var cellHeight = mode === "tile" ? cellWidth * 0.75 : cellWidth + 24;
|
||||
var numRows = Math.ceil(items.length / cols);
|
||||
|
||||
for (var r = 0; r < numRows; r++) {
|
||||
var rowItems = [];
|
||||
for (var c = 0; c < cols; c++) {
|
||||
var idx = r * cols + c;
|
||||
if (idx >= items.length)
|
||||
break;
|
||||
var fi = flatStartIndex + idx;
|
||||
indexMap[fi] = rows.length;
|
||||
rowItems.push({
|
||||
item: items[idx],
|
||||
flatIndex: fi
|
||||
});
|
||||
}
|
||||
cumHeights.push(cumY);
|
||||
rows.push({
|
||||
_rowId: "gr_" + sectionId + "_" + r,
|
||||
type: "grid_row",
|
||||
items: rowItems,
|
||||
sectionId: sectionId,
|
||||
viewMode: mode,
|
||||
cols: cols,
|
||||
height: cellHeight
|
||||
});
|
||||
cumY += cellHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
root._flatIndexToRowMap = indexMap;
|
||||
root._cumulativeHeights = cumHeights;
|
||||
root._visualRows = rows;
|
||||
}
|
||||
|
||||
onGridColumnsChanged: Qt.callLater(_rebuildVisualModel)
|
||||
onWidthChanged: Qt.callLater(_rebuildVisualModel)
|
||||
|
||||
Connections {
|
||||
target: root.controller
|
||||
function onSectionsChanged() {
|
||||
Qt.callLater(root._rebuildVisualModel);
|
||||
}
|
||||
function onViewModeVersionChanged() {
|
||||
Qt.callLater(root._rebuildVisualModel);
|
||||
}
|
||||
function onSearchModeChanged() {
|
||||
root._visualRows = [];
|
||||
root._cumulativeHeights = [];
|
||||
root._flatIndexToRowMap = {};
|
||||
}
|
||||
}
|
||||
|
||||
function resetScroll() {
|
||||
mainFlickable.contentY = 0;
|
||||
mainListView.contentY = mainListView.originY;
|
||||
}
|
||||
|
||||
function ensureVisible(index) {
|
||||
@@ -23,74 +129,25 @@ Item {
|
||||
var entry = controller.flatModel[index];
|
||||
if (!entry || entry.isHeader)
|
||||
return;
|
||||
scrollItemIntoView(index, entry.sectionId);
|
||||
}
|
||||
|
||||
function scrollItemIntoView(flatIndex, sectionId) {
|
||||
var sections = controller?.sections ?? [];
|
||||
var sectionIndex = -1;
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
if (sections[i].id === sectionId) {
|
||||
sectionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sectionIndex < 0)
|
||||
var rowIndex = _flatIndexToRowMap[index];
|
||||
if (rowIndex === undefined || rowIndex >= _cumulativeHeights.length)
|
||||
return;
|
||||
var row = _visualRows[rowIndex];
|
||||
if (!row)
|
||||
return;
|
||||
|
||||
var rowY = _cumulativeHeights[rowIndex];
|
||||
var rowHeight = row.height;
|
||||
var scrollY = mainListView.contentY - mainListView.originY;
|
||||
var viewHeight = mainListView.height;
|
||||
var headerH = stickyHeader.height;
|
||||
|
||||
if (rowY < scrollY + headerH) {
|
||||
mainListView.contentY = Math.max(mainListView.originY, rowY - headerH + mainListView.originY);
|
||||
return;
|
||||
var itemInSection = 0;
|
||||
var foundSection = false;
|
||||
for (var i = 0; i < controller.flatModel.length && i < flatIndex; i++) {
|
||||
var e = controller.flatModel[i];
|
||||
if (e.isHeader && e.section?.id === sectionId)
|
||||
foundSection = true;
|
||||
else if (foundSection && !e.isHeader && e.sectionId === sectionId)
|
||||
itemInSection++;
|
||||
}
|
||||
|
||||
var mode = controller.getSectionViewMode(sectionId);
|
||||
var sectionY = 0;
|
||||
for (var i = 0; i < sectionIndex; i++) {
|
||||
sectionY += getSectionHeight(sections[i]);
|
||||
}
|
||||
|
||||
var itemY, itemHeight;
|
||||
if (mode === "list") {
|
||||
itemY = itemInSection * 52;
|
||||
itemHeight = 52;
|
||||
} else {
|
||||
var cols = controller.getGridColumns(sectionId);
|
||||
var cellWidth = mode === "tile" ? Math.floor(mainFlickable.width / 3) : Math.floor(mainFlickable.width / root.gridColumns);
|
||||
var cellHeight = mode === "tile" ? cellWidth * 0.75 : cellWidth + 24;
|
||||
var row = Math.floor(itemInSection / cols);
|
||||
itemY = row * cellHeight;
|
||||
itemHeight = cellHeight;
|
||||
}
|
||||
|
||||
var targetY = sectionY + 32 + itemY;
|
||||
var targetBottom = targetY + itemHeight;
|
||||
var stickyHeight = mainFlickable.contentY > 0 ? 32 : 0;
|
||||
|
||||
var shadowPadding = 24;
|
||||
if (targetY < mainFlickable.contentY + stickyHeight) {
|
||||
mainFlickable.contentY = Math.max(0, targetY - 32);
|
||||
} else if (targetBottom > mainFlickable.contentY + mainFlickable.height - shadowPadding) {
|
||||
mainFlickable.contentY = Math.min(mainFlickable.contentHeight - mainFlickable.height, targetBottom - mainFlickable.height + shadowPadding);
|
||||
}
|
||||
}
|
||||
|
||||
function getSectionHeight(section) {
|
||||
var mode = controller?.getSectionViewMode(section.id) ?? "list";
|
||||
if (section.collapsed)
|
||||
return 32;
|
||||
|
||||
if (mode === "list") {
|
||||
return 32 + (section.items?.length ?? 0) * 52;
|
||||
} else {
|
||||
var cols = controller?.getGridColumns(section.id) ?? root.gridColumns;
|
||||
var rows = Math.ceil((section.items?.length ?? 0) / cols);
|
||||
var cellWidth = mode === "tile" ? Math.floor(root.width / 3) : Math.floor(root.width / cols);
|
||||
var cellHeight = mode === "tile" ? cellWidth * 0.75 : cellWidth + 24;
|
||||
return 32 + rows * cellHeight;
|
||||
if (rowY + rowHeight > scrollY + viewHeight) {
|
||||
mainListView.contentY = rowY + rowHeight - viewHeight + mainListView.originY;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,42 +160,30 @@ Item {
|
||||
if (!entry || entry.isHeader)
|
||||
return fallback;
|
||||
|
||||
var sections = controller.sections;
|
||||
var sectionIndex = -1;
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
if (sections[i].id === entry.sectionId) {
|
||||
sectionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sectionIndex < 0)
|
||||
var rowIndex = _flatIndexToRowMap[controller.selectedFlatIndex];
|
||||
if (rowIndex === undefined)
|
||||
return fallback;
|
||||
|
||||
var sectionY = 0;
|
||||
for (var i = 0; i < sectionIndex; i++) {
|
||||
sectionY += getSectionHeight(sections[i]);
|
||||
var rowY = (rowIndex < _cumulativeHeights.length) ? _cumulativeHeights[rowIndex] : 0;
|
||||
var row = _visualRows[rowIndex];
|
||||
if (!row)
|
||||
return fallback;
|
||||
|
||||
var itemX = width / 2;
|
||||
var itemH = row.height;
|
||||
|
||||
if (row.type === "grid_row") {
|
||||
var rowItems = row.items;
|
||||
for (var i = 0; i < rowItems.length; i++) {
|
||||
if (rowItems[i].flatIndex === controller.selectedFlatIndex) {
|
||||
var cellWidth = row.viewMode === "tile" ? Math.floor(width / 3) : Math.floor(width / row.cols);
|
||||
itemX = i * cellWidth + cellWidth / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mode = controller.getSectionViewMode(entry.sectionId);
|
||||
var itemInSection = entry.indexInSection || 0;
|
||||
|
||||
var itemY, itemX, itemH;
|
||||
if (mode === "list") {
|
||||
itemY = sectionY + 32 + itemInSection * 52;
|
||||
itemX = width / 2;
|
||||
itemH = 52;
|
||||
} else {
|
||||
var cols = controller.getGridColumns(entry.sectionId);
|
||||
var cellWidth = mode === "tile" ? Math.floor(width / 3) : Math.floor(width / cols);
|
||||
var cellHeight = mode === "tile" ? cellWidth * 0.75 : cellWidth + 24;
|
||||
var row = Math.floor(itemInSection / cols);
|
||||
var col = itemInSection % cols;
|
||||
itemY = sectionY + 32 + row * cellHeight;
|
||||
itemX = col * cellWidth + cellWidth / 2;
|
||||
itemH = cellHeight;
|
||||
}
|
||||
|
||||
var visualY = itemY - mainFlickable.contentY + itemH / 2;
|
||||
var visualY = rowY - mainListView.contentY + mainListView.originY + itemH / 2;
|
||||
var clampedY = Math.max(40, Math.min(height - 40, visualY));
|
||||
return mapToItem(null, itemX, clampedY);
|
||||
}
|
||||
@@ -152,180 +197,124 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
id: mainFlickable
|
||||
DankListView {
|
||||
id: mainListView
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: sectionsColumn.height
|
||||
clip: true
|
||||
scrollBarTopMargin: (root.controller?.sections?.length > 0) ? 32 : 0
|
||||
|
||||
Component.onCompleted: {
|
||||
verticalScrollBar.targetFlickable = mainFlickable;
|
||||
verticalScrollBar.parent = root;
|
||||
verticalScrollBar.z = 102;
|
||||
verticalScrollBar.anchors.right = root.right;
|
||||
verticalScrollBar.anchors.top = root.top;
|
||||
verticalScrollBar.anchors.bottom = root.bottom;
|
||||
model: ScriptModel {
|
||||
values: root._visualRows
|
||||
objectProp: "_rowId"
|
||||
}
|
||||
|
||||
Column {
|
||||
id: sectionsColumn
|
||||
width: parent.width
|
||||
add: null
|
||||
remove: null
|
||||
displaced: null
|
||||
move: null
|
||||
|
||||
Repeater {
|
||||
model: root.controller?.sections ?? []
|
||||
delegate: Item {
|
||||
id: delegateRoot
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
Column {
|
||||
id: sectionDelegate
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: mainListView.width
|
||||
height: modelData?.height ?? 52
|
||||
|
||||
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0
|
||||
readonly property string sectionId: modelData?.id ?? ""
|
||||
readonly property string currentViewMode: {
|
||||
void (versionTrigger);
|
||||
return root.controller?.getSectionViewMode(sectionId) ?? "list";
|
||||
SectionHeader {
|
||||
anchors.fill: parent
|
||||
visible: delegateRoot.modelData?.type === "header"
|
||||
section: delegateRoot.modelData?.section ?? null
|
||||
controller: root.controller
|
||||
viewMode: {
|
||||
var vt = root.controller?.viewModeVersion ?? 0;
|
||||
void (vt);
|
||||
return root.controller?.getSectionViewMode(delegateRoot.modelData?.sectionId ?? "") ?? "list";
|
||||
}
|
||||
canChangeViewMode: {
|
||||
var vt = root.controller?.viewModeVersion ?? 0;
|
||||
void (vt);
|
||||
return root.controller?.canChangeSectionViewMode(delegateRoot.modelData?.sectionId ?? "") ?? false;
|
||||
}
|
||||
canCollapse: root.controller?.canCollapseSection(delegateRoot.modelData?.sectionId ?? "") ?? false
|
||||
}
|
||||
|
||||
ResultItem {
|
||||
anchors.fill: parent
|
||||
visible: delegateRoot.modelData?.type === "list_item"
|
||||
item: delegateRoot.modelData?.type === "list_item" ? (delegateRoot.modelData?.item ?? null) : null
|
||||
isSelected: delegateRoot.modelData?.type === "list_item" && (delegateRoot.modelData?.flatIndex ?? -1) === root.controller?.selectedFlatIndex
|
||||
controller: root.controller
|
||||
flatIndex: delegateRoot.modelData?.type === "list_item" ? (delegateRoot.modelData?.flatIndex ?? -1) : -1
|
||||
|
||||
onClicked: {
|
||||
if (root.controller && delegateRoot.modelData?.item) {
|
||||
root.controller.executeItem(delegateRoot.modelData.item);
|
||||
}
|
||||
readonly property bool isGridMode: currentViewMode === "grid" || currentViewMode === "tile"
|
||||
readonly property bool isCollapsed: modelData?.collapsed ?? false
|
||||
}
|
||||
|
||||
width: sectionsColumn.width
|
||||
onRightClicked: (mouseX, mouseY) => {
|
||||
root.itemRightClicked(delegateRoot.modelData?.flatIndex ?? -1, delegateRoot.modelData?.item ?? null, mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
|
||||
SectionHeader {
|
||||
width: parent.width
|
||||
height: 32
|
||||
section: sectionDelegate.modelData
|
||||
controller: root.controller
|
||||
viewMode: sectionDelegate.currentViewMode
|
||||
canChangeViewMode: root.controller?.canChangeSectionViewMode(sectionDelegate.sectionId) ?? false
|
||||
canCollapse: root.controller?.canCollapseSection(sectionDelegate.sectionId) ?? false
|
||||
}
|
||||
Row {
|
||||
id: gridRowContent
|
||||
anchors.fill: parent
|
||||
visible: delegateRoot.modelData?.type === "grid_row"
|
||||
|
||||
Column {
|
||||
id: listContent
|
||||
width: parent.width
|
||||
visible: !sectionDelegate.isGridMode && !sectionDelegate.isCollapsed
|
||||
Repeater {
|
||||
model: delegateRoot.modelData?.type === "grid_row" ? (delegateRoot.modelData?.items ?? []) : []
|
||||
|
||||
Repeater {
|
||||
model: sectionDelegate.isGridMode || sectionDelegate.isCollapsed ? [] : (sectionDelegate.modelData?.items ?? [])
|
||||
Item {
|
||||
id: gridCellDelegate
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
ResultItem {
|
||||
required property var modelData
|
||||
required property int index
|
||||
readonly property real cellWidth: delegateRoot.modelData?.viewMode === "tile" ? Math.floor(delegateRoot.width / 3) : Math.floor(delegateRoot.width / (delegateRoot.modelData?.cols ?? root.gridColumns))
|
||||
|
||||
width: listContent.width
|
||||
height: 52
|
||||
item: modelData
|
||||
isSelected: getFlatIndex() === root.controller?.selectedFlatIndex
|
||||
controller: root.controller
|
||||
flatIndex: getFlatIndex()
|
||||
width: cellWidth
|
||||
height: delegateRoot.height
|
||||
|
||||
function getFlatIndex() {
|
||||
if (!sectionDelegate?.sectionId)
|
||||
return -1;
|
||||
var flatIdx = 0;
|
||||
var sections = root.controller?.sections ?? [];
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
flatIdx++;
|
||||
if (sections[i].id === sectionDelegate.sectionId)
|
||||
return flatIdx + index;
|
||||
if (!sections[i].collapsed)
|
||||
flatIdx += sections[i].items?.length ?? 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
GridItem {
|
||||
width: parent.width - 4
|
||||
height: parent.height - 4
|
||||
anchors.centerIn: parent
|
||||
visible: delegateRoot.modelData?.viewMode === "grid"
|
||||
item: gridCellDelegate.modelData?.item ?? null
|
||||
isSelected: (gridCellDelegate.modelData?.flatIndex ?? -1) === root.controller?.selectedFlatIndex
|
||||
controller: root.controller
|
||||
flatIndex: gridCellDelegate.modelData?.flatIndex ?? -1
|
||||
|
||||
onClicked: {
|
||||
if (root.controller) {
|
||||
root.controller.executeItem(modelData);
|
||||
}
|
||||
}
|
||||
|
||||
onRightClicked: (mouseX, mouseY) => {
|
||||
root.itemRightClicked(getFlatIndex(), modelData, mouseX, mouseY);
|
||||
onClicked: {
|
||||
if (root.controller && gridCellDelegate.modelData?.item) {
|
||||
root.controller.executeItem(gridCellDelegate.modelData.item);
|
||||
}
|
||||
}
|
||||
|
||||
onRightClicked: (mouseX, mouseY) => {
|
||||
root.itemRightClicked(gridCellDelegate.modelData?.flatIndex ?? -1, gridCellDelegate.modelData?.item ?? null, mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: gridContent
|
||||
width: parent.width
|
||||
visible: sectionDelegate.isGridMode && !sectionDelegate.isCollapsed
|
||||
columns: sectionDelegate.currentViewMode === "tile" ? 3 : root.gridColumns
|
||||
TileItem {
|
||||
width: parent.width - 4
|
||||
height: parent.height - 4
|
||||
anchors.centerIn: parent
|
||||
visible: delegateRoot.modelData?.viewMode === "tile"
|
||||
item: gridCellDelegate.modelData?.item ?? null
|
||||
isSelected: (gridCellDelegate.modelData?.flatIndex ?? -1) === root.controller?.selectedFlatIndex
|
||||
controller: root.controller
|
||||
flatIndex: gridCellDelegate.modelData?.flatIndex ?? -1
|
||||
|
||||
readonly property real cellWidth: sectionDelegate.currentViewMode === "tile" ? Math.floor(width / 3) : Math.floor(width / root.gridColumns)
|
||||
readonly property real cellHeight: sectionDelegate.currentViewMode === "tile" ? cellWidth * 0.75 : cellWidth + 24
|
||||
|
||||
Repeater {
|
||||
model: sectionDelegate.isGridMode && !sectionDelegate.isCollapsed ? (sectionDelegate.modelData?.items ?? []) : []
|
||||
|
||||
Item {
|
||||
id: gridDelegateItem
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: gridContent.cellWidth
|
||||
height: gridContent.cellHeight
|
||||
|
||||
function getFlatIndex() {
|
||||
if (!sectionDelegate?.sectionId)
|
||||
return -1;
|
||||
var flatIdx = 0;
|
||||
var sections = root.controller?.sections ?? [];
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
flatIdx++;
|
||||
if (sections[i].id === sectionDelegate.sectionId)
|
||||
return flatIdx + index;
|
||||
if (!sections[i].collapsed)
|
||||
flatIdx += sections[i].items?.length ?? 0;
|
||||
}
|
||||
return -1;
|
||||
onClicked: {
|
||||
if (root.controller && gridCellDelegate.modelData?.item) {
|
||||
root.controller.executeItem(gridCellDelegate.modelData.item);
|
||||
}
|
||||
}
|
||||
|
||||
readonly property int cachedFlatIndex: getFlatIndex()
|
||||
|
||||
GridItem {
|
||||
width: parent.width - 4
|
||||
height: parent.height - 4
|
||||
anchors.centerIn: parent
|
||||
visible: sectionDelegate.currentViewMode === "grid"
|
||||
item: gridDelegateItem.modelData
|
||||
isSelected: gridDelegateItem.cachedFlatIndex === root.controller?.selectedFlatIndex
|
||||
controller: root.controller
|
||||
flatIndex: gridDelegateItem.cachedFlatIndex
|
||||
|
||||
onClicked: {
|
||||
if (root.controller) {
|
||||
root.controller.executeItem(gridDelegateItem.modelData);
|
||||
}
|
||||
}
|
||||
|
||||
onRightClicked: (mouseX, mouseY) => {
|
||||
root.itemRightClicked(gridDelegateItem.cachedFlatIndex, gridDelegateItem.modelData, mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
|
||||
TileItem {
|
||||
width: parent.width - 4
|
||||
height: parent.height - 4
|
||||
anchors.centerIn: parent
|
||||
visible: sectionDelegate.currentViewMode === "tile"
|
||||
item: gridDelegateItem.modelData
|
||||
isSelected: gridDelegateItem.cachedFlatIndex === root.controller?.selectedFlatIndex
|
||||
controller: root.controller
|
||||
flatIndex: gridDelegateItem.cachedFlatIndex
|
||||
|
||||
onClicked: {
|
||||
if (root.controller) {
|
||||
root.controller.executeItem(gridDelegateItem.modelData);
|
||||
}
|
||||
}
|
||||
|
||||
onRightClicked: (mouseX, mouseY) => {
|
||||
root.itemRightClicked(gridDelegateItem.cachedFlatIndex, gridDelegateItem.modelData, mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
onRightClicked: (mouseX, mouseY) => {
|
||||
root.itemRightClicked(gridCellDelegate.modelData?.flatIndex ?? -1, gridCellDelegate.modelData?.item ?? null, mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,9 +331,9 @@ Item {
|
||||
height: 24
|
||||
z: 100
|
||||
visible: {
|
||||
if (mainFlickable.contentHeight <= mainFlickable.height)
|
||||
if (mainListView.contentHeight <= mainListView.height)
|
||||
return false;
|
||||
var atBottom = mainFlickable.contentY >= mainFlickable.contentHeight - mainFlickable.height - 5;
|
||||
var atBottom = mainListView.contentY >= mainListView.contentHeight - mainListView.height + mainListView.originY - 5;
|
||||
if (atBottom)
|
||||
return false;
|
||||
|
||||
@@ -387,25 +376,30 @@ Item {
|
||||
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0
|
||||
|
||||
readonly property var stickyHeaderSection: {
|
||||
if (!root.controller?.sections || root.controller.sections.length === 0)
|
||||
return null;
|
||||
var sections = root.controller.sections;
|
||||
if (sections.length === 0)
|
||||
return null;
|
||||
|
||||
var scrollY = mainFlickable.contentY;
|
||||
var scrollY = mainListView.contentY - mainListView.originY;
|
||||
if (scrollY <= 0)
|
||||
return null;
|
||||
|
||||
var y = 0;
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
var section = sections[i];
|
||||
var sectionHeight = root.getSectionHeight(section);
|
||||
if (scrollY < y + sectionHeight)
|
||||
return section;
|
||||
y += sectionHeight;
|
||||
var rows = root._visualRows;
|
||||
var heights = root._cumulativeHeights;
|
||||
if (rows.length === 0 || heights.length === 0)
|
||||
return null;
|
||||
|
||||
var lo = 0;
|
||||
var hi = rows.length - 1;
|
||||
while (lo < hi) {
|
||||
var mid = (lo + hi + 1) >> 1;
|
||||
if (mid < heights.length && heights[mid] <= scrollY)
|
||||
lo = mid;
|
||||
else
|
||||
hi = mid - 1;
|
||||
}
|
||||
return sections[sections.length - 1];
|
||||
|
||||
for (var i = lo; i >= 0; i--) {
|
||||
if (rows[i].type === "header")
|
||||
return rows[i].section;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
SectionHeader {
|
||||
|
||||
@@ -42,26 +42,23 @@ function hasWordBoundaryMatch(text, query) {
|
||||
function levenshteinDistance(s1, s2) {
|
||||
var len1 = s1.length
|
||||
var len2 = s2.length
|
||||
var matrix = []
|
||||
var prev = new Array(len2 + 1)
|
||||
var curr = new Array(len2 + 1)
|
||||
|
||||
for (var i = 0; i <= len1; i++) {
|
||||
matrix[i] = [i]
|
||||
}
|
||||
for (var j = 0; j <= len2; j++) {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
for (var j = 0; j <= len2; j++)
|
||||
prev[j] = j
|
||||
|
||||
for (var i = 1; i <= len1; i++) {
|
||||
curr[0] = i
|
||||
for (var j = 1; j <= len2; j++) {
|
||||
var cost = s1[i - 1] === s2[j - 1] ? 0 : 1
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] + cost
|
||||
)
|
||||
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
|
||||
}
|
||||
var tmp = prev
|
||||
prev = curr
|
||||
curr = tmp
|
||||
}
|
||||
return matrix[len1][len2]
|
||||
return prev[len2]
|
||||
}
|
||||
|
||||
function fuzzyScore(text, query) {
|
||||
@@ -153,8 +150,14 @@ function scoreItems(items, query, getFrecencyFn) {
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i]
|
||||
var frecencyData = getFrecencyFn ? getFrecencyFn(item) : null
|
||||
var itemScore = score(item, query, frecencyData)
|
||||
var itemScore
|
||||
|
||||
if (query && item._preScored !== undefined) {
|
||||
itemScore = item._preScored
|
||||
} else {
|
||||
var frecencyData = getFrecencyFn ? getFrecencyFn(item) : null
|
||||
itemScore = score(item, query, frecencyData)
|
||||
}
|
||||
|
||||
if (itemScore > 0 || !query || query.length === 0) {
|
||||
scored.push({
|
||||
@@ -184,7 +187,8 @@ function groupBySection(scoredItems, sectionOrder, sortAlphabetically, maxPerSec
|
||||
icon: sectionOrder[i].icon,
|
||||
priority: sectionOrder[i].priority,
|
||||
items: [],
|
||||
collapsed: false
|
||||
collapsed: false,
|
||||
flatStartIndex: 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +221,8 @@ function groupBySection(scoredItems, sectionOrder, sortAlphabetically, maxPerSec
|
||||
|
||||
function flattenSections(sections) {
|
||||
var flat = []
|
||||
flat._sectionBounds = null
|
||||
var bounds = {}
|
||||
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
var section = sections[i]
|
||||
@@ -228,6 +234,9 @@ function flattenSections(sections) {
|
||||
sectionIndex: i
|
||||
})
|
||||
|
||||
var itemStart = flat.length
|
||||
section.flatStartIndex = itemStart
|
||||
|
||||
if (!section.collapsed) {
|
||||
for (var j = 0; j < section.items.length; j++) {
|
||||
flat.push({
|
||||
@@ -239,7 +248,18 @@ function flattenSections(sections) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var itemEnd = flat.length - 1
|
||||
var itemCount = flat.length - itemStart
|
||||
if (itemCount > 0) {
|
||||
bounds[section.id] = {
|
||||
start: itemStart,
|
||||
end: itemEnd,
|
||||
count: itemCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flat._sectionBounds = bounds
|
||||
return flat
|
||||
}
|
||||
|
||||
@@ -64,7 +64,13 @@ Rectangle {
|
||||
if (!path)
|
||||
return false;
|
||||
var ext = path.split('.').pop().toLowerCase();
|
||||
return ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"].indexOf(ext) >= 0;
|
||||
return ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "jxl", "avif", "heif", "exr"].indexOf(ext) >= 0;
|
||||
}
|
||||
|
||||
DankRipple {
|
||||
id: rippleLayer
|
||||
rippleColor: Theme.surfaceText
|
||||
cornerRadius: root.radius
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -108,15 +114,17 @@ Rectangle {
|
||||
color: Theme.withAlpha(Theme.surfaceContainer, 0.85)
|
||||
visible: root.item?.name?.length > 0
|
||||
|
||||
StyledText {
|
||||
Text {
|
||||
id: labelText
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingXS
|
||||
text: root.item?.name ?? ""
|
||||
text: root.item?._hName ?? root.item?.name ?? ""
|
||||
textFormat: root.item?._hRich ? Text.RichText : Text.PlainText
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.family: Theme.fontFamily
|
||||
color: Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
@@ -170,6 +178,10 @@ Rectangle {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
|
||||
onPressed: mouse => {
|
||||
if (mouse.button === Qt.LeftButton)
|
||||
rippleLayer.trigger(mouse.x, mouse.y);
|
||||
}
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
var scenePos = mapToItem(null, mouse.x, mouse.y);
|
||||
|
||||
@@ -280,6 +280,7 @@ FocusScope {
|
||||
showDirsFirst: true
|
||||
showDotAndDotDot: false
|
||||
showHidden: root.showHiddenFiles
|
||||
caseSensitive: false
|
||||
nameFilters: fileExtensions
|
||||
showFiles: true
|
||||
showDirs: true
|
||||
|
||||
@@ -31,7 +31,7 @@ StyledRect {
|
||||
function determineFileType(fileName) {
|
||||
const ext = getFileExtension(fileName);
|
||||
|
||||
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico"];
|
||||
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico", "jxl", "avif", "heif", "exr"];
|
||||
if (imageExts.includes(ext)) {
|
||||
return "image";
|
||||
}
|
||||
@@ -119,7 +119,7 @@ StyledRect {
|
||||
id: gridPreviewImage
|
||||
anchors.fill: parent
|
||||
anchors.margins: 2
|
||||
property var weExtensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tga"]
|
||||
property var weExtensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tga", ".jxl", ".avif", ".heif", ".exr"]
|
||||
property int weExtIndex: 0
|
||||
property string imagePath: {
|
||||
if (weMode && delegateRoot.fileIsDir)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user