mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 11:38:30 -04:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4af3225f21 | |||
| 3c5245914f | |||
| 89814a2c65 | |||
| 5d2093e54a | |||
| 3cd52ca327 | |||
| 5b41d699fa | |||
| 544599bf1c | |||
| 296b3a3d7e | |||
| 729a990fa7 | |||
| c3fa7b2e1d | |||
| 2c5a1a2804 | |||
| 1973526c4e | |||
| bba5502960 | |||
| 52740290b2 | |||
| 27703575bc | |||
| e6504add7b | |||
| f1e9121295 | |||
| 9ff751b82a | |||
| 21eaaef056 | |||
| bb0be2b215 | |||
| 25847c3f03 | |||
| 7b5c25c50f | |||
| 7535b70fa6 | |||
| e3034e4e94 | |||
| 3da19e5c15 | |||
| ca89e12963 | |||
| 4fb6995796 | |||
| 3f5a54aa88 | |||
| d379d251b9 | |||
| 7ab0e01573 | |||
| 2cb48aaf6b | |||
| e4657aa5f9 | |||
| 197d17ac4e | |||
| 31ea83584b | |||
| ea66b136ba | |||
| 06c0ea2afb | |||
| f4f47c0bc5 | |||
| 8a0ed8a50f | |||
| 63eea01243 | |||
| f590a2965a | |||
| 4ab03deded | |||
| 846d07d86a | |||
| 55d88d8dfb | |||
| a803f96f41 | |||
| da7cc5648b | |||
| 75443758f9 | |||
| eaecff93a4 | |||
| 6e7c20c59c | |||
| 406dcfe110 | |||
| e2b3a2e3ca | |||
| 71ab752e1b | |||
| 204ecd0461 | |||
| 0fdfac366e | |||
| 52123458c7 | |||
| 26b2955cf3 | |||
| d82d86df5c | |||
| c445597f83 | |||
| 6a58adfb29 | |||
| 05feb211ba | |||
| a3b2167e58 | |||
| 0b69feaa1a | |||
| 494144a7c7 | |||
| 2a2c1ca9e6 | |||
| 56b7ecb008 | |||
| 867102b82c | |||
| 4bdb7d17b2 | |||
| c44ffae751 | |||
| ce1595d62d | |||
| 45f6232e32 | |||
| 9cf2ca7196 | |||
| c0eeed4e89 | |||
| d0a4c1c56e | |||
| cb0dc9c68d | |||
| 0439d017b9 | |||
| 8008238ca0 | |||
| a095d0ed90 | |||
| a48cce59d4 | |||
| 4c806f83f1 |
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
@@ -55,15 +55,6 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ steps.app_token.outputs.token }}
|
token: ${{ steps.app_token.outputs.token }}
|
||||||
|
|
||||||
- name: Preflight — verify Release workflow is dispatchable
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
gh api "repos/${{ github.repository }}/actions/workflows/release.yml" \
|
|
||||||
--jq '.state' | grep -qx active ||
|
|
||||||
{ echo "::error::release.yml is not dispatchable; aborting before any push"; exit 1; }
|
|
||||||
|
|
||||||
- name: Port audit (informational)
|
- name: Port audit (informational)
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||||
@@ -92,7 +83,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Dispatch Release workflow
|
- name: Dispatch Release workflow
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||||
run: |
|
run: |
|
||||||
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
|
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
|
||||||
-f tag="${{ steps.derive.outputs.tag }}" \
|
-f tag="${{ steps.derive.outputs.tag }}" \
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v3
|
uses: actions/create-github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
@@ -59,121 +56,10 @@ var authResolveLockCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var authListServicesCmd = &cobra.Command{
|
|
||||||
Use: "list-services",
|
|
||||||
Short: "List candidate lock-screen PAM services available on this system",
|
|
||||||
Long: "Enumerate the lock-screen PAM services that exist on this system and report their resolved auth stack (whether it has an auth directive and whether fingerprint/U2F modules appear inline).",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
asJSON, _ := cmd.Flags().GetBool("json")
|
|
||||||
services := sharedpam.ListLockscreenPamServices()
|
|
||||||
|
|
||||||
if asJSON {
|
|
||||||
payload := struct {
|
|
||||||
Services []sharedpam.LockscreenPamServiceInfo `json:"services"`
|
|
||||||
}{Services: services}
|
|
||||||
data, err := json.MarshalIndent(payload, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error encoding services: %v", err)
|
|
||||||
}
|
|
||||||
fmt.Println(string(data))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(services) == 0 {
|
|
||||||
fmt.Println("No candidate lock-screen PAM services found.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, s := range services {
|
|
||||||
fmt.Printf("%-20s %-30s auth=%-5t fingerprint=%-5t u2f=%t\n", s.Name, s.Path, s.HasAuth, s.InlineFingerprint, s.InlineU2f)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var authValidateCmd = &cobra.Command{
|
|
||||||
Use: "validate",
|
|
||||||
Short: "Validate a PAM service file for use by the DMS lock screen",
|
|
||||||
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password or dedicated U2F stack. Exits 1 when the file is not usable.",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
path, _ := cmd.Flags().GetString("path")
|
|
||||||
service, _ := cmd.Flags().GetString("service")
|
|
||||||
purpose, _ := cmd.Flags().GetString("purpose")
|
|
||||||
asJSON, _ := cmd.Flags().GetBool("json")
|
|
||||||
|
|
||||||
if (path == "") == (service == "") {
|
|
||||||
log.Fatalf("Error: exactly one of --path or --service is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if purpose != "password" && purpose != "u2f" {
|
|
||||||
log.Fatalf("Error: --purpose must be password or u2f")
|
|
||||||
}
|
|
||||||
|
|
||||||
var result sharedpam.LockscreenPamValidation
|
|
||||||
switch {
|
|
||||||
case service != "":
|
|
||||||
if purpose == "u2f" {
|
|
||||||
result = sharedpam.ValidateLockscreenU2fPamService(service)
|
|
||||||
} else {
|
|
||||||
result = sharedpam.ValidateLockscreenPamService(service)
|
|
||||||
}
|
|
||||||
case !filepath.IsAbs(path):
|
|
||||||
result = sharedpam.LockscreenPamValidation{
|
|
||||||
Path: path,
|
|
||||||
MissingModules: []string{},
|
|
||||||
Warnings: []string{},
|
|
||||||
Errors: []string{"--path must be an absolute file path"},
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if purpose == "u2f" {
|
|
||||||
result = sharedpam.ValidateLockscreenU2fPamPath(path)
|
|
||||||
} else {
|
|
||||||
result = sharedpam.ValidateLockscreenPamPath(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if asJSON {
|
|
||||||
data, err := json.MarshalIndent(result, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error encoding validation: %v", err)
|
|
||||||
}
|
|
||||||
fmt.Println(string(data))
|
|
||||||
} else {
|
|
||||||
printLockscreenPamValidation(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.Valid {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func printLockscreenPamValidation(result sharedpam.LockscreenPamValidation) {
|
|
||||||
fmt.Printf("Path: %s\n", result.Path)
|
|
||||||
fmt.Printf("Valid: %t\n", result.Valid)
|
|
||||||
fmt.Printf("Has auth: %t\n", result.HasAuth)
|
|
||||||
fmt.Printf("Inline fingerprint: %t\n", result.InlineFingerprint)
|
|
||||||
fmt.Printf("Inline U2F: %t\n", result.InlineU2f)
|
|
||||||
if len(result.MissingModules) > 0 {
|
|
||||||
fmt.Printf("Missing modules: %s\n", strings.Join(result.MissingModules, ", "))
|
|
||||||
}
|
|
||||||
for _, w := range result.Warnings {
|
|
||||||
fmt.Println("⚠ " + w)
|
|
||||||
}
|
|
||||||
for _, e := range result.Errors {
|
|
||||||
fmt.Println("✗ " + e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
|
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
|
||||||
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
|
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
|
||||||
authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path")
|
authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path")
|
||||||
|
|
||||||
authListServicesCmd.Flags().Bool("json", false, "Output as JSON")
|
|
||||||
|
|
||||||
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
|
|
||||||
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
|
|
||||||
authValidateCmd.Flags().String("purpose", "password", "Validation purpose: password or u2f")
|
|
||||||
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncAuth(nonInteractive bool) error {
|
func syncAuth(nonInteractive bool) error {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -28,19 +27,8 @@ var (
|
|||||||
ssNoConfirm bool
|
ssNoConfirm bool
|
||||||
ssReset bool
|
ssReset bool
|
||||||
ssStdout bool
|
ssStdout bool
|
||||||
ssJSON bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type screenshotMetadata struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Path string `json:"path,omitempty"`
|
|
||||||
Width int `json:"width,omitempty"`
|
|
||||||
Height int `json:"height,omitempty"`
|
|
||||||
Scale float64 `json:"scale,omitempty"`
|
|
||||||
Mime string `json:"mime,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var screenshotCmd = &cobra.Command{
|
var screenshotCmd = &cobra.Command{
|
||||||
Use: "screenshot",
|
Use: "screenshot",
|
||||||
Short: "Capture screenshots",
|
Short: "Capture screenshots",
|
||||||
@@ -51,9 +39,8 @@ Modes:
|
|||||||
full - Capture the focused output
|
full - Capture the focused output
|
||||||
all - Capture all outputs combined
|
all - Capture all outputs combined
|
||||||
output - Capture a specific output by name
|
output - Capture a specific output by name
|
||||||
window - Capture the focused window (Hyprland/Mango/niri)
|
window - Capture the focused window (Hyprland/Mango)
|
||||||
last - Capture the last selected region
|
last - Capture the last selected region
|
||||||
scroll - Select a region, then scroll to capture a stitched tall image
|
|
||||||
|
|
||||||
Output format (--format):
|
Output format (--format):
|
||||||
png - PNG format (default)
|
png - PNG format (default)
|
||||||
@@ -72,10 +59,7 @@ Examples:
|
|||||||
dms screenshot --no-file # Clipboard only
|
dms screenshot --no-file # Clipboard only
|
||||||
dms screenshot --no-confirm # Region capture on mouse release
|
dms screenshot --no-confirm # Region capture on mouse release
|
||||||
dms screenshot --cursor=on # Include cursor
|
dms screenshot --cursor=on # Include cursor
|
||||||
dms screenshot -f jpg -q 85 # JPEG with quality 85
|
dms screenshot -f jpg -q 85 # JPEG with quality 85`,
|
||||||
dms screenshot --json # Print capture metadata as JSON
|
|
||||||
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
|
|
||||||
dms screenshot scroll --interval 250`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var ssRegionCmd = &cobra.Command{
|
var ssRegionCmd = &cobra.Command{
|
||||||
@@ -113,33 +97,10 @@ If no previous region exists, falls back to interactive selection.`,
|
|||||||
var ssWindowCmd = &cobra.Command{
|
var ssWindowCmd = &cobra.Command{
|
||||||
Use: "window",
|
Use: "window",
|
||||||
Short: "Capture the focused window",
|
Short: "Capture the focused window",
|
||||||
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
|
Long: `Capture the currently focused window. Supported on Hyprland and Mango.`,
|
||||||
Run: runScreenshotWindow,
|
Run: runScreenshotWindow,
|
||||||
}
|
}
|
||||||
|
|
||||||
var ssScrollInterval int
|
|
||||||
|
|
||||||
var ssScrollCmd = &cobra.Command{
|
|
||||||
Use: "scroll",
|
|
||||||
Short: "Capture a scrolling region stitched into one tall image",
|
|
||||||
Long: `Select a region, then scroll the content beneath with the mouse wheel or
|
|
||||||
touchpad while frames are captured and stitched vertically. Finish with the
|
|
||||||
on-screen done button; cancel with the cancel button. Enter and Esc work
|
|
||||||
everywhere: most compositors hold the keyboard on the overlay (keyboard
|
|
||||||
scrolling does not reach the app there), while Hyprland leaves the keyboard
|
|
||||||
with the application — keyboard scrolling works, and Enter/Esc act through
|
|
||||||
temporary global binds for the session. The cursor is never included in
|
|
||||||
frames.
|
|
||||||
|
|
||||||
Frames are stitched continuously while scrolling, and revisited content is
|
|
||||||
never duplicated — scrolling up past the starting point extends the image
|
|
||||||
upward. Content jumped past faster than capture can follow is skipped rather
|
|
||||||
than stitched incorrectly.
|
|
||||||
|
|
||||||
Rotated outputs are not supported.`,
|
|
||||||
Run: runScreenshotScroll,
|
|
||||||
}
|
|
||||||
|
|
||||||
var ssListCmd = &cobra.Command{
|
var ssListCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List available outputs",
|
Short: "List available outputs",
|
||||||
@@ -167,12 +128,8 @@ func init() {
|
|||||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
|
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
|
||||||
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
|
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
|
||||||
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
||||||
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
|
|
||||||
|
|
||||||
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
|
|
||||||
|
|
||||||
screenshotCmd.AddCommand(ssRegionCmd)
|
screenshotCmd.AddCommand(ssRegionCmd)
|
||||||
screenshotCmd.AddCommand(ssScrollCmd)
|
|
||||||
screenshotCmd.AddCommand(ssFullCmd)
|
screenshotCmd.AddCommand(ssFullCmd)
|
||||||
screenshotCmd.AddCommand(ssAllCmd)
|
screenshotCmd.AddCommand(ssAllCmd)
|
||||||
screenshotCmd.AddCommand(ssOutputCmd)
|
screenshotCmd.AddCommand(ssOutputCmd)
|
||||||
@@ -246,39 +203,10 @@ func setPopoutScreenshotMode(begin bool) {
|
|||||||
_ = exec.Command("qs", cmdArgs...).Run()
|
_ = exec.Command("qs", cmdArgs...).Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeScreenshotJSON(meta screenshotMetadata) {
|
|
||||||
_ = json.NewEncoder(os.Stdout).Encode(meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
func exitScreenshotError(context string, err error) {
|
|
||||||
if ssJSON {
|
|
||||||
writeScreenshotJSON(screenshotMetadata{Status: "error", Error: err.Error()})
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Error%s: %v\n", context, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatMime(format screenshot.Format) string {
|
|
||||||
switch format {
|
|
||||||
case screenshot.FormatJPEG:
|
|
||||||
return "image/jpeg"
|
|
||||||
case screenshot.FormatPPM:
|
|
||||||
return "image/x-portable-pixmap"
|
|
||||||
default:
|
|
||||||
return "image/png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runScreenshot(config screenshot.Config) {
|
func runScreenshot(config screenshot.Config) {
|
||||||
if ssJSON && config.Stdout {
|
|
||||||
fmt.Fprintln(os.Stderr, "Error: --json cannot be combined with --stdout")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Region select needs the keyboard; drop popout grabs for its duration.
|
// Region select needs the keyboard; drop popout grabs for its duration.
|
||||||
result, err := func() (*screenshot.CaptureResult, error) {
|
result, err := func() (*screenshot.CaptureResult, error) {
|
||||||
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
|
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
|
||||||
if interactive {
|
if interactive {
|
||||||
setPopoutScreenshotMode(true)
|
setPopoutScreenshotMode(true)
|
||||||
defer setPopoutScreenshotMode(false)
|
defer setPopoutScreenshotMode(false)
|
||||||
@@ -287,13 +215,11 @@ func runScreenshot(config screenshot.Config) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exitScreenshotError("", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
if ssJSON {
|
|
||||||
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
|
|
||||||
}
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +231,8 @@ func runScreenshot(config screenshot.Config) {
|
|||||||
|
|
||||||
if config.Stdout {
|
if config.Stdout {
|
||||||
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
||||||
exitScreenshotError(" writing to stdout", err)
|
fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -325,37 +252,22 @@ func runScreenshot(config screenshot.Config) {
|
|||||||
|
|
||||||
filePath = filepath.Join(outputDir, filename)
|
filePath = filepath.Join(outputDir, filename)
|
||||||
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
|
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
|
||||||
exitScreenshotError(" writing file", err)
|
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if !ssJSON {
|
|
||||||
fmt.Println(filePath)
|
fmt.Println(filePath)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if config.Clipboard {
|
if config.Clipboard {
|
||||||
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
||||||
exitScreenshotError(" copying to clipboard", err)
|
fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if !ssJSON && !config.SaveFile {
|
if !config.SaveFile {
|
||||||
fmt.Println("Copied to clipboard")
|
fmt.Println("Copied to clipboard")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ssJSON {
|
|
||||||
scale := result.Scale
|
|
||||||
if scale <= 0 {
|
|
||||||
scale = 1.0
|
|
||||||
}
|
|
||||||
writeScreenshotJSON(screenshotMetadata{
|
|
||||||
Status: "success",
|
|
||||||
Path: filePath,
|
|
||||||
Width: result.Buffer.Width,
|
|
||||||
Height: result.Buffer.Height,
|
|
||||||
Scale: scale,
|
|
||||||
Mime: formatMime(config.Format),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.Notify {
|
if config.Notify {
|
||||||
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
|
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
|
||||||
screenshot.SendNotification(screenshot.NotifyResult{
|
screenshot.SendNotification(screenshot.NotifyResult{
|
||||||
@@ -466,12 +378,6 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
|
|||||||
runScreenshot(config)
|
runScreenshot(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runScreenshotScroll(cmd *cobra.Command, args []string) {
|
|
||||||
config := getScreenshotConfig(screenshot.ModeScroll)
|
|
||||||
config.IntervalMs = min(max(ssScrollInterval, 30), 1000)
|
|
||||||
runScreenshot(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runScreenshotFull(cmd *cobra.Command, args []string) {
|
func runScreenshotFull(cmd *cobra.Command, args []string) {
|
||||||
config := getScreenshotConfig(screenshot.ModeFullScreen)
|
config := getScreenshotConfig(screenshot.ModeFullScreen)
|
||||||
runScreenshot(config)
|
runScreenshot(config)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func init() {
|
|||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
|
||||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
authCmd.AddCommand(authSyncCmd, authResolveLockCmd)
|
||||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||||
updateCmd.AddCommand(updateCheckCmd)
|
updateCmd.AddCommand(updateCheckCmd)
|
||||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func init() {
|
|||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
|
||||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
authCmd.AddCommand(authSyncCmd, authResolveLockCmd)
|
||||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||||
rootCmd.AddCommand(getCommonCommands()...)
|
rootCmd.AddCommand(getCommonCommands()...)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type minimalInstallGroup struct {
|
|||||||
|
|
||||||
func shouldPreferMinimalInstall(pkg string) bool {
|
func shouldPreferMinimalInstall(pkg string) bool {
|
||||||
switch pkg {
|
switch pkg {
|
||||||
case "niri", "niri-git", "hyprland", "hyprland-git":
|
case "niri", "niri-git":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -124,9 +124,7 @@ func GetLogger() *Logger {
|
|||||||
logger = &Logger{base}
|
logger = &Logger{base}
|
||||||
|
|
||||||
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||||
logMu.Lock()
|
_ = SetLogFile(path)
|
||||||
_ = setLogFile(logger, path)
|
|
||||||
logMu.Unlock()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return logger
|
return logger
|
||||||
@@ -147,18 +145,15 @@ func SetLevel(level string) {
|
|||||||
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
||||||
// the file stays plain while stderr keeps its colors.
|
// the file stays plain while stderr keeps its colors.
|
||||||
func SetLogFile(path string) error {
|
func SetLogFile(path string) error {
|
||||||
l := GetLogger()
|
|
||||||
logMu.Lock()
|
logMu.Lock()
|
||||||
defer logMu.Unlock()
|
defer logMu.Unlock()
|
||||||
return setLogFile(l, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLogFile(l *Logger, path string) error {
|
|
||||||
if logFile != nil {
|
if logFile != nil {
|
||||||
logFile.Close()
|
logFile.Close()
|
||||||
logFile = nil
|
logFile = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
l := GetLogger()
|
||||||
if path == "" {
|
if path == "" {
|
||||||
l.SetOutput(logStderr)
|
l.SetOutput(logStderr)
|
||||||
applyColorProfile(l, logStderr)
|
applyColorProfile(l, logStderr)
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ type TemplateDef struct {
|
|||||||
ID string
|
ID string
|
||||||
Commands []string
|
Commands []string
|
||||||
Flatpaks []string
|
Flatpaks []string
|
||||||
ConfigDirs []string
|
|
||||||
ConfigFile string
|
ConfigFile string
|
||||||
Kind TemplateKind
|
Kind TemplateKind
|
||||||
RunUnconditionally bool
|
RunUnconditionally bool
|
||||||
@@ -61,9 +60,9 @@ var templateRegistry = []TemplateDef{
|
|||||||
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
|
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
|
||||||
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
||||||
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
||||||
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigDirs: []string{"vesktop"}, ConfigFile: "vesktop.toml"},
|
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
||||||
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigDirs: []string{"Vencord"}, ConfigFile: "vencord.toml"},
|
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
|
||||||
{ID: "equibop", Commands: []string{"equibop"}, ConfigDirs: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
||||||
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
||||||
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
||||||
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
|
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
|
||||||
@@ -460,9 +459,9 @@ output_path = '%s'
|
|||||||
case TemplateKindGTK:
|
case TemplateKindGTK:
|
||||||
switch opts.Mode {
|
switch opts.Mode {
|
||||||
case ColorModeLight:
|
case ColorModeLight:
|
||||||
appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-light.toml")
|
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
|
||||||
default:
|
default:
|
||||||
appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-dark.toml")
|
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
|
||||||
}
|
}
|
||||||
case TemplateKindTerminal:
|
case TemplateKindTerminal:
|
||||||
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||||
@@ -475,10 +474,10 @@ output_path = '%s'
|
|||||||
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
|
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
|
||||||
case TemplateKindEmacs:
|
case TemplateKindEmacs:
|
||||||
if utils.EmacsConfigDir() != "" {
|
if utils.EmacsConfigDir() != "" {
|
||||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,14 +512,13 @@ func appendConfig(
|
|||||||
cfgFile *os.File,
|
cfgFile *os.File,
|
||||||
checkCmd []string,
|
checkCmd []string,
|
||||||
checkFlatpaks []string,
|
checkFlatpaks []string,
|
||||||
checkConfigDirs []string,
|
|
||||||
fileName string,
|
fileName string,
|
||||||
) {
|
) {
|
||||||
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
|
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
|
||||||
if _, err := os.Stat(configPath); err != nil {
|
if _, err := os.Stat(configPath); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) && !configDirExists(checkConfigDirs) {
|
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := os.ReadFile(configPath)
|
||||||
@@ -601,20 +599,6 @@ func templateSessionActive(tmpl TemplateDef) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func configDirExists(names []string) bool {
|
|
||||||
configHome := utils.XDGConfigHome()
|
|
||||||
if configHome == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, name := range names {
|
|
||||||
info, err := os.Stat(filepath.Join(configHome, name))
|
|
||||||
if err == nil && info.IsDir() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
|
func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
|
||||||
// Both nil is treated as "skip check" / unconditionally run
|
// Both nil is treated as "skip check" / unconditionally run
|
||||||
if checkCmd == nil && checkFlatpaks == nil {
|
if checkCmd == nil && checkFlatpaks == nil {
|
||||||
@@ -1112,7 +1096,7 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
|
|||||||
case tmpl.Kind == TemplateKindEmacs:
|
case tmpl.Kind == TemplateKindEmacs:
|
||||||
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != ""
|
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != ""
|
||||||
default:
|
default:
|
||||||
detected = (appExists(checker, tmpl.Commands, tmpl.Flatpaks) || configDirExists(tmpl.ConfigDirs)) && templateSessionActive(tmpl)
|
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && templateSessionActive(tmpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
|
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestAppendConfigBinaryExists(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{"sh"}, nil, nil, "test.toml")
|
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -82,7 +82,7 @@ func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, nil, "test.toml")
|
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -122,7 +122,7 @@ func TestAppendConfigFlatpakExists(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, nil, "test.toml")
|
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -163,7 +163,7 @@ func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
|
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -203,7 +203,7 @@ func TestAppendConfigBothExist(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, nil, "test.toml")
|
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -244,7 +244,7 @@ func TestAppendConfigNeitherExists(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
|
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -281,7 +281,7 @@ func TestAppendConfigNoChecks(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir}
|
opts := &Options{ShellDir: shellDir}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, nil, nil, nil, "test.toml")
|
appendConfig(opts, cfgFile, nil, nil, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -312,7 +312,7 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir}
|
opts := &Options{ShellDir: shellDir}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, nil, nil, nil, "nonexistent.toml")
|
appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -487,90 +487,3 @@ func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.NotContains(t, string(output), "[templates.dmsmango]")
|
assert.NotContains(t, string(output), "[templates.dmsmango]")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppendConfigConfigDirExists(t *testing.T) {
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
shellDir := filepath.Join(tempDir, "shell")
|
|
||||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
|
||||||
if err := os.MkdirAll(configsDir, 0o755); err != nil {
|
|
||||||
t.Fatalf("failed to create configs dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
testConfig := "vencord config content"
|
|
||||||
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte(testConfig), 0o644); err != nil {
|
|
||||||
t.Fatalf("failed to write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
configHome := filepath.Join(tempDir, "config")
|
|
||||||
if err := os.MkdirAll(filepath.Join(configHome, "Vencord"), 0o755); err != nil {
|
|
||||||
t.Fatalf("failed to create Vencord config dir: %v", err)
|
|
||||||
}
|
|
||||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
|
||||||
|
|
||||||
outFile := filepath.Join(tempDir, "output.toml")
|
|
||||||
cfgFile, err := os.Create(outFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create output file: %v", err)
|
|
||||||
}
|
|
||||||
defer cfgFile.Close()
|
|
||||||
|
|
||||||
mockChecker := mocks_utils.NewMockAppChecker(t)
|
|
||||||
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
|
|
||||||
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
|
|
||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
|
|
||||||
|
|
||||||
cfgFile.Close()
|
|
||||||
output, err := os.ReadFile(outFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read output: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, testConfig+"\n", string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppendConfigConfigDirDoesNotExist(t *testing.T) {
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
shellDir := filepath.Join(tempDir, "shell")
|
|
||||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
|
||||||
if err := os.MkdirAll(configsDir, 0o755); err != nil {
|
|
||||||
t.Fatalf("failed to create configs dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte("vencord config content"), 0o644); err != nil {
|
|
||||||
t.Fatalf("failed to write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
configHome := filepath.Join(tempDir, "config")
|
|
||||||
if err := os.MkdirAll(configHome, 0o755); err != nil {
|
|
||||||
t.Fatalf("failed to create config home: %v", err)
|
|
||||||
}
|
|
||||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
|
||||||
|
|
||||||
outFile := filepath.Join(tempDir, "output.toml")
|
|
||||||
cfgFile, err := os.Create(outFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create output file: %v", err)
|
|
||||||
}
|
|
||||||
defer cfgFile.Close()
|
|
||||||
|
|
||||||
mockChecker := mocks_utils.NewMockAppChecker(t)
|
|
||||||
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
|
|
||||||
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
|
|
||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
|
|
||||||
|
|
||||||
cfgFile.Close()
|
|
||||||
output, err := os.ReadFile(outFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read output: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Empty(t, string(output))
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-334
@@ -74,7 +74,6 @@ type AuthSettings struct {
|
|||||||
EnableU2f bool `json:"enableU2f"`
|
EnableU2f bool `json:"enableU2f"`
|
||||||
GreeterEnableFprint bool `json:"greeterEnableFprint"`
|
GreeterEnableFprint bool `json:"greeterEnableFprint"`
|
||||||
GreeterEnableU2f bool `json:"greeterEnableU2f"`
|
GreeterEnableU2f bool `json:"greeterEnableU2f"`
|
||||||
GreeterPamExternallyManaged bool `json:"greeterPamExternallyManaged"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncAuthOptions struct {
|
type SyncAuthOptions struct {
|
||||||
@@ -237,14 +236,6 @@ func syncAuthConfigWithDeps(logFunc func(string), sudoPassword string, options S
|
|||||||
return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err)
|
return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.GreeterPamExternallyManaged {
|
|
||||||
if err := removeManagedGreeterPamBlockWithDeps(logFunc, sudoPassword, deps); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logFunc("ℹ /etc/pam.d/greetd is externally managed. Skipping DMS greeter PAM sync.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil {
|
if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -592,330 +583,6 @@ func buildManagedLockscreenPamContent(baseDirs []string, readFile func(string) (
|
|||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var lockscreenPamCandidateServices = []string{
|
|
||||||
"login",
|
|
||||||
"system-auth",
|
|
||||||
"system-login",
|
|
||||||
"system-local-login",
|
|
||||||
"common-auth",
|
|
||||||
"base-auth",
|
|
||||||
}
|
|
||||||
|
|
||||||
type LockscreenPamServiceInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Dir string `json:"dir"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
HasAuth bool `json:"hasAuth"`
|
|
||||||
InlineFingerprint bool `json:"inlineFingerprint"`
|
|
||||||
InlineU2f bool `json:"inlineU2f"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LockscreenPamValidation struct {
|
|
||||||
Valid bool `json:"valid"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
HasAuth bool `json:"hasAuth"`
|
|
||||||
InlineFingerprint bool `json:"inlineFingerprint"`
|
|
||||||
InlineU2f bool `json:"inlineU2f"`
|
|
||||||
MissingModules []string `json:"missingModules"`
|
|
||||||
Warnings []string `json:"warnings"`
|
|
||||||
Errors []string `json:"errors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type lockscreenPamValidateDeps struct {
|
|
||||||
baseDirs []string
|
|
||||||
readFile func(string) ([]byte, error)
|
|
||||||
stat func(string) (os.FileInfo, error)
|
|
||||||
pamModuleExists func(string) bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultValidateDeps() lockscreenPamValidateDeps {
|
|
||||||
return lockscreenPamValidateDeps{
|
|
||||||
baseDirs: lockscreenPamBaseDirs,
|
|
||||||
readFile: os.ReadFile,
|
|
||||||
stat: os.Stat,
|
|
||||||
pamModuleExists: pamModuleExists,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lockscreenPamAnalysis is a non-destructive walk of a PAM service. Unlike
|
|
||||||
// resolveService it detects (rather than strips) pam_fprintd/pam_u2f and
|
|
||||||
// records unknown directives instead of hard-failing on them.
|
|
||||||
type lockscreenPamAnalysis struct {
|
|
||||||
lines []string
|
|
||||||
hasAuth bool
|
|
||||||
inlineFingerprint bool
|
|
||||||
inlineU2f bool
|
|
||||||
modules []string
|
|
||||||
authModules []string
|
|
||||||
unknownDirectives []string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r lockscreenPamResolver) analyzePath(path string) lockscreenPamAnalysis {
|
|
||||||
var acc lockscreenPamAnalysis
|
|
||||||
if err := r.analyzeInto(filepath.Clean(path), "", nil, &acc); err != nil {
|
|
||||||
acc.err = err
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack []string, acc *lockscreenPamAnalysis) error {
|
|
||||||
for _, seen := range stack {
|
|
||||||
if seen == path {
|
|
||||||
chain := append(append([]string{}, stack...), path)
|
|
||||||
display := make([]string, 0, len(chain))
|
|
||||||
for _, item := range chain {
|
|
||||||
display = append(display, filepath.Base(item))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := r.readFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read PAM file %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rawLine := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
|
|
||||||
rawLine = strings.TrimRight(rawLine, "\r")
|
|
||||||
trimmed := strings.TrimSpace(rawLine)
|
|
||||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok {
|
|
||||||
lineType := pamDirectiveType(trimmed)
|
|
||||||
if filterType != "" && lineType != "" && lineType != filterType {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nestedPath := include.target
|
|
||||||
if filepath.IsAbs(nestedPath) {
|
|
||||||
nestedPath = filepath.Clean(nestedPath)
|
|
||||||
} else {
|
|
||||||
located, err := r.locate(include.target)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read PAM file %s: %w", include.target, err)
|
|
||||||
}
|
|
||||||
nestedPath = located
|
|
||||||
}
|
|
||||||
if err := r.analyzeInto(nestedPath, include.filterType, append(stack, path), acc); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lineType := pamDirectiveType(trimmed)
|
|
||||||
if lineType == "" {
|
|
||||||
acc.unknownDirectives = append(acc.unknownDirectives, trimmed)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if filterType != "" && lineType != filterType {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.lines = append(acc.lines, rawLine)
|
|
||||||
if lineType == "auth" {
|
|
||||||
acc.hasAuth = true
|
|
||||||
}
|
|
||||||
|
|
||||||
foundModule := false
|
|
||||||
for _, field := range strings.Fields(trimmed) {
|
|
||||||
if strings.HasPrefix(field, "#") {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if strings.Contains(field, "pam_fprintd") {
|
|
||||||
acc.inlineFingerprint = true
|
|
||||||
}
|
|
||||||
if strings.Contains(field, "pam_u2f") {
|
|
||||||
acc.inlineU2f = true
|
|
||||||
}
|
|
||||||
if !foundModule && strings.HasSuffix(field, ".so") {
|
|
||||||
acc.modules = append(acc.modules, field)
|
|
||||||
if lineType == "auth" {
|
|
||||||
acc.authModules = append(acc.authModules, field)
|
|
||||||
}
|
|
||||||
foundModule = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Earlier base dir wins per name (libpam precedence).
|
|
||||||
func ListLockscreenPamServices() []LockscreenPamServiceInfo {
|
|
||||||
return listLockscreenPamServices(lockscreenPamBaseDirs, os.ReadFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listLockscreenPamServices(baseDirs []string, readFile func(string) ([]byte, error)) []LockscreenPamServiceInfo {
|
|
||||||
resolver := lockscreenPamResolver{baseDirs: baseDirs, readFile: readFile}
|
|
||||||
out := make([]LockscreenPamServiceInfo, 0, len(lockscreenPamCandidateServices))
|
|
||||||
for _, name := range lockscreenPamCandidateServices {
|
|
||||||
path, err := resolver.locate(name)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info := LockscreenPamServiceInfo{
|
|
||||||
Name: name,
|
|
||||||
Dir: filepath.Dir(path),
|
|
||||||
Path: path,
|
|
||||||
}
|
|
||||||
if analysis := resolver.analyzePath(path); analysis.err == nil {
|
|
||||||
info.HasAuth = analysis.hasAuth
|
|
||||||
info.InlineFingerprint = analysis.inlineFingerprint
|
|
||||||
info.InlineU2f = analysis.inlineU2f
|
|
||||||
}
|
|
||||||
out = append(out, info)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateLockscreenPamService(name string) LockscreenPamValidation {
|
|
||||||
return validateLockscreenPam(name, "", defaultValidateDeps())
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateLockscreenPamPath(path string) LockscreenPamValidation {
|
|
||||||
return validateLockscreenPam("", path, defaultValidateDeps())
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateLockscreenU2fPamService(name string) LockscreenPamValidation {
|
|
||||||
return validateLockscreenU2fPam(name, "", defaultValidateDeps())
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateLockscreenU2fPamPath(path string) LockscreenPamValidation {
|
|
||||||
return validateLockscreenU2fPam("", path, defaultValidateDeps())
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
|
|
||||||
result := LockscreenPamValidation{
|
|
||||||
MissingModules: []string{},
|
|
||||||
Warnings: []string{},
|
|
||||||
Errors: []string{},
|
|
||||||
}
|
|
||||||
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
|
|
||||||
|
|
||||||
var analysis lockscreenPamAnalysis
|
|
||||||
if path != "" {
|
|
||||||
result.Path = path
|
|
||||||
analysis = resolver.analyzePath(path)
|
|
||||||
} else {
|
|
||||||
located, err := resolver.locate(serviceName)
|
|
||||||
if err != nil {
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("PAM service %q not found: %v", serviceName, err))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
result.Path = located
|
|
||||||
analysis = resolver.analyzePath(located)
|
|
||||||
}
|
|
||||||
|
|
||||||
if analysis.err != nil {
|
|
||||||
result.Errors = append(result.Errors, analysis.err.Error())
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
result.HasAuth = analysis.hasAuth
|
|
||||||
result.InlineFingerprint = analysis.inlineFingerprint
|
|
||||||
result.InlineU2f = analysis.inlineU2f
|
|
||||||
|
|
||||||
if !analysis.hasAuth {
|
|
||||||
result.Errors = append(result.Errors, "no auth directives found after include resolution")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, directive := range analysis.unknownDirectives {
|
|
||||||
result.Warnings = append(result.Warnings, "unsupported PAM directive (libpam may still handle it at runtime): "+directive)
|
|
||||||
}
|
|
||||||
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, ref := range analysis.modules {
|
|
||||||
name := filepath.Base(ref)
|
|
||||||
if seen[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[name] = true
|
|
||||||
if moduleReferenceExists(ref, deps) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.MissingModules = append(result.MissingModules, name)
|
|
||||||
result.Warnings = append(result.Warnings, "referenced PAM module not found: "+name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if analysis.inlineFingerprint {
|
|
||||||
result.Warnings = append(result.Warnings, "pam_fprintd is present in the resolved stack; may double-prompt with DMS's separate fingerprint context")
|
|
||||||
}
|
|
||||||
if analysis.inlineU2f {
|
|
||||||
result.Warnings = append(result.Warnings, "pam_u2f is present in the resolved stack; may double-prompt with DMS's separate U2F context")
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Valid = len(result.Errors) == 0
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateLockscreenU2fPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
|
|
||||||
result := validateLockscreenPam(serviceName, path, deps)
|
|
||||||
if result.Path == "" {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
|
|
||||||
analysis := resolver.analyzePath(result.Path)
|
|
||||||
if analysis.err != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredWarnings := result.Warnings[:0]
|
|
||||||
for _, warning := range result.Warnings {
|
|
||||||
if strings.Contains(warning, "pam_u2f is present") && strings.Contains(warning, "double-prompt") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filteredWarnings = append(filteredWarnings, warning)
|
|
||||||
}
|
|
||||||
result.Warnings = filteredWarnings
|
|
||||||
|
|
||||||
hasU2fAuth := false
|
|
||||||
unsafeModules := []string{}
|
|
||||||
unsafeSeen := map[string]bool{}
|
|
||||||
for _, ref := range analysis.authModules {
|
|
||||||
name := filepath.Base(ref)
|
|
||||||
if name == "pam_u2f.so" {
|
|
||||||
hasU2fAuth = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch name {
|
|
||||||
case "pam_env.so", "pam_faildelay.so", "pam_nologin.so":
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
if !unsafeSeen[name] {
|
|
||||||
unsafeSeen[name] = true
|
|
||||||
unsafeModules = append(unsafeModules, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasU2fAuth {
|
|
||||||
result.Errors = append(result.Errors, "no pam_u2f auth directive found; select a dedicated security-key PAM service")
|
|
||||||
}
|
|
||||||
for _, name := range unsafeModules {
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("additional auth module %s is not allowed in a dedicated security-key PAM service", name))
|
|
||||||
}
|
|
||||||
for _, name := range result.MissingModules {
|
|
||||||
if strings.Contains(name, "pam_u2f") {
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("%s is not installed or its configured path is unavailable", name))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Valid = len(result.Errors) == 0
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool {
|
|
||||||
if filepath.IsAbs(ref) {
|
|
||||||
_, err := deps.stat(ref)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
return deps.pamModuleExists(ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserLockscreenPamService = "dankshell"
|
const UserLockscreenPamService = "dankshell"
|
||||||
|
|
||||||
func UserLockscreenPamDir() string {
|
func UserLockscreenPamDir() string {
|
||||||
@@ -965,7 +632,7 @@ func buildManagedLockscreenU2FPamContent() string {
|
|||||||
|
|
||||||
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
|
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
|
||||||
if deps.isNixOS() {
|
if deps.isNixOS() {
|
||||||
logFunc("ℹ NixOS detected. DMS does not write /etc/pam.d/dankshell; the lock screen uses a sanitized password-only service in the user state directory unless you select a custom PAM source.")
|
logFunc("ℹ NixOS detected. DMS continues to use /etc/pam.d/login for lock screen password auth on NixOS unless you declare security.pam.services.dankshell yourself. U2F and fingerprint are handled separately and should not be included in dankshell.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -611,8 +611,8 @@ func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err)
|
t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err)
|
||||||
}
|
}
|
||||||
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "sanitized password-only service") {
|
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") {
|
||||||
t.Fatalf("expected NixOS informational log describing the user-state fallback, got %v", logs)
|
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
|
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
|
||||||
t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
|
t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
|
||||||
@@ -786,301 +786,6 @@ func TestRemoveManagedGreeterPamBlockWithDeps(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *pamTestEnv) validateDeps() lockscreenPamValidateDeps {
|
|
||||||
return lockscreenPamValidateDeps{
|
|
||||||
baseDirs: []string{e.pamDir},
|
|
||||||
readFile: os.ReadFile,
|
|
||||||
stat: os.Stat,
|
|
||||||
pamModuleExists: func(module string) bool { return e.availableModules[module] },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListLockscreenPamServices(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
t.Run("dedupes by name with earlier base dir winning", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
etcDir := t.TempDir()
|
|
||||||
vendorDir := t.TempDir()
|
|
||||||
// login exists in both dirs; system-auth only in the vendor dir.
|
|
||||||
writeTestFile(t, filepath.Join(etcDir, "login"), "#%PAM-1.0\nauth required pam_unix.so\naccount required pam_unix.so\n")
|
|
||||||
writeTestFile(t, filepath.Join(vendorDir, "login"), "#%PAM-1.0\nauth required pam_deny.so\n")
|
|
||||||
writeTestFile(t, filepath.Join(vendorDir, "system-auth"), "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n")
|
|
||||||
|
|
||||||
services := listLockscreenPamServices([]string{etcDir, vendorDir}, os.ReadFile)
|
|
||||||
if len(services) != 2 {
|
|
||||||
t.Fatalf("expected 2 services (login, system-auth), got %d: %+v", len(services), services)
|
|
||||||
}
|
|
||||||
byName := map[string]LockscreenPamServiceInfo{}
|
|
||||||
for _, s := range services {
|
|
||||||
byName[s.Name] = s
|
|
||||||
}
|
|
||||||
login, ok := byName["login"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected login service, got %+v", services)
|
|
||||||
}
|
|
||||||
if login.Dir != etcDir || login.Path != filepath.Join(etcDir, "login") {
|
|
||||||
t.Fatalf("expected login to resolve in earlier dir %s, got dir=%s path=%s", etcDir, login.Dir, login.Path)
|
|
||||||
}
|
|
||||||
if !login.HasAuth {
|
|
||||||
t.Fatalf("expected login to report hasAuth")
|
|
||||||
}
|
|
||||||
if byName["system-auth"].Dir != vendorDir {
|
|
||||||
t.Fatalf("expected system-auth to resolve in vendor dir, got %s", byName["system-auth"].Dir)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("include resolution sets hasAuth and detects inline fprintd/u2f", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
|
|
||||||
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n")
|
|
||||||
|
|
||||||
services := listLockscreenPamServices([]string{env.pamDir}, os.ReadFile)
|
|
||||||
var login LockscreenPamServiceInfo
|
|
||||||
for _, s := range services {
|
|
||||||
if s.Name == "login" {
|
|
||||||
login = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !login.HasAuth {
|
|
||||||
t.Fatalf("expected hasAuth via resolved include")
|
|
||||||
}
|
|
||||||
if !login.InlineFingerprint || !login.InlineU2f {
|
|
||||||
t.Fatalf("expected inline fingerprint and u2f detection, got %+v", login)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateLockscreenPam(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
t.Run("valid service with resolved auth", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.availableModules["pam_unix.so"] = true
|
|
||||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
|
|
||||||
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
|
|
||||||
|
|
||||||
result := validateLockscreenPam("login", "", env.validateDeps())
|
|
||||||
if !result.Valid {
|
|
||||||
t.Fatalf("expected valid result, got %+v", result)
|
|
||||||
}
|
|
||||||
if !result.HasAuth {
|
|
||||||
t.Fatalf("expected hasAuth true")
|
|
||||||
}
|
|
||||||
if len(result.Errors) != 0 {
|
|
||||||
t.Fatalf("expected no errors, got %v", result.Errors)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("path outside base dirs is read directly", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.availableModules["pam_unix.so"] = true
|
|
||||||
outside := filepath.Join(t.TempDir(), "custom-pam")
|
|
||||||
writeTestFile(t, outside, "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n")
|
|
||||||
|
|
||||||
result := validateLockscreenPam("", outside, env.validateDeps())
|
|
||||||
if !result.Valid || result.Path != outside {
|
|
||||||
t.Fatalf("expected valid result for outside path, got %+v", result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("missing module produces warning and missingModules but stays valid", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.availableModules["pam_unix.so"] = true
|
|
||||||
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth required pam_absent.so\naccount required pam_unix.so\n")
|
|
||||||
|
|
||||||
result := validateLockscreenPam("system-auth", "", env.validateDeps())
|
|
||||||
if !result.Valid {
|
|
||||||
t.Fatalf("expected valid despite missing module, got %+v", result)
|
|
||||||
}
|
|
||||||
if len(result.MissingModules) != 1 || result.MissingModules[0] != "pam_absent.so" {
|
|
||||||
t.Fatalf("expected missing pam_absent.so, got %v", result.MissingModules)
|
|
||||||
}
|
|
||||||
if !containsSubstr(result.Warnings, "pam_absent.so") {
|
|
||||||
t.Fatalf("expected warning about missing module, got %v", result.Warnings)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("unknown directive is a warning not an error", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.availableModules["pam_unix.so"] = true
|
|
||||||
env.availableModules["pam_foo.so"] = true
|
|
||||||
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nbadtype required pam_foo.so\naccount required pam_unix.so\n")
|
|
||||||
|
|
||||||
result := validateLockscreenPam("system-auth", "", env.validateDeps())
|
|
||||||
if !result.Valid {
|
|
||||||
t.Fatalf("expected valid with unknown directive, got %+v", result)
|
|
||||||
}
|
|
||||||
if len(result.Errors) != 0 {
|
|
||||||
t.Fatalf("expected no errors, got %v", result.Errors)
|
|
||||||
}
|
|
||||||
if !containsSubstr(result.Warnings, "unsupported PAM directive") {
|
|
||||||
t.Fatalf("expected unsupported directive warning, got %v", result.Warnings)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("cyclic include is an error", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\n")
|
|
||||||
env.writePamFile(t, "system-auth", "auth include login\n")
|
|
||||||
|
|
||||||
result := validateLockscreenPam("login", "", env.validateDeps())
|
|
||||||
if result.Valid {
|
|
||||||
t.Fatalf("expected invalid on cyclic include, got %+v", result)
|
|
||||||
}
|
|
||||||
if !containsSubstr(result.Errors, "cyclic PAM include detected") {
|
|
||||||
t.Fatalf("expected cyclic include error, got %v", result.Errors)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no auth directives is an error", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.availableModules["pam_unix.so"] = true
|
|
||||||
env.writePamFile(t, "system-auth", "#%PAM-1.0\naccount required pam_unix.so\n")
|
|
||||||
|
|
||||||
result := validateLockscreenPam("system-auth", "", env.validateDeps())
|
|
||||||
if result.Valid {
|
|
||||||
t.Fatalf("expected invalid when no auth directives, got %+v", result)
|
|
||||||
}
|
|
||||||
if !containsSubstr(result.Errors, "no auth directives") {
|
|
||||||
t.Fatalf("expected no-auth error, got %v", result.Errors)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("missing file is an error", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
result := validateLockscreenPam("", filepath.Join(env.pamDir, "does-not-exist"), env.validateDeps())
|
|
||||||
if result.Valid || len(result.Errors) == 0 {
|
|
||||||
t.Fatalf("expected invalid for missing file, got %+v", result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("inline fingerprint and u2f produce warnings", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.availableModules["pam_unix.so"] = true
|
|
||||||
env.availableModules["pam_fprintd.so"] = true
|
|
||||||
env.availableModules["pam_u2f.so"] = true
|
|
||||||
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n")
|
|
||||||
|
|
||||||
result := validateLockscreenPam("system-auth", "", env.validateDeps())
|
|
||||||
if !result.Valid {
|
|
||||||
t.Fatalf("expected valid, got %+v", result)
|
|
||||||
}
|
|
||||||
if !result.InlineFingerprint || !result.InlineU2f {
|
|
||||||
t.Fatalf("expected inline flags set, got %+v", result)
|
|
||||||
}
|
|
||||||
if !containsSubstr(result.Warnings, "pam_fprintd") || !containsSubstr(result.Warnings, "pam_u2f") {
|
|
||||||
t.Fatalf("expected double-prompt warnings, got %v", result.Warnings)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateLockscreenU2fPam(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
t.Run("accepts a dedicated U2F stack with custom options", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.availableModules["pam_u2f.so"] = true
|
|
||||||
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue authfile=/etc/u2f-mappings\naccount required pam_permit.so\n")
|
|
||||||
|
|
||||||
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
|
|
||||||
if !result.Valid {
|
|
||||||
t.Fatalf("expected valid dedicated U2F stack, got %+v", result)
|
|
||||||
}
|
|
||||||
if !result.InlineU2f {
|
|
||||||
t.Fatalf("expected inline U2F detection, got %+v", result)
|
|
||||||
}
|
|
||||||
if containsSubstr(result.Warnings, "double-prompt") {
|
|
||||||
t.Fatalf("dedicated U2F validation should not warn about its expected U2F module: %v", result.Warnings)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects a primary login stack that also prompts for a password", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.availableModules["pam_unix.so"] = true
|
|
||||||
env.availableModules["pam_u2f.so"] = true
|
|
||||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth required pam_unix.so\nauth required pam_u2f.so cue\naccount required pam_unix.so\n")
|
|
||||||
|
|
||||||
result := validateLockscreenU2fPam("login", "", env.validateDeps())
|
|
||||||
if result.Valid {
|
|
||||||
t.Fatalf("expected mixed password/U2F stack to be rejected, got %+v", result)
|
|
||||||
}
|
|
||||||
if !containsSubstr(result.Errors, "pam_unix.so") || !containsSubstr(result.Errors, "dedicated security-key") {
|
|
||||||
t.Fatalf("expected actionable mixed-stack error, got %v", result.Errors)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects a stack without pam_u2f", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.availableModules["pam_unix.so"] = true
|
|
||||||
env.writePamFile(t, "password-only", "#%PAM-1.0\nauth required pam_unix.so\n")
|
|
||||||
|
|
||||||
result := validateLockscreenU2fPam("password-only", "", env.validateDeps())
|
|
||||||
if result.Valid || !containsSubstr(result.Errors, "pam_u2f") {
|
|
||||||
t.Fatalf("expected missing-U2F error, got %+v", result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("does not accept a similarly named module as pam_u2f", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.availableModules["pam_u2f_helper.so"] = true
|
|
||||||
env.writePamFile(t, "not-u2f", "#%PAM-1.0\nauth required pam_u2f_helper.so\n")
|
|
||||||
|
|
||||||
result := validateLockscreenU2fPam("not-u2f", "", env.validateDeps())
|
|
||||||
if result.Valid || !containsSubstr(result.Errors, "no pam_u2f auth directive") {
|
|
||||||
t.Fatalf("expected exact pam_u2f module validation, got %+v", result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects a missing pam_u2f module", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue\n")
|
|
||||||
|
|
||||||
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
|
|
||||||
if result.Valid || !containsSubstr(result.Errors, "pam_u2f.so is not installed") {
|
|
||||||
t.Fatalf("expected missing-module error, got %+v", result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsSubstr(items []string, substr string) bool {
|
|
||||||
for _, item := range items {
|
|
||||||
if strings.Contains(item, substr) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSyncAuthConfigWithDeps(t *testing.T) {
|
func TestSyncAuthConfigWithDeps(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1143,39 +848,6 @@ func TestSyncAuthConfigWithDeps(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("externally managed greetd is stripped and greeter sync skipped", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newPamTestEnv(t)
|
|
||||||
env.availableModules["pam_fprintd.so"] = true
|
|
||||||
env.writeSettings(t, `{"greeterPamExternallyManaged":true,"greeterEnableFprint":true}`)
|
|
||||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
|
|
||||||
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
|
|
||||||
env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\n"+
|
|
||||||
GreeterPamManagedBlockStart+"\n"+
|
|
||||||
"auth sufficient pam_fprintd.so max-tries=2 timeout=10\n"+
|
|
||||||
GreeterPamManagedBlockEnd+"\n")
|
|
||||||
|
|
||||||
var logs []string
|
|
||||||
err := syncAuthConfigWithDeps(func(msg string) {
|
|
||||||
logs = append(logs, msg)
|
|
||||||
}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
greetd := readFileString(t, env.greetdPath)
|
|
||||||
if strings.Contains(greetd, GreeterPamManagedBlockStart) || strings.Contains(greetd, "pam_fprintd") {
|
|
||||||
t.Fatalf("expected DMS-managed block stripped from externally managed greetd:\n%s", greetd)
|
|
||||||
}
|
|
||||||
if !strings.Contains(greetd, "auth include system-auth") {
|
|
||||||
t.Fatalf("expected non-DMS greetd lines to remain:\n%s", greetd)
|
|
||||||
}
|
|
||||||
if !containsSubstr(logs, "externally managed") {
|
|
||||||
t.Fatalf("expected externally-managed skip log, got %v", logs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NixOS remains informational and non-mutating", func(t *testing.T) {
|
t.Run("NixOS remains informational and non-mutating", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package qmlchecks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGreeterExternalAuthStatusUsesEffectiveFingerprintAvailability(t *testing.T) {
|
|
||||||
data, err := os.ReadFile("../../../quickshell/Modules/Greetd/GreeterContent.qml")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read greeter QML: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
content := string(data)
|
|
||||||
for _, required := range []string{
|
|
||||||
"readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f",
|
|
||||||
"if (greeterPamHasFprint && greeterPamHasU2f)",
|
|
||||||
"if (greeterPamHasFprint)",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(content, required) {
|
|
||||||
t.Fatalf("greeter external-auth status must contain %q", required)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,51 +23,3 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
|
|||||||
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
|
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLockScreenAuthenticationCardOwnsFactorControls(t *testing.T) {
|
|
||||||
data, err := os.ReadFile("../../../quickshell/Modules/Settings/LockScreenTab.qml")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read lock screen settings QML: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
content := string(data)
|
|
||||||
authCard := strings.Index(content, `title: I18n.tr("Authentication")`)
|
|
||||||
behaviorCard := strings.Index(content, `title: I18n.tr("Behavior")`)
|
|
||||||
fingerprintToggle := strings.Index(content, `settingKey: "enableFprint"`)
|
|
||||||
u2fToggle := strings.Index(content, `settingKey: "enableU2f"`)
|
|
||||||
u2fSource := strings.Index(content, `settingKey: "lockU2fPamPath"`)
|
|
||||||
if authCard < 0 || behaviorCard < 0 || fingerprintToggle < 0 || u2fToggle < 0 || u2fSource < 0 {
|
|
||||||
t.Fatalf("expected authentication card, factor toggles, and U2F source setting")
|
|
||||||
}
|
|
||||||
for name, position := range map[string]int{
|
|
||||||
"fingerprint toggle": fingerprintToggle,
|
|
||||||
"U2F toggle": u2fToggle,
|
|
||||||
"U2F source": u2fSource,
|
|
||||||
} {
|
|
||||||
if position < authCard || position > behaviorCard {
|
|
||||||
t.Fatalf("%s must remain in the authentication card", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
|
|
||||||
data, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read lock screen PAM QML: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
content := string(data)
|
|
||||||
for _, required := range []string{
|
|
||||||
"SettingsData.lockPamExternallyManaged",
|
|
||||||
"SettingsData.lockU2fPamPath",
|
|
||||||
"customU2fPamActive",
|
|
||||||
"u2fSuppressedByPrimaryPam",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(content, required) {
|
|
||||||
t.Fatalf("lock screen PAM must contain %q", required)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(content, "runningFromNixStore || resolveUserPam.running") {
|
|
||||||
t.Fatalf("DMS-managed policy must generate the sanitized user PAM stack on Nix-store installs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,30 +30,41 @@ func DetectCompositor() Compositor {
|
|||||||
return detectedCompositor
|
return detectedCompositor
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates := []struct {
|
hyprlandSig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
|
||||||
socket string
|
niriSocket := os.Getenv("NIRI_SOCKET")
|
||||||
needsStat bool
|
swaySocket := os.Getenv("SWAYSOCK")
|
||||||
compositor Compositor
|
scrollSocket := os.Getenv("SCROLLSOCK")
|
||||||
}{
|
miracleSocket := os.Getenv("MIRACLESOCK")
|
||||||
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
|
mangoSocket := os.Getenv("MANGO_INSTANCE_SIGNATURE")
|
||||||
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
|
|
||||||
{os.Getenv("SCROLLSOCK"), true, CompositorScroll},
|
|
||||||
{os.Getenv("MIRACLESOCK"), true, CompositorMiracle},
|
|
||||||
{os.Getenv("SWAYSOCK"), true, CompositorSway},
|
|
||||||
{os.Getenv("HYPRLAND_INSTANCE_SIGNATURE"), false, CompositorHyprland},
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stale env var from a previous session must not mask the live compositor
|
switch {
|
||||||
for _, c := range candidates {
|
case mangoSocket != "":
|
||||||
if c.socket == "" {
|
if _, err := os.Stat(mangoSocket); err == nil {
|
||||||
continue
|
detectedCompositor = CompositorMango
|
||||||
|
return detectedCompositor
|
||||||
}
|
}
|
||||||
if c.needsStat {
|
case niriSocket != "":
|
||||||
if _, err := os.Stat(c.socket); err != nil {
|
if _, err := os.Stat(niriSocket); err == nil {
|
||||||
continue
|
detectedCompositor = CompositorNiri
|
||||||
|
return detectedCompositor
|
||||||
}
|
}
|
||||||
|
case scrollSocket != "":
|
||||||
|
if _, err := os.Stat(scrollSocket); err == nil {
|
||||||
|
detectedCompositor = CompositorScroll
|
||||||
|
return detectedCompositor
|
||||||
}
|
}
|
||||||
detectedCompositor = c.compositor
|
case miracleSocket != "":
|
||||||
|
if _, err := os.Stat(miracleSocket); err == nil {
|
||||||
|
detectedCompositor = CompositorMiracle
|
||||||
|
return detectedCompositor
|
||||||
|
}
|
||||||
|
case swaySocket != "":
|
||||||
|
if _, err := os.Stat(swaySocket); err == nil {
|
||||||
|
detectedCompositor = CompositorSway
|
||||||
|
return detectedCompositor
|
||||||
|
}
|
||||||
|
case hyprlandSig != "":
|
||||||
|
detectedCompositor = CompositorHyprland
|
||||||
return detectedCompositor
|
return detectedCompositor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +90,7 @@ func GetActiveWindow() (*WindowGeometry, error) {
|
|||||||
case CompositorMango:
|
case CompositorMango:
|
||||||
return getMangoActiveWindow()
|
return getMangoActiveWindow()
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
|
return nil, fmt.Errorf("window capture requires Hyprland or Mango")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,58 +54,6 @@ func BufferToImageWithFormat(buf *ShmBuffer, format uint32) *image.RGBA {
|
|||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
func ImageToBuffer(img image.Image) (*ShmBuffer, error) {
|
|
||||||
bounds := img.Bounds()
|
|
||||||
w, h := bounds.Dx(), bounds.Dy()
|
|
||||||
buf, err := CreateShmBuffer(w, h, w*4)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
data := buf.Data()
|
|
||||||
switch src := img.(type) {
|
|
||||||
case *image.NRGBA:
|
|
||||||
for y := range h {
|
|
||||||
srcOff := y * src.Stride
|
|
||||||
dstOff := y * buf.Stride
|
|
||||||
for x := range w {
|
|
||||||
si, di := srcOff+x*4, dstOff+x*4
|
|
||||||
a := uint32(src.Pix[si+3])
|
|
||||||
data[di+0] = uint8(uint32(src.Pix[si+2]) * a / 255)
|
|
||||||
data[di+1] = uint8(uint32(src.Pix[si+1]) * a / 255)
|
|
||||||
data[di+2] = uint8(uint32(src.Pix[si+0]) * a / 255)
|
|
||||||
data[di+3] = uint8(a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case *image.RGBA:
|
|
||||||
for y := range h {
|
|
||||||
srcOff := y * src.Stride
|
|
||||||
dstOff := y * buf.Stride
|
|
||||||
for x := range w {
|
|
||||||
si, di := srcOff+x*4, dstOff+x*4
|
|
||||||
data[di+0] = src.Pix[si+2]
|
|
||||||
data[di+1] = src.Pix[si+1]
|
|
||||||
data[di+2] = src.Pix[si+0]
|
|
||||||
data[di+3] = src.Pix[si+3]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
for y := range h {
|
|
||||||
dstOff := y * buf.Stride
|
|
||||||
for x := range w {
|
|
||||||
cr, cg, cb, ca := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
|
|
||||||
di := dstOff + x*4
|
|
||||||
data[di+0] = uint8(cb >> 8)
|
|
||||||
data[di+1] = uint8(cg >> 8)
|
|
||||||
data[di+2] = uint8(cr >> 8)
|
|
||||||
data[di+3] = uint8(ca >> 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.Format = FormatARGB8888
|
|
||||||
return buf, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func EncodePNG(w io.Writer, img image.Image) error {
|
func EncodePNG(w io.Writer, img image.Image) error {
|
||||||
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
||||||
return enc.Encode(w, img)
|
return enc.Encode(w, img)
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
package screenshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
"image/png"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const niriScreenshotTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
// CaptureNiriWindowImage captures the focused window through niri's
|
|
||||||
// ScreenshotWindow action; niri replies before writing the file, so a second
|
|
||||||
// event-stream connection waits for ScreenshotCaptured. niri also copies the
|
|
||||||
// capture to its own clipboard, which cannot be disabled.
|
|
||||||
func CaptureNiriWindowImage(showPointer bool) (image.Image, error) {
|
|
||||||
socket := os.Getenv("NIRI_SOCKET")
|
|
||||||
if socket == "" {
|
|
||||||
return nil, fmt.Errorf("NIRI_SOCKET not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := os.Getenv("XDG_RUNTIME_DIR")
|
|
||||||
if dir == "" {
|
|
||||||
dir = os.TempDir()
|
|
||||||
}
|
|
||||||
path := filepath.Join(dir, fmt.Sprintf("dms-window-%d.png", os.Getpid()))
|
|
||||||
|
|
||||||
events, err := subscribeNiriEvents(socket)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer events.Close()
|
|
||||||
|
|
||||||
if err := requestNiriWindowScreenshot(socket, path, showPointer); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer os.Remove(path)
|
|
||||||
|
|
||||||
if err := awaitNiriScreenshot(events, path); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open niri screenshot: %w", err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
img, err := png.Decode(f)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("decode niri screenshot: %w", err)
|
|
||||||
}
|
|
||||||
return img, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribeNiriEvents(socket string) (net.Conn, error) {
|
|
||||||
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("connect niri socket: %w", err)
|
|
||||||
}
|
|
||||||
_ = conn.SetDeadline(time.Now().Add(niriScreenshotTimeout))
|
|
||||||
|
|
||||||
if _, err := conn.Write([]byte("\"EventStream\"\n")); err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, fmt.Errorf("subscribe niri events: %w", err)
|
|
||||||
}
|
|
||||||
return conn, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func awaitNiriScreenshot(events net.Conn, path string) error {
|
|
||||||
scanner := bufio.NewScanner(events)
|
|
||||||
scanner.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
var event struct {
|
|
||||||
ScreenshotCaptured *struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
} `json:"ScreenshotCaptured"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(scanner.Bytes(), &event) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if event.ScreenshotCaptured != nil && event.ScreenshotCaptured.Path == path {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return fmt.Errorf("await niri screenshot: %w", err)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("niri event stream closed before screenshot completed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestNiriWindowScreenshot(socket, path string, showPointer bool) error {
|
|
||||||
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect niri socket: %w", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
|
||||||
|
|
||||||
request := map[string]any{
|
|
||||||
"Action": map[string]any{
|
|
||||||
"ScreenshotWindow": map[string]any{
|
|
||||||
"id": nil,
|
|
||||||
"write_to_disk": true,
|
|
||||||
"show_pointer": showPointer,
|
|
||||||
"path": path,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
payload, err := json.Marshal(request)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := conn.Write(append(payload, '\n')); err != nil {
|
|
||||||
return fmt.Errorf("niri request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
line, err := bufio.NewReader(conn).ReadBytes('\n')
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("niri reply: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var reply map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(line, &reply); err != nil {
|
|
||||||
return fmt.Errorf("parse niri reply: %w", err)
|
|
||||||
}
|
|
||||||
if raw, ok := reply["Err"]; ok {
|
|
||||||
var msg string
|
|
||||||
_ = json.Unmarshal(raw, &msg)
|
|
||||||
return fmt.Errorf("niri screenshot: %s", msg)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Screenshoter) captureNiriWindow() (*CaptureResult, error) {
|
|
||||||
img, err := CaptureNiriWindowImage(s.config.Cursor == CursorOn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
buf, err := ImageToBuffer(img)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
scale := 1.0
|
|
||||||
if output := s.findOutputByName(GetFocusedMonitor()); output != nil {
|
|
||||||
scale = output.effectiveScale()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CaptureResult{
|
|
||||||
Buffer: buf,
|
|
||||||
YInverted: false,
|
|
||||||
Format: uint32(FormatARGB8888),
|
|
||||||
Scale: scale,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -95,9 +95,6 @@ type RegionSelector struct {
|
|||||||
showCapturedCursor bool
|
showCapturedCursor bool
|
||||||
shiftHeld bool
|
shiftHeld bool
|
||||||
|
|
||||||
phase selectorPhase
|
|
||||||
scroll *scrollSession
|
|
||||||
|
|
||||||
running bool
|
running bool
|
||||||
cancelled bool
|
cancelled bool
|
||||||
result Region
|
result Region
|
||||||
@@ -170,32 +167,20 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
|||||||
|
|
||||||
r.running = true
|
r.running = true
|
||||||
for r.running {
|
for r.running {
|
||||||
if err := r.dispatchOrTick(); err != nil {
|
if err := r.ctx.Dispatch(); err != nil {
|
||||||
return nil, false, fmt.Errorf("dispatch: %w", err)
|
return nil, false, fmt.Errorf("dispatch: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.scroll != nil && r.scroll.abortErr != nil {
|
|
||||||
return nil, false, r.scroll.abortErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.cancelled || r.capturedBuffer == nil {
|
if r.cancelled || r.capturedBuffer == nil {
|
||||||
return nil, r.cancelled, nil
|
return nil, r.cancelled, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
yInverted := false
|
yInverted := false
|
||||||
var format uint32
|
var format uint32
|
||||||
scale := 1.0
|
|
||||||
if r.selection.surface != nil {
|
if r.selection.surface != nil {
|
||||||
yInverted = r.selection.surface.yInverted
|
yInverted = r.selection.surface.yInverted
|
||||||
format = r.selection.surface.screenFormat
|
format = r.selection.surface.screenFormat
|
||||||
if s := r.selection.surface.output.fractionalScale; s > 0 {
|
|
||||||
scale = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if r.scroll != nil {
|
|
||||||
yInverted = false
|
|
||||||
format = uint32(r.scroll.format)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CaptureResult{
|
return &CaptureResult{
|
||||||
@@ -203,7 +188,6 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
|||||||
Region: r.result,
|
Region: r.result,
|
||||||
YInverted: yInverted,
|
YInverted: yInverted,
|
||||||
Format: format,
|
Format: format,
|
||||||
Scale: scale,
|
|
||||||
}, false, nil
|
}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,9 +691,7 @@ func (r *RegionSelector) initRenderBuffer(os *OutputSurface) {
|
|||||||
}
|
}
|
||||||
slot.pool = pool
|
slot.pool = pool
|
||||||
|
|
||||||
// niri latches surface opacity from the first buffer's format
|
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), os.screenFormat)
|
||||||
// (observed), so slots are ARGB from the start with A=255 when opaque
|
|
||||||
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), alphaFormat(os.screenFormat))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("create render slot wl_buffer failed", "err", err)
|
log.Error("create render slot wl_buffer failed", "err", err)
|
||||||
pool.Destroy()
|
pool.Destroy()
|
||||||
@@ -751,9 +733,8 @@ func (r *RegionSelector) applyPreSelection(os *OutputSurface) {
|
|||||||
|
|
||||||
x1 := float64(r.preSelect.X-os.output.x) * scaleX
|
x1 := float64(r.preSelect.X-os.output.x) * scaleX
|
||||||
y1 := float64(r.preSelect.Y-os.output.y) * scaleY
|
y1 := float64(r.preSelect.Y-os.output.y) * scaleY
|
||||||
// selection edges are inclusive; the exclusive width edge is one device px past it
|
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width) * scaleX
|
||||||
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width)*scaleX - scaleX
|
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height) * scaleY
|
||||||
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height)*scaleY - scaleY
|
|
||||||
|
|
||||||
r.selection.hasSelection = true
|
r.selection.hasSelection = true
|
||||||
r.selection.dragging = false
|
r.selection.dragging = false
|
||||||
@@ -783,13 +764,10 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch r.phase {
|
|
||||||
case phaseScroll:
|
|
||||||
r.drawScrollOverlay(os, slot.shm)
|
|
||||||
default:
|
|
||||||
slot.shm.CopyFrom(srcBuf)
|
slot.shm.CopyFrom(srcBuf)
|
||||||
|
|
||||||
|
// Draw overlay (dimming + selection) into this slot
|
||||||
r.drawOverlay(os, slot.shm)
|
r.drawOverlay(os, slot.shm)
|
||||||
}
|
|
||||||
|
|
||||||
if os.viewport != nil {
|
if os.viewport != nil {
|
||||||
_ = os.wlSurface.SetBufferScale(1)
|
_ = os.wlSurface.SetBufferScale(1)
|
||||||
@@ -825,8 +803,6 @@ func (r *RegionSelector) cleanup() {
|
|||||||
r.cursorBuffer.Close()
|
r.cursorBuffer.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
r.cleanupScroll()
|
|
||||||
|
|
||||||
for _, os := range r.surfaces {
|
for _, os := range r.surfaces {
|
||||||
for _, slot := range os.slots {
|
for _, slot := range os.slots {
|
||||||
if slot == nil {
|
if slot == nil {
|
||||||
|
|||||||
@@ -94,20 +94,6 @@ func (r *RegionSelector) setupPointerHandlers() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.phase == phaseScroll {
|
|
||||||
if e.Button != 0x110 || e.State != 1 || r.activeSurface != r.selection.surface {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch r.scrollBarHit(r.pointerX, r.pointerY) {
|
|
||||||
case "done":
|
|
||||||
r.finishScroll()
|
|
||||||
case "cancel":
|
|
||||||
r.cancelled = true
|
|
||||||
r.running = false
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch e.Button {
|
switch e.Button {
|
||||||
case 0x110: // BTN_LEFT
|
case 0x110: // BTN_LEFT
|
||||||
switch e.State {
|
switch e.State {
|
||||||
@@ -149,17 +135,6 @@ func (r *RegionSelector) setupKeyboardHandlers() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.phase == phaseScroll {
|
|
||||||
switch e.Key {
|
|
||||||
case 1:
|
|
||||||
r.cancelled = true
|
|
||||||
r.running = false
|
|
||||||
case 28, 96:
|
|
||||||
r.finishScroll()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch e.Key {
|
switch e.Key {
|
||||||
case 1:
|
case 1:
|
||||||
r.cancelled = true
|
r.cancelled = true
|
||||||
@@ -177,15 +152,17 @@ func (r *RegionSelector) setupKeyboardHandlers() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, int) {
|
func (r *RegionSelector) finishSelection() {
|
||||||
if r.selection.surface == nil {
|
if r.selection.surface == nil {
|
||||||
return nil, 0, 0, 0, 0
|
r.running = false
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
os := r.selection.surface
|
os := r.selection.surface
|
||||||
srcBuf := r.getSourceBuffer(os)
|
srcBuf := r.getSourceBuffer(os)
|
||||||
if srcBuf == nil {
|
if srcBuf == nil {
|
||||||
return nil, 0, 0, 0, 0
|
r.running = false
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
x1, y1 := r.selection.anchorX, r.selection.anchorY
|
x1, y1 := r.selection.anchorX, r.selection.anchorY
|
||||||
@@ -204,10 +181,24 @@ func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, i
|
|||||||
scaleY = float64(srcBuf.Height) / float64(os.logicalH)
|
scaleY = float64(srcBuf.Height) / float64(os.logicalH)
|
||||||
}
|
}
|
||||||
|
|
||||||
bx1 := clamp(int(x1*scaleX), 0, srcBuf.Width)
|
bx1 := int(x1 * scaleX)
|
||||||
by1 := clamp(int(y1*scaleY), 0, srcBuf.Height)
|
by1 := int(y1 * scaleY)
|
||||||
bx2 := clamp(int(x2*scaleX), 0, srcBuf.Width)
|
bx2 := int(x2 * scaleX)
|
||||||
by2 := clamp(int(y2*scaleY), 0, srcBuf.Height)
|
by2 := int(y2 * scaleY)
|
||||||
|
|
||||||
|
// Clamp to buffer bounds
|
||||||
|
if bx1 < 0 {
|
||||||
|
bx1 = 0
|
||||||
|
}
|
||||||
|
if by1 < 0 {
|
||||||
|
by1 = 0
|
||||||
|
}
|
||||||
|
if bx2 > srcBuf.Width {
|
||||||
|
bx2 = srcBuf.Width
|
||||||
|
}
|
||||||
|
if by2 > srcBuf.Height {
|
||||||
|
by2 = srcBuf.Height
|
||||||
|
}
|
||||||
|
|
||||||
w, h := bx2-bx1+1, by2-by1+1
|
w, h := bx2-bx1+1, by2-by1+1
|
||||||
if r.shiftHeld && w != h {
|
if r.shiftHeld && w != h {
|
||||||
@@ -224,23 +215,7 @@ func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, i
|
|||||||
h = 1
|
h = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return os, bx1, by1, w, h
|
// Create cropped buffer and copy pixels directly
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) finishSelection() {
|
|
||||||
os, bx1, by1, w, h := r.selectionDeviceRect()
|
|
||||||
if os == nil {
|
|
||||||
r.running = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.screenshoter != nil && r.screenshoter.config.Mode == ModeScroll {
|
|
||||||
r.enterScrollPhase(os, bx1, by1, w, h)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
srcBuf := r.getSourceBuffer(os)
|
|
||||||
|
|
||||||
cropped, err := CreateShmBuffer(w, h, w*4)
|
cropped, err := CreateShmBuffer(w, h, w*4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.running = false
|
r.running = false
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
|||||||
w, h := renderBuf.Width, renderBuf.Height
|
w, h := renderBuf.Width, renderBuf.Height
|
||||||
format := os.screenFormat
|
format := os.screenFormat
|
||||||
|
|
||||||
// dim, forcing alpha: the X-format source's padding byte is undefined
|
// Dim the entire buffer
|
||||||
for y := 0; y < h; y++ {
|
for y := 0; y < h; y++ {
|
||||||
off := y * stride
|
off := y * stride
|
||||||
for x := 0; x < w; x++ {
|
for x := 0; x < w; x++ {
|
||||||
@@ -68,7 +68,6 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
|||||||
data[i+0] = uint8(int(data[i+0]) * 3 / 5)
|
data[i+0] = uint8(int(data[i+0]) * 3 / 5)
|
||||||
data[i+1] = uint8(int(data[i+1]) * 3 / 5)
|
data[i+1] = uint8(int(data[i+1]) * 3 / 5)
|
||||||
data[i+2] = uint8(int(data[i+2]) * 3 / 5)
|
data[i+2] = uint8(int(data[i+2]) * 3 / 5)
|
||||||
data[i+3] = 255
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +110,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
|||||||
data[di+0] = srcData[si+0]
|
data[di+0] = srcData[si+0]
|
||||||
data[di+1] = srcData[si+1]
|
data[di+1] = srcData[si+1]
|
||||||
data[di+2] = srcData[si+2]
|
data[di+2] = srcData[si+2]
|
||||||
data[di+3] = 255
|
data[di+3] = srcData[si+3]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,81 +126,6 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
|||||||
r.drawDimensions(data, stride, w, h, bx1, by1, selW, selH, format)
|
r.drawDimensions(data, stride, w, h, bx1, by1, selW, selH, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RegionSelector) drawScrollOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
|
||||||
data := renderBuf.Data()
|
|
||||||
stride := renderBuf.Stride
|
|
||||||
w, h := renderBuf.Width, renderBuf.Height
|
|
||||||
|
|
||||||
// 40% premultiplied scrim
|
|
||||||
for y := 0; y < h; y++ {
|
|
||||||
off := y * stride
|
|
||||||
for x := 0; x < w; x++ {
|
|
||||||
i := off + x*4
|
|
||||||
if i+3 >= len(data) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data[i+0], data[i+1], data[i+2], data[i+3] = 0, 0, 0, 102
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s := r.scroll
|
|
||||||
if s == nil || r.selection.surface != os {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// hole oversized 2px so overlay pixels never land in captured frames
|
|
||||||
holeX := s.holeX - 2
|
|
||||||
holeY := s.holeY - 2
|
|
||||||
holeW := s.holeW + 4
|
|
||||||
holeH := s.holeH + 4
|
|
||||||
|
|
||||||
x1 := clamp(holeX, 0, w)
|
|
||||||
y1 := clamp(holeY, 0, h)
|
|
||||||
x2 := clamp(holeX+holeW, 0, w)
|
|
||||||
y2 := clamp(holeY+holeH, 0, h)
|
|
||||||
|
|
||||||
for y := y1; y < y2; y++ {
|
|
||||||
off := y * stride
|
|
||||||
for x := x1; x < x2; x++ {
|
|
||||||
i := off + x*4
|
|
||||||
if i+3 >= len(data) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data[i+0], data[i+1], data[i+2], data[i+3] = 0, 0, 0, 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r.drawBorder(data, stride, w, h, holeX-1, holeY-1, holeW+2, holeH+2, os.screenFormat)
|
|
||||||
r.drawScrollBar(data, stride, w, h, os.screenFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, format uint32) {
|
|
||||||
s := r.scroll
|
|
||||||
style := LoadOverlayStyle()
|
|
||||||
const charH = 12
|
|
||||||
|
|
||||||
r.fillRect(data, stride, bufW, bufH, s.barX, s.barY, s.barW, s.barH,
|
|
||||||
style.BackgroundR, style.BackgroundG, style.BackgroundB, 245, format)
|
|
||||||
|
|
||||||
labelY := s.doneY + (s.btnH-charH)/2
|
|
||||||
r.fillRect(data, stride, bufW, bufH, s.doneX, s.doneY, s.doneW, s.btnH,
|
|
||||||
style.AccentR, style.AccentG, style.AccentB, 255, format)
|
|
||||||
r.drawText(data, stride, bufW, bufH, s.doneX+12, labelY, "done", 10, 10, 10, format)
|
|
||||||
|
|
||||||
r.fillRect(data, stride, bufW, bufH, s.cancelX, s.cancelY, s.cancelW, s.btnH,
|
|
||||||
70, 70, 70, 255, format)
|
|
||||||
r.drawText(data, stride, bufW, bufH, s.cancelX+12, labelY, "cancel",
|
|
||||||
style.TextR, style.TextG, style.TextB, format)
|
|
||||||
|
|
||||||
rows := 0
|
|
||||||
if s.st != nil {
|
|
||||||
rows = s.st.rows()
|
|
||||||
}
|
|
||||||
counter := fmt.Sprintf("%d shots %dpx", s.kept, rows)
|
|
||||||
r.drawText(data, stride, bufW, bufH, s.cancelX+s.cancelW+16, labelY, counter,
|
|
||||||
style.TextR, style.TextG, style.TextB, format)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
|
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
|
||||||
if r.selection.dragging {
|
if r.selection.dragging {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -28,21 +28,6 @@ type CaptureResult struct {
|
|||||||
Region Region
|
Region Region
|
||||||
YInverted bool
|
YInverted bool
|
||||||
Format uint32
|
Format uint32
|
||||||
Scale float64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *WaylandOutput) effectiveScale() float64 {
|
|
||||||
scale := o.fractionalScale
|
|
||||||
if scale <= 0 && DetectCompositor() == CompositorHyprland {
|
|
||||||
scale = GetHyprlandMonitorScale(o.name)
|
|
||||||
}
|
|
||||||
if scale <= 0 {
|
|
||||||
scale = float64(o.scale)
|
|
||||||
}
|
|
||||||
if scale <= 0 {
|
|
||||||
return 1.0
|
|
||||||
}
|
|
||||||
return scale
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Screenshoter struct {
|
type Screenshoter struct {
|
||||||
@@ -92,7 +77,7 @@ func (s *Screenshoter) Run() (*CaptureResult, error) {
|
|||||||
switch s.config.Mode {
|
switch s.config.Mode {
|
||||||
case ModeLastRegion:
|
case ModeLastRegion:
|
||||||
return s.captureLastRegion()
|
return s.captureLastRegion()
|
||||||
case ModeRegion, ModeScroll:
|
case ModeRegion:
|
||||||
return s.captureRegion()
|
return s.captureRegion()
|
||||||
case ModeWindow:
|
case ModeWindow:
|
||||||
return s.captureWindow()
|
return s.captureWindow()
|
||||||
@@ -145,10 +130,6 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
|
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
|
||||||
if DetectCompositor() == CompositorNiri {
|
|
||||||
return s.captureNiriWindow()
|
|
||||||
}
|
|
||||||
|
|
||||||
geom, err := GetActiveWindow()
|
geom, err := GetActiveWindow()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -274,7 +255,6 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
|
|||||||
Region: region,
|
Region: region,
|
||||||
YInverted: false,
|
YInverted: false,
|
||||||
Format: result.Format,
|
Format: result.Format,
|
||||||
Scale: scale,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +430,6 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
|
|||||||
Buffer: composite,
|
Buffer: composite,
|
||||||
Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)},
|
Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)},
|
||||||
Format: format,
|
Format: format,
|
||||||
Scale: maxScale,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,7 +502,6 @@ func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result.Scale = output.effectiveScale()
|
|
||||||
|
|
||||||
if result.YInverted {
|
if result.YInverted {
|
||||||
result.Buffer.FlipVertical()
|
result.Buffer.FlipVertical()
|
||||||
@@ -626,7 +604,6 @@ func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*Ca
|
|||||||
Region: region,
|
Region: region,
|
||||||
YInverted: false,
|
YInverted: false,
|
||||||
Format: result.Format,
|
Format: result.Format,
|
||||||
Scale: scale,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +612,16 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
|
|||||||
return s.captureRegionOnTransformedOutput(output, region)
|
return s.captureRegionOnTransformedOutput(output, region)
|
||||||
}
|
}
|
||||||
|
|
||||||
scale := output.effectiveScale()
|
scale := output.fractionalScale
|
||||||
|
if scale <= 0 && DetectCompositor() == CompositorHyprland {
|
||||||
|
scale = GetHyprlandMonitorScale(output.name)
|
||||||
|
}
|
||||||
|
if scale <= 0 {
|
||||||
|
scale = float64(output.scale)
|
||||||
|
}
|
||||||
|
if scale <= 0 {
|
||||||
|
scale = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
localX := int32(float64(region.X-output.x) * scale)
|
localX := int32(float64(region.X-output.x) * scale)
|
||||||
localY := int32(float64(region.Y-output.y) * scale)
|
localY := int32(float64(region.Y-output.y) * scale)
|
||||||
@@ -674,12 +660,7 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
|
|||||||
return nil, fmt.Errorf("capture region: %w", err)
|
return nil, fmt.Errorf("capture region: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := s.processFrame(frame, region)
|
return s.processFrame(frame, region)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result.Scale = scale
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) {
|
func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) {
|
||||||
@@ -688,7 +669,16 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
scale := output.effectiveScale()
|
scale := output.fractionalScale
|
||||||
|
if scale <= 0 && DetectCompositor() == CompositorHyprland {
|
||||||
|
scale = GetHyprlandMonitorScale(output.name)
|
||||||
|
}
|
||||||
|
if scale <= 0 {
|
||||||
|
scale = float64(output.scale)
|
||||||
|
}
|
||||||
|
if scale <= 0 {
|
||||||
|
scale = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
localX := int(float64(region.X-output.x) * scale)
|
localX := int(float64(region.X-output.x) * scale)
|
||||||
localY := int(float64(region.Y-output.y) * scale)
|
localY := int(float64(region.Y-output.y) * scale)
|
||||||
@@ -740,7 +730,6 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
|
|||||||
Region: region,
|
Region: region,
|
||||||
YInverted: false,
|
YInverted: false,
|
||||||
Format: result.Format,
|
Format: result.Format,
|
||||||
Scale: scale,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,574 +0,0 @@
|
|||||||
package screenshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"os/signal"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_layer_shell"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
|
||||||
|
|
||||||
type selectorPhase int
|
|
||||||
|
|
||||||
const (
|
|
||||||
phaseSelect selectorPhase = iota
|
|
||||||
phaseScroll
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
scrollMaxFailures = 5
|
|
||||||
scrollSeamTicks = 4
|
|
||||||
)
|
|
||||||
|
|
||||||
type scrollSession struct {
|
|
||||||
output *WaylandOutput
|
|
||||||
// wire coords for CaptureOutputRegion (logical or device px per compositor)
|
|
||||||
capX, capY, capW, capH int32
|
|
||||||
// device-pixel rect in the overlay buffer, for hole/border drawing
|
|
||||||
holeX, holeY, holeW, holeH int
|
|
||||||
|
|
||||||
interval time.Duration
|
|
||||||
nextTick time.Time
|
|
||||||
inFlight bool
|
|
||||||
failures int
|
|
||||||
kept int
|
|
||||||
abortErr error
|
|
||||||
|
|
||||||
buf *ShmBuffer
|
|
||||||
pool *client.ShmPool
|
|
||||||
wlBuf *client.Buffer
|
|
||||||
frame *wlr_screencopy.ZwlrScreencopyFrameV1
|
|
||||||
format PixelFormat
|
|
||||||
frameW, frameH int
|
|
||||||
yInverted bool
|
|
||||||
|
|
||||||
prevSig []float32
|
|
||||||
prevPlaced bool
|
|
||||||
unmatched bool
|
|
||||||
unmatchedTicks int
|
|
||||||
|
|
||||||
// control bar geometry in overlay buffer pixels
|
|
||||||
barX, barY, barW, barH int
|
|
||||||
doneX, doneY, doneW int
|
|
||||||
cancelX, cancelY int
|
|
||||||
cancelW int
|
|
||||||
btnH int
|
|
||||||
|
|
||||||
sigCh chan os.Signal
|
|
||||||
keysBound bool
|
|
||||||
|
|
||||||
st *stitcher
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) dispatchOrTick() error {
|
|
||||||
timeout := -1
|
|
||||||
if s := r.scroll; r.phase == phaseScroll && s != nil && s.sigCh != nil {
|
|
||||||
select {
|
|
||||||
case sig := <-s.sigCh:
|
|
||||||
switch sig {
|
|
||||||
case unix.SIGUSR2:
|
|
||||||
r.cancelled = true
|
|
||||||
r.running = false
|
|
||||||
default:
|
|
||||||
r.finishScroll()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s := r.scroll; r.phase == phaseScroll && s.abortErr == nil && (s.st == nil || !s.st.full) {
|
|
||||||
timeout = max(int(time.Until(s.nextTick).Milliseconds()), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fds := []unix.PollFd{{Fd: int32(r.ctx.Fd()), Events: unix.POLLIN}}
|
|
||||||
n, err := unix.Poll(fds, timeout)
|
|
||||||
switch {
|
|
||||||
case err == unix.EINTR:
|
|
||||||
return nil
|
|
||||||
case err != nil:
|
|
||||||
return err
|
|
||||||
case n > 0:
|
|
||||||
return r.ctx.Dispatch()
|
|
||||||
}
|
|
||||||
|
|
||||||
r.scrollTick()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) scrollTick() {
|
|
||||||
s := r.scroll
|
|
||||||
if s == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.inFlight || (s.st != nil && s.st.full) {
|
|
||||||
s.nextTick = time.Now().Add(s.interval)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.startScrollCapture()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) enterScrollPhase(os *OutputSurface, x, y, w, h int) {
|
|
||||||
switch {
|
|
||||||
case os.output.transform != TransformNormal:
|
|
||||||
r.abortScroll(fmt.Errorf("scroll capture does not support rotated outputs"))
|
|
||||||
return
|
|
||||||
case w < 1 || h < 1:
|
|
||||||
r.abortScroll(fmt.Errorf("empty scroll capture region"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
interval := 45
|
|
||||||
if r.screenshoter != nil && r.screenshoter.config.IntervalMs > 0 {
|
|
||||||
interval = r.screenshoter.config.IntervalMs
|
|
||||||
}
|
|
||||||
|
|
||||||
capX, capY, capW, capH := x, y, w, h
|
|
||||||
switch DetectCompositor() {
|
|
||||||
case CompositorHyprland, CompositorMango:
|
|
||||||
// both take device pixels, deviating from spec (observed)
|
|
||||||
default:
|
|
||||||
// spec: logical coordinates, scaled by the compositor
|
|
||||||
// https://wayland.app/protocols/wlr-screencopy-unstable-v1#zwlr_screencopy_manager_v1:request:capture_output_region
|
|
||||||
if scale := os.output.fractionalScale; scale > 1 {
|
|
||||||
capX = int(float64(x)/scale + 0.5)
|
|
||||||
capY = int(float64(y)/scale + 0.5)
|
|
||||||
capW = int(float64(w)/scale + 0.5)
|
|
||||||
capH = int(float64(h)/scale + 0.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r.scroll = &scrollSession{
|
|
||||||
output: os.output,
|
|
||||||
capX: int32(capX),
|
|
||||||
capY: int32(capY),
|
|
||||||
capW: int32(capW),
|
|
||||||
capH: int32(capH),
|
|
||||||
holeX: x,
|
|
||||||
holeY: y,
|
|
||||||
holeW: w,
|
|
||||||
holeH: h,
|
|
||||||
interval: time.Duration(interval) * time.Millisecond,
|
|
||||||
nextTick: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
r.layoutScrollBar(os)
|
|
||||||
|
|
||||||
for _, surf := range r.surfaces {
|
|
||||||
r.setInputPassthrough(surf, surf == os)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hyprland routes all pointer input to exclusive-keyboard layers
|
|
||||||
// (https://github.com/hyprwm/Hyprland/discussions/14136), so the keyboard
|
|
||||||
// is released there and Enter/Esc come back via temporary global binds
|
|
||||||
if DetectCompositor() == CompositorHyprland {
|
|
||||||
r.enterHyprlandScrollInput(os)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.phase = phaseScroll
|
|
||||||
for _, surf := range r.surfaces {
|
|
||||||
r.redrawSurface(surf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sized for the worst-case counter so the input region is set once
|
|
||||||
func (r *RegionSelector) layoutScrollBar(os *OutputSurface) {
|
|
||||||
s := r.scroll
|
|
||||||
const charAdv, pad, gap = 9, 12, 16
|
|
||||||
|
|
||||||
s.btnH = 24
|
|
||||||
s.doneW = len("done")*charAdv + 24
|
|
||||||
s.cancelW = len("cancel")*charAdv + 24
|
|
||||||
counterW := len("99999 shots 999999px") * charAdv
|
|
||||||
s.barW = pad + s.doneW + gap + s.cancelW + gap + counterW + pad
|
|
||||||
s.barH = s.btnH + 24
|
|
||||||
|
|
||||||
bufW, bufH := os.screenBuf.Width, os.screenBuf.Height
|
|
||||||
s.barX = (bufW - s.barW) / 2
|
|
||||||
s.barY = bufH - s.barH - 24
|
|
||||||
|
|
||||||
borderX1, borderY1 := s.holeX-3, s.holeY-3
|
|
||||||
borderX2, borderY2 := s.holeX+s.holeW+3, s.holeY+s.holeH+3
|
|
||||||
overlaps := s.barX < borderX2 && s.barX+s.barW > borderX1 &&
|
|
||||||
s.barY < borderY2 && s.barY+s.barH > borderY1
|
|
||||||
if overlaps {
|
|
||||||
s.barY = 24
|
|
||||||
}
|
|
||||||
|
|
||||||
s.doneX = s.barX + pad
|
|
||||||
s.doneY = s.barY + (s.barH-s.btnH)/2
|
|
||||||
s.cancelX = s.doneX + s.doneW + gap
|
|
||||||
s.cancelY = s.doneY
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) setInputPassthrough(os *OutputSurface, withBar bool) {
|
|
||||||
reg, err := r.compositor.CreateRegion()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if withBar && os.screenBuf != nil && os.logicalW > 0 {
|
|
||||||
s := r.scroll
|
|
||||||
scaleX := float64(os.logicalW) / float64(os.screenBuf.Width)
|
|
||||||
scaleY := float64(os.logicalH) / float64(os.screenBuf.Height)
|
|
||||||
_ = reg.Add(int32(float64(s.barX)*scaleX), int32(float64(s.barY)*scaleY),
|
|
||||||
int32(float64(s.barW)*scaleX)+1, int32(float64(s.barH)*scaleY)+1)
|
|
||||||
}
|
|
||||||
_ = os.wlSurface.SetInputRegion(reg)
|
|
||||||
_ = reg.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) enterHyprlandScrollInput(osurf *OutputSurface) {
|
|
||||||
for _, surf := range r.surfaces {
|
|
||||||
_ = surf.layerSurf.SetKeyboardInteractivity(uint32(wlr_layer_shell.ZwlrLayerSurfaceV1KeyboardInteractivityNone))
|
|
||||||
}
|
|
||||||
if r.shortcutsInhibitor != nil {
|
|
||||||
_ = r.shortcutsInhibitor.Destroy()
|
|
||||||
r.shortcutsInhibitor = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
s := r.scroll
|
|
||||||
scale := osurf.output.fractionalScale
|
|
||||||
if scale <= 0 {
|
|
||||||
scale = 1
|
|
||||||
}
|
|
||||||
cx := int(float64(osurf.output.x) + float64(s.holeX+s.holeW/2)/scale)
|
|
||||||
cy := int(float64(osurf.output.y) + float64(s.holeY+s.holeH/2)/scale)
|
|
||||||
hyprlandFocusWindowAt(cx, cy)
|
|
||||||
|
|
||||||
s.sigCh = make(chan os.Signal, 2)
|
|
||||||
signal.Notify(s.sigCh, unix.SIGUSR1, unix.SIGUSR2)
|
|
||||||
s.keysBound = hyprlandBindScrollKeys(os.Getpid())
|
|
||||||
}
|
|
||||||
|
|
||||||
func hyprlandFocusWindowAt(x, y int) {
|
|
||||||
out, err := exec.Command("hyprctl", "-j", "clients").Output()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var clients []struct {
|
|
||||||
Address string `json:"address"`
|
|
||||||
At [2]int `json:"at"`
|
|
||||||
Size [2]int `json:"size"`
|
|
||||||
Mapped bool `json:"mapped"`
|
|
||||||
Hidden bool `json:"hidden"`
|
|
||||||
FocusHistoryID int `json:"focusHistoryID"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(out, &clients) != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
best := -1
|
|
||||||
for i, c := range clients {
|
|
||||||
if !c.Mapped || c.Hidden {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if x < c.At[0] || x >= c.At[0]+c.Size[0] || y < c.At[1] || y >= c.At[1]+c.Size[1] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if best < 0 || c.FocusHistoryID < clients[best].FocusHistoryID {
|
|
||||||
best = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if best < 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = exec.Command("hyprctl", "dispatch", "focuswindow", "address:"+clients[best].Address).Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
func hyprlandBindScrollKeys(pid int) bool {
|
|
||||||
batch := fmt.Sprintf("keyword bind ,Return,exec,kill -USR1 %d ; keyword bind ,Escape,exec,kill -USR2 %d", pid, pid)
|
|
||||||
return exec.Command("hyprctl", "--batch", batch).Run() == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hyprlandUnbindScrollKeys() {
|
|
||||||
_ = exec.Command("hyprctl", "--batch", "keyword unbind ,Return ; keyword unbind ,Escape").Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) scrollBarHit(x, y float64) string {
|
|
||||||
s := r.scroll
|
|
||||||
os := r.selection.surface
|
|
||||||
if s == nil || os == nil || os.screenBuf == nil || os.logicalW == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
bx := int(x * float64(os.screenBuf.Width) / float64(os.logicalW))
|
|
||||||
by := int(y * float64(os.screenBuf.Height) / float64(os.logicalH))
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case bx >= s.doneX && bx < s.doneX+s.doneW && by >= s.doneY && by < s.doneY+s.btnH:
|
|
||||||
return "done"
|
|
||||||
case bx >= s.cancelX && bx < s.cancelX+s.cancelW && by >= s.cancelY && by < s.cancelY+s.btnH:
|
|
||||||
return "cancel"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func alphaFormat(format uint32) uint32 {
|
|
||||||
switch format {
|
|
||||||
case uint32(FormatXRGB8888):
|
|
||||||
return uint32(FormatARGB8888)
|
|
||||||
case uint32(FormatXBGR8888):
|
|
||||||
return uint32(FormatABGR8888)
|
|
||||||
default:
|
|
||||||
return format
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) startScrollCapture() {
|
|
||||||
s := r.scroll
|
|
||||||
frame, err := r.screencopy.CaptureOutputRegion(0, s.output.wlOutput, s.capX, s.capY, s.capW, s.capH)
|
|
||||||
if err != nil {
|
|
||||||
r.abortScroll(fmt.Errorf("scroll capture: %w", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.inFlight = true
|
|
||||||
s.frame = frame
|
|
||||||
s.nextTick = time.Now().Add(s.interval)
|
|
||||||
|
|
||||||
frame.SetBufferHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) {
|
|
||||||
if err := s.ensureCaptureBuffer(r, e); err != nil {
|
|
||||||
r.abortScroll(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := frame.Copy(s.wlBuf); err != nil {
|
|
||||||
log.Error("scroll frame copy failed", "err", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
frame.SetFlagsHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FlagsEvent) {
|
|
||||||
s.yInverted = (e.Flags & 1) != 0
|
|
||||||
})
|
|
||||||
|
|
||||||
frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) {
|
|
||||||
frame.Destroy()
|
|
||||||
s.frame = nil
|
|
||||||
s.inFlight = false
|
|
||||||
s.failures = 0
|
|
||||||
s.nextTick = time.Now().Add(s.interval)
|
|
||||||
r.handleScrollFrame()
|
|
||||||
})
|
|
||||||
|
|
||||||
frame.SetFailedHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FailedEvent) {
|
|
||||||
frame.Destroy()
|
|
||||||
s.frame = nil
|
|
||||||
s.inFlight = false
|
|
||||||
s.failures++
|
|
||||||
s.nextTick = time.Now().Add(s.interval)
|
|
||||||
if s.failures >= scrollMaxFailures {
|
|
||||||
r.abortScroll(fmt.Errorf("screencopy failed %d consecutive times", s.failures))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *scrollSession) ensureCaptureBuffer(r *RegionSelector, e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) error {
|
|
||||||
if s.buf != nil {
|
|
||||||
if int(e.Width) != s.frameW || int(e.Height) != s.frameH || PixelFormat(e.Format) != s.format {
|
|
||||||
return fmt.Errorf("output changed during scroll capture")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
format := PixelFormat(e.Format)
|
|
||||||
if int(e.Stride) < int(e.Width)*format.BytesPerPixel() {
|
|
||||||
return fmt.Errorf("invalid stride from compositor: %d for width %d", e.Stride, e.Width)
|
|
||||||
}
|
|
||||||
|
|
||||||
buf, err := CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create scroll buffer: %w", err)
|
|
||||||
}
|
|
||||||
buf.Format = format
|
|
||||||
|
|
||||||
pool, err := r.shm.CreatePool(buf.Fd(), int32(buf.Size()))
|
|
||||||
if err != nil {
|
|
||||||
buf.Close()
|
|
||||||
return fmt.Errorf("create scroll pool: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), e.Format)
|
|
||||||
if err != nil {
|
|
||||||
pool.Destroy()
|
|
||||||
buf.Close()
|
|
||||||
return fmt.Errorf("create scroll wl_buffer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.buf = buf
|
|
||||||
s.pool = pool
|
|
||||||
s.wlBuf = wlBuf
|
|
||||||
s.format = format
|
|
||||||
s.frameW = int(e.Width)
|
|
||||||
s.frameH = int(e.Height)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) handleScrollFrame() {
|
|
||||||
s := r.scroll
|
|
||||||
if s == nil || s.buf == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.extractRows()
|
|
||||||
if err != nil {
|
|
||||||
r.abortScroll(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.st == nil {
|
|
||||||
s.st = newStitcher(s.frameW * 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
cols := s.st.rowSamples(rows)
|
|
||||||
sig := s.st.frameSig(rows)
|
|
||||||
dup := duplicateFrame(sig, s.prevSig)
|
|
||||||
s.prevSig = sig
|
|
||||||
|
|
||||||
// moving content: recapture at compositor speed, the timer paces idle only
|
|
||||||
if !dup {
|
|
||||||
s.nextTick = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
var added int
|
|
||||||
switch {
|
|
||||||
case dup && s.unmatched:
|
|
||||||
// settled somewhere unreachable: seam a new segment after a few ticks
|
|
||||||
s.unmatchedTicks++
|
|
||||||
if s.unmatchedTicks < scrollSeamTicks {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var placed bool
|
|
||||||
added, placed = s.st.pushFrame(rows, cols)
|
|
||||||
if !placed {
|
|
||||||
added = s.st.seamAppend(rows, cols)
|
|
||||||
}
|
|
||||||
s.prevPlaced = true
|
|
||||||
s.unmatched = false
|
|
||||||
s.unmatchedTicks = 0
|
|
||||||
case dup && s.prevPlaced:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
var placed bool
|
|
||||||
added, placed = s.st.pushFrame(rows, cols)
|
|
||||||
s.prevPlaced = placed
|
|
||||||
s.unmatched = !placed
|
|
||||||
s.unmatchedTicks = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if scrollDebug {
|
|
||||||
log.Error("scroll frame", "dup", dup, "unmatched", s.unmatched,
|
|
||||||
"placed", s.prevPlaced, "added", added, "canvas", s.st.rows(), "kept", s.kept)
|
|
||||||
}
|
|
||||||
if added == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.kept++
|
|
||||||
if r.selection.surface != nil {
|
|
||||||
r.redrawSurface(r.selection.surface)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var scrollDebug = os.Getenv("DMS_SCROLL_DEBUG") != ""
|
|
||||||
|
|
||||||
func (s *scrollSession) extractRows() ([]byte, error) {
|
|
||||||
src := s.buf
|
|
||||||
format := s.format
|
|
||||||
|
|
||||||
if format.Is24Bit() {
|
|
||||||
converted, newFormat, err := src.ConvertTo32Bit(format)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("convert scroll frame: %w", err)
|
|
||||||
}
|
|
||||||
defer converted.Close()
|
|
||||||
src = converted
|
|
||||||
s.format = newFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]byte, s.frameW*4*s.frameH)
|
|
||||||
data := src.Data()
|
|
||||||
for y := 0; y < s.frameH; y++ {
|
|
||||||
srcY := y
|
|
||||||
if s.yInverted {
|
|
||||||
srcY = s.frameH - 1 - y
|
|
||||||
}
|
|
||||||
srcOff := srcY * src.Stride
|
|
||||||
dstOff := y * s.frameW * 4
|
|
||||||
if srcOff+s.frameW*4 > len(data) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
copy(rows[dstOff:dstOff+s.frameW*4], data[srcOff:srcOff+s.frameW*4])
|
|
||||||
}
|
|
||||||
return rows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) finishScroll() {
|
|
||||||
s := r.scroll
|
|
||||||
if s == nil || s.st == nil || s.st.rows() == 0 {
|
|
||||||
r.cancelled = true
|
|
||||||
r.running = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
buf, err := CreateShmBuffer(s.frameW, s.st.rows(), s.frameW*4)
|
|
||||||
if err != nil {
|
|
||||||
r.abortScroll(fmt.Errorf("create stitched buffer: %w", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
copy(buf.Data(), s.st.canvas)
|
|
||||||
buf.Format = s.format
|
|
||||||
|
|
||||||
r.capturedBuffer = buf
|
|
||||||
r.capturedRegion = Region{
|
|
||||||
X: int32(s.holeX),
|
|
||||||
Y: int32(s.holeY),
|
|
||||||
Width: int32(s.holeW),
|
|
||||||
Height: int32(s.holeH),
|
|
||||||
Output: s.output.name,
|
|
||||||
}
|
|
||||||
// same convention as finishSelection or preselect breaks on scaled outputs
|
|
||||||
r.result = Region{
|
|
||||||
X: int32(s.holeX) + s.output.x,
|
|
||||||
Y: int32(s.holeY) + s.output.y,
|
|
||||||
Width: int32(s.holeW),
|
|
||||||
Height: int32(s.holeH),
|
|
||||||
Output: s.output.name,
|
|
||||||
}
|
|
||||||
r.running = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) abortScroll(err error) {
|
|
||||||
if r.scroll == nil {
|
|
||||||
r.scroll = &scrollSession{}
|
|
||||||
}
|
|
||||||
r.scroll.abortErr = err
|
|
||||||
r.running = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RegionSelector) cleanupScroll() {
|
|
||||||
s := r.scroll
|
|
||||||
if s == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.keysBound {
|
|
||||||
hyprlandUnbindScrollKeys()
|
|
||||||
}
|
|
||||||
if s.sigCh != nil {
|
|
||||||
signal.Stop(s.sigCh)
|
|
||||||
}
|
|
||||||
if s.frame != nil {
|
|
||||||
s.frame.Destroy()
|
|
||||||
}
|
|
||||||
if s.wlBuf != nil {
|
|
||||||
s.wlBuf.Destroy()
|
|
||||||
}
|
|
||||||
if s.pool != nil {
|
|
||||||
s.pool.Destroy()
|
|
||||||
}
|
|
||||||
if s.buf != nil {
|
|
||||||
s.buf.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
package screenshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mirrors handleScrollFrame's stitch logic so glides run without a compositor
|
|
||||||
type simSession struct {
|
|
||||||
prevSig []float32
|
|
||||||
prevPlaced bool
|
|
||||||
unmatched bool
|
|
||||||
unmatchedTicks int
|
|
||||||
st *stitcher
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *simSession) observe(rows []byte) {
|
|
||||||
cols := s.st.rowSamples(rows)
|
|
||||||
sig := s.st.frameSig(rows)
|
|
||||||
dup := duplicateFrame(sig, s.prevSig)
|
|
||||||
s.prevSig = sig
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case dup && s.unmatched:
|
|
||||||
s.unmatchedTicks++
|
|
||||||
if s.unmatchedTicks < scrollSeamTicks {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, placed := s.st.pushFrame(rows, cols); !placed {
|
|
||||||
s.st.seamAppend(rows, cols)
|
|
||||||
}
|
|
||||||
s.prevPlaced = true
|
|
||||||
s.unmatched = false
|
|
||||||
s.unmatchedTicks = 0
|
|
||||||
case dup && s.prevPlaced:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
_, placed := s.st.pushFrame(rows, cols)
|
|
||||||
s.prevPlaced = placed
|
|
||||||
s.unmatched = !placed
|
|
||||||
s.unmatchedTicks = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// the page at a fractional scroll offset, as a compositor renders mid-glide
|
|
||||||
func fractionalFrame(page []byte, stride, frameH int, offset float64) []byte {
|
|
||||||
top := int(offset)
|
|
||||||
frac := offset - float64(top)
|
|
||||||
out := make([]byte, frameH*stride)
|
|
||||||
for y := 0; y < frameH; y++ {
|
|
||||||
a := page[(top+y)*stride : (top+y+1)*stride]
|
|
||||||
b := page[(top+y+1)*stride : (top+y+2)*stride]
|
|
||||||
row := out[y*stride : (y+1)*stride]
|
|
||||||
for x := range row {
|
|
||||||
row[x] = byte(float64(a[x])*(1-frac) + float64(b[x])*frac)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// blank gaps between paragraphs plus identical card blocks repeated around
|
|
||||||
func webbyPage(rng *rand.Rand, stride, rows int) []byte {
|
|
||||||
page := make([]byte, rows*stride)
|
|
||||||
card := make([]byte, 40*stride)
|
|
||||||
rng.Read(card)
|
|
||||||
|
|
||||||
row := 0
|
|
||||||
for row < rows {
|
|
||||||
switch rng.Intn(4) {
|
|
||||||
case 0: // blank gap
|
|
||||||
row += 10 + rng.Intn(20)
|
|
||||||
case 1: // repeated card block
|
|
||||||
n := copy(page[row*stride:], card)
|
|
||||||
row += n / stride
|
|
||||||
default: // paragraph of distinct rows
|
|
||||||
n := (8 + rng.Intn(22)) * stride
|
|
||||||
if row*stride+n > len(page) {
|
|
||||||
n = len(page) - row*stride
|
|
||||||
}
|
|
||||||
rng.Read(page[row*stride : row*stride+n])
|
|
||||||
row += n / stride
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return page
|
|
||||||
}
|
|
||||||
|
|
||||||
// screen-fixed sidebar in the unsampled outer 8% plus per-frame hover noise
|
|
||||||
func addFixedChrome(rng *rand.Rand, frame []byte, stride, frameH int, sidebar []byte) {
|
|
||||||
sbw := len(sidebar) / frameH
|
|
||||||
for y := 0; y < frameH; y++ {
|
|
||||||
copy(frame[y*stride:y*stride+sbw], sidebar[y*sbw:(y+1)*sbw])
|
|
||||||
}
|
|
||||||
hoverTop := 40 + rng.Intn(frameH-80)
|
|
||||||
for y := hoverTop; y < hoverTop+24; y++ {
|
|
||||||
off := y*stride + stride/3
|
|
||||||
for x := 0; x < 60; x++ {
|
|
||||||
frame[off+x] ^= 0x08
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// starting at the page bottom and scrolling up must prepend, never stall
|
|
||||||
func TestScrollSimulationBottomUp(t *testing.T) {
|
|
||||||
const stride = 2048
|
|
||||||
const frameH = 240
|
|
||||||
rng := rand.New(rand.NewSource(99))
|
|
||||||
page := webbyPage(rng, stride, 4000)
|
|
||||||
|
|
||||||
st := newStitcher(stride)
|
|
||||||
sidebar := make([]byte, frameH*140)
|
|
||||||
rng.Read(sidebar)
|
|
||||||
|
|
||||||
sim := &simSession{st: st}
|
|
||||||
|
|
||||||
pos := 3700.0
|
|
||||||
capture := func() []byte {
|
|
||||||
f := fractionalFrame(page, stride, frameH, pos)
|
|
||||||
addFixedChrome(rng, f, stride, frameH, sidebar)
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
glide := func(target float64) {
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
step := (target - pos) * 0.45
|
|
||||||
if step > -1 && step < 1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
pos += step
|
|
||||||
if i%4 != 3 {
|
|
||||||
pos = float64(int(pos))
|
|
||||||
}
|
|
||||||
sim.observe(capture())
|
|
||||||
}
|
|
||||||
pos = target
|
|
||||||
sim.observe(capture())
|
|
||||||
sim.observe(capture())
|
|
||||||
}
|
|
||||||
|
|
||||||
sim.observe(capture())
|
|
||||||
for _, target := range []float64{3640, 3560, 3460, 3340, 3240} {
|
|
||||||
glide(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantRows := (3700 + frameH) - 3240
|
|
||||||
got := sim.st.rows()
|
|
||||||
if got < wantRows-stitchMinAppend || got > wantRows+2 {
|
|
||||||
t.Fatalf("canvas has %d rows, want ~%d (upward scrolling must prepend)", got, wantRows)
|
|
||||||
}
|
|
||||||
topPage := 3240 + (wantRows - got)
|
|
||||||
for _, cr := range []int{0, 100, 300} {
|
|
||||||
if !rowMatchesPage(sim.st.canvas, page, stride, cr, topPage+cr) {
|
|
||||||
t.Fatalf("canvas row %d does not map onto page row %d", cr, topPage+cr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// exact page row or a blend of neighbors, allowing a one-row offset
|
|
||||||
func rowMatchesPage(canvas, page []byte, stride, canvasRow, pageRow int) bool {
|
|
||||||
for x := 200; x < stride-1400; x++ {
|
|
||||||
c := int(canvas[canvasRow*stride+x])
|
|
||||||
lo, hi := 255, 0
|
|
||||||
for k := pageRow - 1; k <= pageRow+1; k++ {
|
|
||||||
v := int(page[k*stride+x])
|
|
||||||
lo, hi = min(lo, v), max(hi, v)
|
|
||||||
}
|
|
||||||
if c < lo-1 || c > hi+1 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// a fling past a full frame height must seam a new segment, not go dead
|
|
||||||
func TestScrollSimulationFastFlingRecovers(t *testing.T) {
|
|
||||||
const stride = 2048
|
|
||||||
const frameH = 240
|
|
||||||
rng := rand.New(rand.NewSource(7))
|
|
||||||
page := webbyPage(rng, stride, 4000)
|
|
||||||
|
|
||||||
sim := &simSession{st: newStitcher(stride)}
|
|
||||||
frame := func(top int) []byte {
|
|
||||||
return slices.Clone(page[top*stride : (top+frameH)*stride])
|
|
||||||
}
|
|
||||||
rest := func(top int) {
|
|
||||||
for range scrollSeamTicks + 2 {
|
|
||||||
sim.observe(frame(top))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rest(0)
|
|
||||||
sim.observe(frame(60))
|
|
||||||
sim.observe(frame(130))
|
|
||||||
rest(130)
|
|
||||||
firstRange := 130 + frameH
|
|
||||||
|
|
||||||
sim.observe(frame(900))
|
|
||||||
sim.observe(frame(1400))
|
|
||||||
rest(1800)
|
|
||||||
|
|
||||||
sim.observe(frame(1860))
|
|
||||||
sim.observe(frame(1930))
|
|
||||||
rest(1930)
|
|
||||||
|
|
||||||
wantRows := firstRange + (1930 - 1800) + frameH
|
|
||||||
if got := sim.st.rows(); got != wantRows {
|
|
||||||
t.Fatalf("canvas has %d rows, want %d (first range %d + new segment)", got, wantRows, firstRange)
|
|
||||||
}
|
|
||||||
seamStart := firstRange
|
|
||||||
if !slices.Equal(sim.st.canvas[seamStart*stride:], page[1800*stride:(1930+frameH)*stride]) {
|
|
||||||
t.Fatal("new segment content wrong after fling recovery")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// eased glides with up/down scrubbing must cover the range exactly once
|
|
||||||
func TestScrollSimulationSmoothGlide(t *testing.T) {
|
|
||||||
const stride = 2048
|
|
||||||
const frameH = 240
|
|
||||||
rng := rand.New(rand.NewSource(99))
|
|
||||||
page := webbyPage(rng, stride, 4000)
|
|
||||||
|
|
||||||
st := newStitcher(stride)
|
|
||||||
sidebar := make([]byte, frameH*140)
|
|
||||||
rng.Read(sidebar)
|
|
||||||
|
|
||||||
sim := &simSession{st: st}
|
|
||||||
|
|
||||||
pos := 0.0
|
|
||||||
capture := func() []byte {
|
|
||||||
f := fractionalFrame(page, stride, frameH, pos)
|
|
||||||
addFixedChrome(rng, f, stride, frameH, sidebar)
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
glide := func(target float64) {
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
step := (target - pos) * 0.45
|
|
||||||
if step > -1 && step < 1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
pos += step
|
|
||||||
// mostly snapped to device pixels, with the odd fractional frame
|
|
||||||
if i%4 != 3 {
|
|
||||||
pos = float64(int(pos))
|
|
||||||
}
|
|
||||||
sim.observe(capture())
|
|
||||||
}
|
|
||||||
pos = target
|
|
||||||
sim.observe(capture())
|
|
||||||
sim.observe(capture())
|
|
||||||
}
|
|
||||||
|
|
||||||
sim.observe(capture())
|
|
||||||
for _, target := range []float64{160, 330, 480, 650, 800, 960, 1100} {
|
|
||||||
glide(target)
|
|
||||||
}
|
|
||||||
for _, target := range []float64{700, 300, 900, 1100} {
|
|
||||||
glide(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantRows := 1100 + frameH
|
|
||||||
got := sim.st.rows()
|
|
||||||
if got < wantRows-stitchMinAppend || got > wantRows+2 {
|
|
||||||
t.Fatalf("canvas has %d rows, want ~%d (more = duplicated bands, fewer = gaps)", got, wantRows)
|
|
||||||
}
|
|
||||||
|
|
||||||
hoverLo, hoverHi := stride/3, stride/3+60
|
|
||||||
mismatched := 0
|
|
||||||
for row := 0; row < min(got, wantRows); row += 7 {
|
|
||||||
off := row * stride
|
|
||||||
a1, b1 := sim.st.canvas[off+200:off+hoverLo], page[off+200:off+hoverLo]
|
|
||||||
a2, b2 := sim.st.canvas[off+hoverHi:off+stride], page[off+hoverHi:off+stride]
|
|
||||||
if !slices.Equal(a1, b1) || !slices.Equal(a2, b2) {
|
|
||||||
mismatched++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if mismatched > (wantRows/7)/20 {
|
|
||||||
t.Fatalf("%d of %d sampled rows mismatch page content (mid-animation pixels baked in)", mismatched, wantRows/7)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
package screenshot
|
|
||||||
|
|
||||||
// Frame stitcher after mark-shot's column-sampling design
|
|
||||||
// (https://github.com/jswysnemc/mark-shot, src/scroll/stitcher_algorithm.cpp).
|
|
||||||
// Only rows overhanging the captured range are committed; frames that match
|
|
||||||
// nothing are dropped without touching state.
|
|
||||||
|
|
||||||
const (
|
|
||||||
stitchMaxCanvasBytes = 256 << 20
|
|
||||||
stitchMaxRowsCap = 30000
|
|
||||||
|
|
||||||
// mark-shot: StitchConfig{100, 9.0f, 15, 1.0f}
|
|
||||||
stitchAcceptDiff = 9.0
|
|
||||||
stitchApproxDiff = 1.0
|
|
||||||
stitchMinCompare = 50
|
|
||||||
stitchMinCanvas = 100
|
|
||||||
stitchMinAppend = 15
|
|
||||||
stitchCoarseStep = 8
|
|
||||||
stitchPredictWindow = 160
|
|
||||||
stitchBandSamples = 17
|
|
||||||
|
|
||||||
// mark-shot: kDuplicateAvgDiff=1.1f, kDuplicateMaxDiff=4, 18x24 grid
|
|
||||||
stitchDupAvgDiff = 1.1
|
|
||||||
stitchDupMaxDiff = 4.0
|
|
||||||
stitchSigCols = 18
|
|
||||||
stitchSigRows = 24
|
|
||||||
|
|
||||||
// blank rows agree at every offset and must not decide a match
|
|
||||||
stitchActivityMin = 2.0
|
|
||||||
stitchRowMatchTol = 4.0
|
|
||||||
stitchMinActive = 12
|
|
||||||
)
|
|
||||||
|
|
||||||
// mean luminance per band (8-32%, 34-66%, 68-92%); the outer 8% is chrome
|
|
||||||
type rowCols [3]float32
|
|
||||||
|
|
||||||
type stitcher struct {
|
|
||||||
stride int
|
|
||||||
sampleOffs [3][]int
|
|
||||||
|
|
||||||
canvas []byte
|
|
||||||
cols []rowCols
|
|
||||||
|
|
||||||
anchor int
|
|
||||||
last []rowCols
|
|
||||||
lastOffset int
|
|
||||||
|
|
||||||
maxRows int
|
|
||||||
full bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newStitcher(stride int) *stitcher {
|
|
||||||
px := stride / 4
|
|
||||||
st := &stitcher{
|
|
||||||
stride: stride,
|
|
||||||
maxRows: min(stitchMaxCanvasBytes/stride, stitchMaxRowsCap),
|
|
||||||
}
|
|
||||||
bands := [3][2]float64{{0.08, 0.32}, {0.34, 0.66}, {0.68, 0.92}}
|
|
||||||
for b, band := range bands {
|
|
||||||
lo := int(float64(px) * band[0])
|
|
||||||
hi := max(int(float64(px)*band[1]), lo+1)
|
|
||||||
n := min(stitchBandSamples, hi-lo)
|
|
||||||
for s := range n {
|
|
||||||
st.sampleOffs[b] = append(st.sampleOffs[b], (lo+(hi-lo)*s/n)*4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *stitcher) rowSamples(data []byte) []rowCols {
|
|
||||||
rows := len(data) / st.stride
|
|
||||||
cols := make([]rowCols, rows)
|
|
||||||
for y := range rows {
|
|
||||||
row := data[y*st.stride:]
|
|
||||||
for b := range 3 {
|
|
||||||
var sum float32
|
|
||||||
for _, off := range st.sampleOffs[b] {
|
|
||||||
sum += 0.114*float32(row[off]) + 0.587*float32(row[off+1]) + 0.299*float32(row[off+2])
|
|
||||||
}
|
|
||||||
cols[y][b] = sum / float32(len(st.sampleOffs[b]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cols
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *stitcher) frameSig(data []byte) []float32 {
|
|
||||||
rows := len(data) / st.stride
|
|
||||||
px := st.stride / 4
|
|
||||||
sig := make([]float32, 0, stitchSigCols*stitchSigRows)
|
|
||||||
for gy := range stitchSigRows {
|
|
||||||
y := (2*gy + 1) * rows / (2 * stitchSigRows)
|
|
||||||
for gx := range stitchSigCols {
|
|
||||||
x := (2*gx + 1) * px / (2 * stitchSigCols)
|
|
||||||
off := y*st.stride + x*4
|
|
||||||
sig = append(sig, 0.114*float32(data[off])+0.587*float32(data[off+1])+0.299*float32(data[off+2]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sig
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *stitcher) rows() int {
|
|
||||||
return len(st.cols)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rowColsDiff(a, b rowCols) float32 {
|
|
||||||
return (abs32(a[0]-b[0]) + abs32(a[1]-b[1]) + abs32(a[2]-b[2])) / 3
|
|
||||||
}
|
|
||||||
|
|
||||||
func duplicateFrame(a, b []float32) bool {
|
|
||||||
if len(a) != len(b) || len(a) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var sum, maxDiff float32
|
|
||||||
for i := range a {
|
|
||||||
d := abs32(a[i] - b[i])
|
|
||||||
sum += d
|
|
||||||
maxDiff = max(maxDiff, d)
|
|
||||||
}
|
|
||||||
return sum/float32(len(a)) <= stitchDupAvgDiff && maxDiff <= stitchDupMaxDiff
|
|
||||||
}
|
|
||||||
|
|
||||||
// sticky header/footer zones, per mark-shot: 10% top, 8% bottom, min 16px
|
|
||||||
func matchIgnores(h int) (top, bottom int) {
|
|
||||||
if h < 80 {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
return clamp(h/10, 16, h/4), clamp(h*8/100, 16, h/4)
|
|
||||||
}
|
|
||||||
|
|
||||||
func activity(f []rowCols) []bool {
|
|
||||||
active := make([]bool, len(f))
|
|
||||||
for i := 1; i < len(f); i++ {
|
|
||||||
active[i] = rowColsDiff(f[i], f[i-1]) > stitchActivityMin
|
|
||||||
}
|
|
||||||
return active
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *stitcher) pushFrame(frame []byte, f []rowCols) (int, bool) {
|
|
||||||
if st.full || len(f) == 0 {
|
|
||||||
return 0, true
|
|
||||||
}
|
|
||||||
|
|
||||||
h := len(f)
|
|
||||||
if len(st.cols) == 0 {
|
|
||||||
n := st.appendRows(frame, f, 0)
|
|
||||||
st.anchor = 0
|
|
||||||
st.last = f
|
|
||||||
st.lastOffset = 0
|
|
||||||
return n, true
|
|
||||||
}
|
|
||||||
|
|
||||||
pos, ok := st.locateFrame(f, activity(f))
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
delta := pos - st.anchor
|
|
||||||
added := 0
|
|
||||||
if over := pos + h - len(st.cols); over >= stitchMinAppend {
|
|
||||||
added += st.appendRows(frame, f, h-over)
|
|
||||||
}
|
|
||||||
if over := -pos; over >= stitchMinAppend {
|
|
||||||
n := st.prependRows(frame, f, over)
|
|
||||||
added += n
|
|
||||||
pos += n
|
|
||||||
}
|
|
||||||
|
|
||||||
st.anchor = pos
|
|
||||||
st.last = f
|
|
||||||
st.lastOffset = delta
|
|
||||||
return added, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// seamAppend starts a new segment after a jump capture couldn't follow.
|
|
||||||
func (st *stitcher) seamAppend(frame []byte, f []rowCols) int {
|
|
||||||
if st.full || len(f) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
pos := len(st.cols)
|
|
||||||
n := st.appendRows(frame, f, 0)
|
|
||||||
st.anchor = pos
|
|
||||||
st.last = f
|
|
||||||
st.lastOffset = 0
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *stitcher) locateFrame(f []rowCols, active []bool) (int, bool) {
|
|
||||||
d, diff := st.adjacentOffset(f, active)
|
|
||||||
pred := st.anchor + d
|
|
||||||
|
|
||||||
if diff <= stitchAcceptDiff {
|
|
||||||
if _, ok := st.verifyAt(f, active, pred); ok {
|
|
||||||
return pred, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pos, _, ok := st.scanPositions(f, active, pred, true); ok {
|
|
||||||
return pos, true
|
|
||||||
}
|
|
||||||
pos, _, ok := st.scanPositions(f, active, pred, false)
|
|
||||||
return pos, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *stitcher) verifyAt(f []rowCols, active []bool, pos int) (float32, bool) {
|
|
||||||
diff, count, activeMatches := st.canvasDiff(f, active, pos)
|
|
||||||
ok := count >= stitchMinCanvas && diff <= stitchAcceptDiff && activeMatches >= stitchMinActive
|
|
||||||
return diff, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// signed deltas searched outward from the previous one (mark-shot's
|
|
||||||
// predictOffsetIter), early-exiting once a diff beats approxDiff
|
|
||||||
func (st *stitcher) adjacentOffset(f []rowCols, active []bool) (int, float32) {
|
|
||||||
h := len(f)
|
|
||||||
if len(st.last) != h {
|
|
||||||
return 0, float32(1e9)
|
|
||||||
}
|
|
||||||
limit := max(h-stitchMinCompare-1, 0)
|
|
||||||
|
|
||||||
bestD, bestDiff := 0, float32(1e9)
|
|
||||||
countdown := -1
|
|
||||||
try := func(d int) bool {
|
|
||||||
if d < -limit || d > limit {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
diff, activeMatches := st.pairDiff(f, active, d)
|
|
||||||
if activeMatches >= stitchMinActive && diff < bestDiff {
|
|
||||||
bestDiff, bestD = diff, d
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case bestDiff < stitchApproxDiff/4:
|
|
||||||
return true
|
|
||||||
case bestDiff < stitchApproxDiff && countdown < 0:
|
|
||||||
countdown = 10
|
|
||||||
}
|
|
||||||
if countdown > 0 {
|
|
||||||
countdown--
|
|
||||||
}
|
|
||||||
return countdown == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if try(st.lastOffset) {
|
|
||||||
return bestD, bestDiff
|
|
||||||
}
|
|
||||||
for k := 1; ; k++ {
|
|
||||||
lo, hi := st.lastOffset-k, st.lastOffset+k
|
|
||||||
if lo < -limit && hi > limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if try(hi) || try(lo) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestD, bestDiff
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *stitcher) pairDiff(f []rowCols, active []bool, d int) (float32, int) {
|
|
||||||
h := len(f)
|
|
||||||
top, bottom := matchIgnores(h)
|
|
||||||
lo := max(top, -d)
|
|
||||||
hi := min(h-bottom, h-d)
|
|
||||||
|
|
||||||
count := hi - lo
|
|
||||||
if count < stitchMinCompare {
|
|
||||||
return float32(1e9), 0
|
|
||||||
}
|
|
||||||
var sum float32
|
|
||||||
activeMatches := 0
|
|
||||||
for i := lo; i < hi; i++ {
|
|
||||||
rd := rowColsDiff(f[i], st.last[i+d])
|
|
||||||
sum += rd
|
|
||||||
if active[i] && rd <= stitchRowMatchTol {
|
|
||||||
activeMatches++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sum / float32(count), activeMatches
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *stitcher) canvasDiff(f []rowCols, active []bool, pos int) (float32, int, int) {
|
|
||||||
h := len(f)
|
|
||||||
top, bottom := matchIgnores(h)
|
|
||||||
lo := max(top, -pos)
|
|
||||||
hi := min(h-bottom, len(st.cols)-pos)
|
|
||||||
|
|
||||||
count := hi - lo
|
|
||||||
if count < 1 {
|
|
||||||
return float32(1e9), 0, 0
|
|
||||||
}
|
|
||||||
var sum float32
|
|
||||||
activeMatches := 0
|
|
||||||
for i := lo; i < hi; i++ {
|
|
||||||
rd := rowColsDiff(f[i], st.cols[pos+i])
|
|
||||||
sum += rd
|
|
||||||
if active[i] && rd <= stitchRowMatchTol {
|
|
||||||
activeMatches++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sum / float32(count), count, activeMatches
|
|
||||||
}
|
|
||||||
|
|
||||||
// mark-shot's findEdgePosition (nearOnly: edges + prediction window, 1px) and
|
|
||||||
// findKnownPosition (coarse sweep refined around the winner)
|
|
||||||
func (st *stitcher) scanPositions(f []rowCols, active []bool, pred int, nearOnly bool) (int, float32, bool) {
|
|
||||||
h := len(f)
|
|
||||||
C := len(st.cols)
|
|
||||||
minPos := stitchMinCanvas - h
|
|
||||||
maxPos := C - stitchMinCanvas
|
|
||||||
|
|
||||||
bestPos, bestDiff := 0, float32(1e9)
|
|
||||||
bestDist := 1 << 30
|
|
||||||
consider := func(pos int) {
|
|
||||||
if pos < minPos || pos > maxPos {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
diff, ok := st.verifyAt(f, active, pos)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dist := pos - pred
|
|
||||||
if dist < 0 {
|
|
||||||
dist = -dist
|
|
||||||
}
|
|
||||||
better := diff < bestDiff
|
|
||||||
if !nearOnly {
|
|
||||||
better = dist < bestDist || dist == bestDist && diff < bestDiff
|
|
||||||
}
|
|
||||||
if better {
|
|
||||||
bestPos, bestDiff, bestDist = pos, diff, dist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if nearOnly {
|
|
||||||
for pos := pred - stitchPredictWindow; pos <= pred+stitchPredictWindow; pos++ {
|
|
||||||
consider(pos)
|
|
||||||
}
|
|
||||||
for pos := C - h; pos <= maxPos; pos++ {
|
|
||||||
consider(pos)
|
|
||||||
}
|
|
||||||
for pos := minPos; pos <= 0; pos++ {
|
|
||||||
consider(pos)
|
|
||||||
}
|
|
||||||
if bestDiff > stitchAcceptDiff {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
return bestPos, bestDiff, true
|
|
||||||
}
|
|
||||||
|
|
||||||
for pos := minPos; pos <= maxPos; pos += stitchCoarseStep {
|
|
||||||
consider(pos)
|
|
||||||
}
|
|
||||||
if bestDiff > stitchAcceptDiff {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
refined, refinedDiff := bestPos, bestDiff
|
|
||||||
for pos := bestPos - stitchCoarseStep + 1; pos < bestPos+stitchCoarseStep; pos++ {
|
|
||||||
if pos == bestPos {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if diff, ok := st.verifyAt(f, active, pos); ok && diff < refinedDiff {
|
|
||||||
refined, refinedDiff = pos, diff
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return refined, refinedDiff, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *stitcher) appendRows(frame []byte, f []rowCols, from int) int {
|
|
||||||
n := len(f) - from
|
|
||||||
if room := st.maxRows - len(st.cols); n > room {
|
|
||||||
n = room
|
|
||||||
st.full = true
|
|
||||||
}
|
|
||||||
if n <= 0 {
|
|
||||||
st.full = true
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
st.canvas = append(st.canvas, frame[from*st.stride:(from+n)*st.stride]...)
|
|
||||||
st.cols = append(st.cols, f[from:from+n]...)
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *stitcher) prependRows(frame []byte, f []rowCols, n int) int {
|
|
||||||
if room := st.maxRows - len(st.cols); n > room {
|
|
||||||
n = room
|
|
||||||
st.full = true
|
|
||||||
}
|
|
||||||
if n <= 0 {
|
|
||||||
st.full = true
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas := make([]byte, n*st.stride+len(st.canvas))
|
|
||||||
copy(canvas, frame[:n*st.stride])
|
|
||||||
copy(canvas[n*st.stride:], st.canvas)
|
|
||||||
st.canvas = canvas
|
|
||||||
|
|
||||||
cols := make([]rowCols, 0, n+len(st.cols))
|
|
||||||
cols = append(cols, f[:n]...)
|
|
||||||
st.cols = append(cols, st.cols...)
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func abs32(f float32) float32 {
|
|
||||||
if f < 0 {
|
|
||||||
return -f
|
|
||||||
}
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package screenshot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"math/rand"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
testStride = 512
|
|
||||||
testFrameH = 240
|
|
||||||
)
|
|
||||||
|
|
||||||
func makePage(t *testing.T, rows int) []byte {
|
|
||||||
t.Helper()
|
|
||||||
rng := rand.New(rand.NewSource(42))
|
|
||||||
page := make([]byte, rows*testStride)
|
|
||||||
rng.Read(page)
|
|
||||||
return page
|
|
||||||
}
|
|
||||||
|
|
||||||
func frameAt(page []byte, top int) []byte {
|
|
||||||
return page[top*testStride : (top+testFrameH)*testStride]
|
|
||||||
}
|
|
||||||
|
|
||||||
func pushFrame(st *stitcher, frame []byte) int {
|
|
||||||
n, _ := st.pushFrame(frame, st.rowSamples(frame))
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStitchSlidingWindows(t *testing.T) {
|
|
||||||
page := makePage(t, 1000)
|
|
||||||
|
|
||||||
for _, delta := range []int{20, 60, 110} {
|
|
||||||
st := newStitcher(testStride)
|
|
||||||
lastTop := 0
|
|
||||||
for top := 0; top+testFrameH <= 900; top += delta {
|
|
||||||
lastTop = top
|
|
||||||
pushFrame(st, frameAt(page, top))
|
|
||||||
}
|
|
||||||
|
|
||||||
wantRows := lastTop + testFrameH
|
|
||||||
if st.rows() != wantRows {
|
|
||||||
t.Fatalf("delta %d: got %d rows, want %d", delta, st.rows(), wantRows)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(st.canvas, page[:wantRows*testStride]) {
|
|
||||||
t.Fatalf("delta %d: canvas does not match source rows", delta)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStitchDropsNoOverlap(t *testing.T) {
|
|
||||||
page := makePage(t, 1000)
|
|
||||||
st := newStitcher(testStride)
|
|
||||||
|
|
||||||
pushFrame(st, frameAt(page, 0))
|
|
||||||
if appended := pushFrame(st, frameAt(page, testFrameH+50)); appended != 0 {
|
|
||||||
t.Fatalf("unmatched jump appended %d rows", appended)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(st.canvas, page[:testFrameH*testStride]) {
|
|
||||||
t.Fatal("canvas changed on unmatched frame")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStitchNoGrowthCases(t *testing.T) {
|
|
||||||
page := makePage(t, 1000)
|
|
||||||
blank := make([]byte, testFrameH*testStride)
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
first, second []byte
|
|
||||||
}{
|
|
||||||
{"identical frame", frameAt(page, 0), frameAt(page, 0)},
|
|
||||||
{"jitter below min append", frameAt(page, 0), frameAt(page, stitchMinAppend-5)},
|
|
||||||
{"blank on blank", blank, blank},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
st := newStitcher(testStride)
|
|
||||||
pushFrame(st, tc.first)
|
|
||||||
if appended := pushFrame(st, tc.second); appended != 0 {
|
|
||||||
t.Fatalf("%s: appended %d rows", tc.name, appended)
|
|
||||||
}
|
|
||||||
if st.rows() != testFrameH {
|
|
||||||
t.Fatalf("%s: got %d rows, want %d", tc.name, st.rows(), testFrameH)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStitchRevisitNeverDuplicates(t *testing.T) {
|
|
||||||
page := makePage(t, 1000)
|
|
||||||
st := newStitcher(testStride)
|
|
||||||
|
|
||||||
pushFrame(st, frameAt(page, 0))
|
|
||||||
pushFrame(st, frameAt(page, 100))
|
|
||||||
pushFrame(st, frameAt(page, 200))
|
|
||||||
|
|
||||||
for _, top := range []int{150, 60, 0, 80, 190} {
|
|
||||||
if appended := pushFrame(st, frameAt(page, top)); appended != 0 {
|
|
||||||
t.Fatalf("revisited frame at %d appended %d rows", top, appended)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pushFrame(st, frameAt(page, 300))
|
|
||||||
|
|
||||||
wantRows := 300 + testFrameH
|
|
||||||
if st.rows() != wantRows {
|
|
||||||
t.Fatalf("got %d rows, want %d", st.rows(), wantRows)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(st.canvas, page[:wantRows*testStride]) {
|
|
||||||
t.Fatal("canvas corrupted by revisited frames")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStitchScrollUpPrepends(t *testing.T) {
|
|
||||||
page := makePage(t, 1000)
|
|
||||||
st := newStitcher(testStride)
|
|
||||||
|
|
||||||
pushFrame(st, frameAt(page, 500))
|
|
||||||
if appended := pushFrame(st, frameAt(page, 420)); appended != 80 {
|
|
||||||
t.Fatalf("upward frame appended %d rows, want 80", appended)
|
|
||||||
}
|
|
||||||
pushFrame(st, frameAt(page, 560))
|
|
||||||
|
|
||||||
if !bytes.Equal(st.canvas, page[420*testStride:(560+testFrameH)*testStride]) {
|
|
||||||
t.Fatal("canvas does not match page range after prepend + append")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStitchNoisyChromeStillMatches(t *testing.T) {
|
|
||||||
page := makePage(t, 1000)
|
|
||||||
st := newStitcher(testStride)
|
|
||||||
|
|
||||||
addChrome := func(frame []byte, seed byte) []byte {
|
|
||||||
f := slices.Clone(frame)
|
|
||||||
for y := range testFrameH {
|
|
||||||
for x := range 32 {
|
|
||||||
f[y*testStride+x] = seed + byte(y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for y := 100; y < 124; y++ {
|
|
||||||
for x := testStride / 2; x < testStride/2+40; x++ {
|
|
||||||
f[y*testStride+x] ^= 0x08
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
pushFrame(st, addChrome(frameAt(page, 0), 1))
|
|
||||||
if appended := pushFrame(st, addChrome(frameAt(page, 90), 2)); appended != 90 {
|
|
||||||
t.Fatalf("appended %d rows, want 90", appended)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStitchMaxRowsCap(t *testing.T) {
|
|
||||||
page := makePage(t, 1000)
|
|
||||||
st := newStitcher(testStride)
|
|
||||||
st.maxRows = testFrameH + 10
|
|
||||||
|
|
||||||
pushFrame(st, frameAt(page, 0))
|
|
||||||
if appended := pushFrame(st, frameAt(page, 100)); appended != 10 {
|
|
||||||
t.Fatalf("appended %d rows past cap, want 10", appended)
|
|
||||||
}
|
|
||||||
if !st.full {
|
|
||||||
t.Fatal("stitcher not marked full at cap")
|
|
||||||
}
|
|
||||||
if pushFrame(st, frameAt(page, 300)) != 0 {
|
|
||||||
t.Fatal("push after full appended rows")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ const (
|
|||||||
ModeAllScreens
|
ModeAllScreens
|
||||||
ModeOutput
|
ModeOutput
|
||||||
ModeLastRegion
|
ModeLastRegion
|
||||||
ModeScroll
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Format int
|
type Format int
|
||||||
@@ -63,7 +62,6 @@ type Config struct {
|
|||||||
SaveFile bool
|
SaveFile bool
|
||||||
Notify bool
|
Notify bool
|
||||||
Stdout bool
|
Stdout bool
|
||||||
IntervalMs int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ func NewManager() (*Manager, error) {
|
|||||||
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
|
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
|
||||||
m.promptBroker = broker
|
m.promptBroker = broker
|
||||||
|
|
||||||
adapter, err := findAdapter(conn)
|
adapter, err := m.findAdapter()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
|
||||||
}
|
}
|
||||||
m.adapterPath = adapter
|
m.adapterPath = adapter
|
||||||
|
|
||||||
@@ -74,12 +74,12 @@ func NewManager() (*Manager, error) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
|
func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
|
||||||
obj := conn.Object(bluezService, dbus.ObjectPath("/"))
|
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
|
||||||
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
||||||
|
|
||||||
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
|
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
|
||||||
return "", fmt.Errorf("%w: %v", ErrNoAdapter, err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
for path, interfaces := range objects {
|
for path, interfaces := range objects {
|
||||||
@@ -89,7 +89,7 @@ func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", ErrNoAdapter
|
return "", fmt.Errorf("no adapter found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) initialize() error {
|
func (m *Manager) initialize() error {
|
||||||
@@ -487,11 +487,6 @@ func (m *Manager) StopDiscovery() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SetPowered(powered bool) error {
|
func (m *Manager) SetPowered(powered bool) error {
|
||||||
if powered {
|
|
||||||
if err := rfkillUnblockBluetooth(); err != nil {
|
|
||||||
log.Debugf("[BluezManager] rfkill unblock failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
obj := m.dbusConn.Object(bluezService, m.adapterPath)
|
obj := m.dbusConn.Object(bluezService, m.adapterPath)
|
||||||
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
|
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
package bluez
|
|
||||||
|
|
||||||
func rfkillUnblockBluetooth() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package bluez
|
|
||||||
|
|
||||||
import "os"
|
|
||||||
|
|
||||||
// linux/rfkill.h: struct rfkill_event { __u32 idx; __u8 type; __u8 op; __u8 soft; __u8 hard; },
|
|
||||||
// RFKILL_TYPE_BLUETOOTH=2, RFKILL_OP_CHANGE_ALL=3
|
|
||||||
func rfkillUnblockBluetooth() error {
|
|
||||||
f, err := os.OpenFile("/dev/rfkill", os.O_WRONLY, 0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
var event [8]byte
|
|
||||||
event[4] = 2
|
|
||||||
event[5] = 3
|
|
||||||
_, err = f.Write(event[:])
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package bluez
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrNoAdapter = errors.New("no bluetooth adapter found")
|
|
||||||
|
|
||||||
func WaitForAdapter() error {
|
|
||||||
conn, err := dbus.ConnectSystemBus()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
if err := conn.AddMatchSignal(
|
|
||||||
dbus.WithMatchInterface(objectMgrIface),
|
|
||||||
dbus.WithMatchMember("InterfacesAdded"),
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
signals := make(chan *dbus.Signal, 64)
|
|
||||||
conn.Signal(signals)
|
|
||||||
|
|
||||||
if _, err := findAdapter(conn); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for sig := range signals {
|
|
||||||
if sig == nil || sig.Name != objectMgrIface+".InterfacesAdded" || len(sig.Body) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ifaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := ifaces[adapter1Iface]; ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("dbus signal stream closed")
|
|
||||||
}
|
|
||||||
@@ -229,9 +229,6 @@ func (m *Manager) snapshotState() CUPSState {
|
|||||||
func (m *Manager) Subscribe(id string) chan CUPSState {
|
func (m *Manager) Subscribe(id string) chan CUPSState {
|
||||||
ch := make(chan CUPSState, 64)
|
ch := make(chan CUPSState, 64)
|
||||||
|
|
||||||
m.subLifecycleMu.Lock()
|
|
||||||
defer m.subLifecycleMu.Unlock()
|
|
||||||
|
|
||||||
wasEmpty := true
|
wasEmpty := true
|
||||||
m.subscribers.Range(func(key string, ch chan CUPSState) bool {
|
m.subscribers.Range(func(key string, ch chan CUPSState) bool {
|
||||||
wasEmpty = false
|
wasEmpty = false
|
||||||
@@ -240,25 +237,19 @@ func (m *Manager) Subscribe(id string) chan CUPSState {
|
|||||||
|
|
||||||
m.subscribers.Store(id, ch)
|
m.subscribers.Store(id, ch)
|
||||||
|
|
||||||
if !wasEmpty || m.subscription == nil {
|
if wasEmpty && m.subscription != nil {
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.subscription.Start(); err != nil {
|
if err := m.subscription.Start(); err != nil {
|
||||||
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
|
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
|
||||||
return ch
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
m.eventWG.Add(1)
|
m.eventWG.Add(1)
|
||||||
go m.eventHandler()
|
go m.eventHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Unsubscribe(id string) {
|
func (m *Manager) Unsubscribe(id string) {
|
||||||
m.subLifecycleMu.Lock()
|
|
||||||
defer m.subLifecycleMu.Unlock()
|
|
||||||
|
|
||||||
if val, ok := m.subscribers.LoadAndDelete(id); ok {
|
if val, ok := m.subscribers.LoadAndDelete(id); ok {
|
||||||
close(val)
|
close(val)
|
||||||
}
|
}
|
||||||
@@ -269,22 +260,18 @@ func (m *Manager) Unsubscribe(id string) {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
if !isEmpty || m.subscription == nil {
|
if isEmpty && m.subscription != nil {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
m.subscription.Stop()
|
m.subscription.Stop()
|
||||||
m.eventWG.Wait()
|
m.eventWG.Wait()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) Close() {
|
func (m *Manager) Close() {
|
||||||
close(m.stopChan)
|
close(m.stopChan)
|
||||||
|
|
||||||
m.subLifecycleMu.Lock()
|
|
||||||
if m.subscription != nil {
|
if m.subscription != nil {
|
||||||
m.subscription.Stop()
|
m.subscription.Stop()
|
||||||
}
|
}
|
||||||
m.subLifecycleMu.Unlock()
|
|
||||||
|
|
||||||
m.eventWG.Wait()
|
m.eventWG.Wait()
|
||||||
m.notifierWg.Wait()
|
m.notifierWg.Wait()
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package cups
|
package cups
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
|
mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
|
||||||
@@ -78,67 +75,6 @@ func TestManager_Subscribe(t *testing.T) {
|
|||||||
assert.Equal(t, 0, count)
|
assert.Equal(t, 0, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mirrors the real managers: eventChan guarded by mu, conn/running deliberately
|
|
||||||
// unsynchronized so overlapping Start/Stop trips the race detector
|
|
||||||
type stubSubscription struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
events chan SubscriptionEvent
|
|
||||||
conn *int
|
|
||||||
running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubSubscription) Start() error {
|
|
||||||
if s.running {
|
|
||||||
return errors.New("already running")
|
|
||||||
}
|
|
||||||
s.running = true
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
s.events = make(chan SubscriptionEvent)
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
v := 0
|
|
||||||
s.conn = &v
|
|
||||||
*s.conn++
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubSubscription) Stop() {
|
|
||||||
if !s.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.running = false
|
|
||||||
s.conn = nil
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
close(s.events)
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubSubscription) Events() <-chan SubscriptionEvent {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.events
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_SubscribeUnsubscribeRace(t *testing.T) {
|
|
||||||
m := NewTestManager(mocks_cups.NewMockCUPSClientInterface(t), nil)
|
|
||||||
m.subscription = &stubSubscription{}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i := range 8 {
|
|
||||||
wg.Go(func() {
|
|
||||||
id := fmt.Sprintf("client-%d", i)
|
|
||||||
for range 50 {
|
|
||||||
m.Subscribe(id)
|
|
||||||
m.Unsubscribe(id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_Close(t *testing.T) {
|
func TestManager_Close(t *testing.T) {
|
||||||
mockClient := mocks_cups.NewMockCUPSClientInterface(t)
|
mockClient := mocks_cups.NewMockCUPSClientInterface(t)
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ type Manager struct {
|
|||||||
client CUPSClientInterface
|
client CUPSClientInterface
|
||||||
pkHelper PkHelper
|
pkHelper PkHelper
|
||||||
subscription SubscriptionManagerInterface
|
subscription SubscriptionManagerInterface
|
||||||
subLifecycleMu sync.Mutex
|
|
||||||
stateMutex sync.RWMutex
|
stateMutex sync.RWMutex
|
||||||
subscribers syncmap.Map[string, chan CUPSState]
|
subscribers syncmap.Map[string, chan CUPSState]
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
|||||||
@@ -744,32 +744,6 @@ func (b *IWDBackend) DisconnectWiFi() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *IWDBackend) abortInFlightConnection(ssid string) {
|
|
||||||
b.stateMutex.Lock()
|
|
||||||
if !b.state.IsConnecting || b.state.ConnectingSSID != ssid {
|
|
||||||
b.stateMutex.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b.state.IsConnecting = false
|
|
||||||
b.state.ConnectingSSID = ""
|
|
||||||
b.state.LastError = ""
|
|
||||||
b.stateMutex.Unlock()
|
|
||||||
|
|
||||||
b.attemptMutex.RLock()
|
|
||||||
att := b.curAttempt
|
|
||||||
b.attemptMutex.RUnlock()
|
|
||||||
|
|
||||||
if att != nil && att.ssid == ssid {
|
|
||||||
att.mu.Lock()
|
|
||||||
att.finalized = true
|
|
||||||
att.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := b.DisconnectWiFi(); err != nil {
|
|
||||||
log.Warnf("[abortInFlightConnection] failed to abort connection to %s: %v", ssid, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
|
func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
|
||||||
b.stateMutex.RLock()
|
b.stateMutex.RLock()
|
||||||
currentSSID := b.state.WiFiSSID
|
currentSSID := b.state.WiFiSSID
|
||||||
@@ -838,10 +812,6 @@ func (b *IWDBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
|
|||||||
return fmt.Errorf("failed to set autoconnect: %w", call.Err)
|
return fmt.Errorf("failed to set autoconnect: %w", call.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !autoconnect {
|
|
||||||
b.abortInFlightConnection(ssid)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.updateState()
|
b.updateState()
|
||||||
|
|
||||||
if b.onStateChange != nil {
|
if b.onStateChange != nil {
|
||||||
|
|||||||
@@ -359,24 +359,6 @@ func (b *NetworkManagerBackend) DisconnectWiFi() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *NetworkManagerBackend) abortInFlightConnection(ssid string) {
|
|
||||||
b.stateMutex.Lock()
|
|
||||||
if !b.state.IsConnecting || b.state.ConnectingSSID != ssid {
|
|
||||||
b.stateMutex.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b.state.IsConnecting = false
|
|
||||||
b.state.ConnectingSSID = ""
|
|
||||||
b.state.LastError = ""
|
|
||||||
b.stateMutex.Unlock()
|
|
||||||
|
|
||||||
b.clearCachedWiFiSecretBySSID(ssid)
|
|
||||||
|
|
||||||
if err := b.DisconnectWiFi(); err != nil {
|
|
||||||
log.Warnf("[abortInFlightConnection] failed to abort connection to %s: %v", ssid, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
|
func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
|
||||||
conn, err := b.findConnection(ssid)
|
conn, err := b.findConnection(ssid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -981,10 +963,6 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
|
|||||||
return fmt.Errorf("failed to update connection: %w", err)
|
return fmt.Errorf("failed to update connection: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !autoconnect {
|
|
||||||
b.abortInFlightConnection(ssid)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.updateWiFiNetworks()
|
b.updateWiFiNetworks()
|
||||||
|
|
||||||
if b.onStateChange != nil {
|
if b.onStateChange != nil {
|
||||||
|
|||||||
@@ -117,12 +117,11 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(req.Method, "cups.") {
|
if strings.HasPrefix(req.Method, "cups.") {
|
||||||
mgr, err := ensureCupsManager()
|
if cupsManager == nil {
|
||||||
if err != nil {
|
|
||||||
models.RespondError(conn, req.ID, "CUPS manager not initialized")
|
models.RespondError(conn, req.ID, "CUPS manager not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cups.HandleRequest(conn, req, mgr)
|
cups.HandleRequest(conn, req, cupsManager)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -84,8 +85,8 @@ var geoClientInstance geolocation.Client
|
|||||||
const dbusClientID = "dms-dbus-client"
|
const dbusClientID = "dms-dbus-client"
|
||||||
|
|
||||||
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
|
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
|
||||||
var cupsMu sync.Mutex
|
var cupsSubscribers syncmap.Map[string, bool]
|
||||||
var cupsSubscriberCount int
|
var cupsSubscriberCount atomic.Int32
|
||||||
|
|
||||||
func getSocketDir() string {
|
func getSocketDir() string {
|
||||||
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
|
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
|
||||||
@@ -242,55 +243,17 @@ func InitializeAppPickerManager() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeCupsManagerLocked() (bool, error) {
|
func InitializeCupsManager() error {
|
||||||
if cupsManager != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
manager, err := cups.NewManager()
|
manager, err := cups.NewManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("Failed to initialize cups manager: %v", err)
|
log.Warnf("Failed to initialize cups manager: %v", err)
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cupsManager = manager
|
cupsManager = manager
|
||||||
|
|
||||||
log.Info("CUPS manager initialized")
|
log.Info("CUPS manager initialized")
|
||||||
return true, nil
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
func ensureCupsManager() (*cups.Manager, error) {
|
|
||||||
cupsMu.Lock()
|
|
||||||
created, err := initializeCupsManagerLocked()
|
|
||||||
mgr := cupsManager
|
|
||||||
cupsMu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if created {
|
|
||||||
notifyCapabilityChange()
|
|
||||||
}
|
|
||||||
return mgr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cupsAvailable() bool {
|
|
||||||
cupsMu.Lock()
|
|
||||||
defer cupsMu.Unlock()
|
|
||||||
return cupsManager != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func releaseCupsSubscriber() {
|
|
||||||
cupsMu.Lock()
|
|
||||||
cupsSubscriberCount--
|
|
||||||
var mgr *cups.Manager
|
|
||||||
if cupsSubscriberCount == 0 && cupsManager != nil {
|
|
||||||
mgr = cupsManager
|
|
||||||
cupsManager = nil
|
|
||||||
}
|
|
||||||
cupsMu.Unlock()
|
|
||||||
if mgr == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
|
|
||||||
mgr.Close()
|
|
||||||
notifyCapabilityChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitializeBrightnessManager() error {
|
func InitializeBrightnessManager() error {
|
||||||
@@ -500,7 +463,7 @@ func getCapabilities() Capabilities {
|
|||||||
caps = append(caps, "browser")
|
caps = append(caps, "browser")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cupsAvailable() {
|
if cupsManager != nil {
|
||||||
caps = append(caps, "cups")
|
caps = append(caps, "cups")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,7 +533,7 @@ func getServerInfo() ServerInfo {
|
|||||||
caps = append(caps, "browser")
|
caps = append(caps, "browser")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cupsAvailable() {
|
if cupsManager != nil {
|
||||||
caps = append(caps, "cups")
|
caps = append(caps, "cups")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,31 +997,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if shouldSubscribe("cups") {
|
if shouldSubscribe("cups") {
|
||||||
cupsMu.Lock()
|
cupsSubscribers.Store(clientID+"-cups", true)
|
||||||
cupsSubscriberCount++
|
count := cupsSubscriberCount.Add(1)
|
||||||
created, err := initializeCupsManagerLocked()
|
|
||||||
mgr := cupsManager
|
|
||||||
cupsMu.Unlock()
|
|
||||||
|
|
||||||
if err != nil {
|
if count == 1 {
|
||||||
|
if err := InitializeCupsManager(); err != nil {
|
||||||
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
|
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
|
||||||
} else if created {
|
} else {
|
||||||
notifyCapabilityChange()
|
notifyCapabilityChange()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if mgr == nil {
|
if cupsManager != nil {
|
||||||
releaseCupsSubscriber()
|
|
||||||
} else {
|
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
cupsChan := mgr.Subscribe(clientID + "-cups")
|
cupsChan := cupsManager.Subscribe(clientID + "-cups")
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() {
|
defer func() {
|
||||||
mgr.Unsubscribe(clientID + "-cups")
|
cupsManager.Unsubscribe(clientID + "-cups")
|
||||||
releaseCupsSubscriber()
|
cupsSubscribers.Delete(clientID + "-cups")
|
||||||
|
count := cupsSubscriberCount.Add(-1)
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
|
||||||
|
if cupsManager != nil {
|
||||||
|
cupsManager.Close()
|
||||||
|
cupsManager = nil
|
||||||
|
notifyCapabilityChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
initialState := mgr.GetState()
|
initialState := cupsManager.GetState()
|
||||||
select {
|
select {
|
||||||
case eventChan <- ServiceEvent{Service: "cups", Data: initialState}:
|
case eventChan <- ServiceEvent{Service: "cups", Data: initialState}:
|
||||||
case <-stopChan:
|
case <-stopChan:
|
||||||
@@ -1368,12 +1338,9 @@ func cleanupManagers() {
|
|||||||
if appPickerManager != nil {
|
if appPickerManager != nil {
|
||||||
appPickerManager.Close()
|
appPickerManager.Close()
|
||||||
}
|
}
|
||||||
cupsMu.Lock()
|
|
||||||
if cupsManager != nil {
|
if cupsManager != nil {
|
||||||
cupsManager.Close()
|
cupsManager.Close()
|
||||||
cupsManager = nil
|
|
||||||
}
|
}
|
||||||
cupsMu.Unlock()
|
|
||||||
if brightnessManager != nil {
|
if brightnessManager != nil {
|
||||||
brightnessManager.Close()
|
brightnessManager.Close()
|
||||||
}
|
}
|
||||||
@@ -1731,21 +1698,10 @@ func Start(printDocs bool) error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
if err := InitializeBluezManager(); err != nil {
|
||||||
err := InitializeBluezManager()
|
|
||||||
if err == nil {
|
|
||||||
notifyCapabilityChange()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Warnf("Bluez manager unavailable: %v", err)
|
log.Warnf("Bluez manager unavailable: %v", err)
|
||||||
if !errors.Is(err, bluez.ErrNoAdapter) {
|
} else {
|
||||||
return
|
notifyCapabilityChange()
|
||||||
}
|
|
||||||
if err := bluez.WaitForAdapter(); err != nil {
|
|
||||||
log.Warnf("Bluetooth adapter watch failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Info("Bluetooth adapter appeared, initializing bluez manager")
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -50,42 +50,11 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
||||||
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
argv := []string{"pacman", "-Syu", "--noconfirm", "--needed"}
|
||||||
}
|
if len(opts.Ignored) > 0 {
|
||||||
|
argv = append(argv, "--ignore", strings.Join(opts.Ignored, ","))
|
||||||
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
|
|
||||||
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
|
|
||||||
// AUR packages are exempt — holding those cannot break the repo dependency graph.
|
|
||||||
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
|
|
||||||
if len(ignored) == 0 {
|
|
||||||
return ignored
|
|
||||||
}
|
|
||||||
repoPending := make(map[string]bool, len(pending))
|
|
||||||
for _, p := range pending {
|
|
||||||
if p.Repo == RepoSystem {
|
|
||||||
repoPending[p.Name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(ignored))
|
|
||||||
for _, name := range ignored {
|
|
||||||
if repoPending[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func isPacmanFamily(b Backend) bool {
|
|
||||||
if b == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch b.ID() {
|
|
||||||
case "pacman", "paru", "yay":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
return privilegedArgv(opts, argv...)
|
||||||
}
|
}
|
||||||
|
|
||||||
type archHelperBackend struct {
|
type archHelperBackend struct {
|
||||||
@@ -143,7 +112,7 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
|
|||||||
}
|
}
|
||||||
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ")
|
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ")
|
||||||
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
||||||
return Run(ctx, wrapInTerminal(term, title, cmd, opts.TerminalArgs), RunOptions{OnLine: onLine})
|
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
|
||||||
}
|
}
|
||||||
|
|
||||||
func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string {
|
func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func findTerminal(override string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
func wrapInTerminal(term, title, shellCmd string) []string {
|
||||||
const appID = "com.danklinux.dms"
|
const appID = "com.danklinux.dms"
|
||||||
banner := fmt.Sprintf(
|
banner := fmt.Sprintf(
|
||||||
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
||||||
@@ -129,26 +129,24 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
|||||||
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||||
full := export + banner + "; " + shellCmd + "; " + closer
|
full := export + banner + "; " + shellCmd + "; " + closer
|
||||||
|
|
||||||
var argv []string
|
|
||||||
execFlag := "-e"
|
|
||||||
switch term {
|
switch term {
|
||||||
case "kitty", "alacritty", "wezterm":
|
case "kitty":
|
||||||
argv = []string{term, "--class", appID, "-T", title}
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "alacritty":
|
||||||
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
case "foot":
|
case "foot":
|
||||||
argv = []string{term, "--app-id=" + appID, "--title=" + title}
|
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||||
case "ghostty":
|
case "ghostty":
|
||||||
argv = []string{term, "--class=" + appID, "--title=" + title}
|
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||||
|
case "wezterm":
|
||||||
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
case "xterm":
|
case "xterm":
|
||||||
argv = []string{term, "-class", appID, "-T", title}
|
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
case "konsole":
|
case "konsole":
|
||||||
argv = []string{term, "-p", "tabtitle=" + title}
|
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
|
||||||
case "gnome-terminal":
|
case "gnome-terminal":
|
||||||
// --wait: the factory process otherwise returns immediately
|
return []string{term, "--title=" + title, "--", "sh", "-c", full}
|
||||||
argv = []string{term, "--wait", "--title=" + title}
|
|
||||||
execFlag = "--"
|
|
||||||
default:
|
default:
|
||||||
argv = []string{term}
|
return []string{term, "-e", "sh", "-c", full}
|
||||||
}
|
}
|
||||||
argv = append(argv, extraArgs...)
|
|
||||||
return append(argv, execFlag, "sh", "-c", full)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
|
|||||||
DryRun: params.BoolOpt(req.Params, "dry", false),
|
DryRun: params.BoolOpt(req.Params, "dry", false),
|
||||||
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
||||||
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
||||||
TerminalArgs: stringSliceOpt(req.Params, "terminalArgs"),
|
|
||||||
Ignored: stringSliceOpt(req.Params, "ignored"),
|
Ignored: stringSliceOpt(req.Params, "ignored"),
|
||||||
}
|
}
|
||||||
if err := m.Upgrade(opts); err != nil {
|
if err := m.Upgrade(opts); err != nil {
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if opts.CustomCommand != "" {
|
if opts.CustomCommand != "" {
|
||||||
m.runCustomUpgrade(ctx, opts)
|
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,9 +345,6 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
|||||||
opts.Targets = append([]Package(nil), m.state.Packages...)
|
opts.Targets = append([]Package(nil), m.state.Packages...)
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
}
|
}
|
||||||
if isPacmanFamily(m.selection.System) {
|
|
||||||
opts.Ignored = dropPacmanRepoIgnores(opts.Ignored, opts.Targets)
|
|
||||||
}
|
|
||||||
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
|
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
|
||||||
|
|
||||||
backends := upgradeBackends(m.selection, opts)
|
backends := upgradeBackends(m.selection, opts)
|
||||||
@@ -392,8 +389,8 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
|||||||
m.finishSuccessfulUpgrade(true)
|
m.finishSuccessfulUpgrade(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
|
||||||
term := findTerminal(opts.Terminal)
|
term := findTerminal(terminalOverride)
|
||||||
if term == "" {
|
if term == "" {
|
||||||
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||||
return
|
return
|
||||||
@@ -410,30 +407,24 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
|||||||
m.markDirty()
|
m.markDirty()
|
||||||
|
|
||||||
onLine := func(line string) { m.appendLog(line) }
|
onLine := func(line string) { m.appendLog(line) }
|
||||||
argv := wrapInTerminal(term, "DMS — System Update (custom)", opts.CustomCommand, opts.TerminalArgs)
|
argv := wrapInTerminal(term, "DMS — System Update (custom)", command)
|
||||||
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
|
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
|
||||||
|
code := ErrCodeBackendFailed
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||||
m.failCustomUpgrade(ErrCodeTimeout, err)
|
code = ErrCodeTimeout
|
||||||
return
|
|
||||||
case errors.Is(ctx.Err(), context.Canceled):
|
case errors.Is(ctx.Err(), context.Canceled):
|
||||||
m.failCustomUpgrade(ErrCodeCancelled, err)
|
code = ErrCodeCancelled
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// exit status reflects the trailing `read`, not the update command
|
|
||||||
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
m.finishSuccessfulUpgrade(false)
|
|
||||||
m.runRefresh(context.Background(), false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) failCustomUpgrade(code ErrorCode, err error) {
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.state.Phase = PhaseError
|
m.state.Phase = PhaseError
|
||||||
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
|
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
m.markDirty()
|
m.markDirty()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.finishSuccessfulUpgrade(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
|
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ type UpgradeOptions struct {
|
|||||||
AttachStdio bool
|
AttachStdio bool
|
||||||
CustomCommand string
|
CustomCommand string
|
||||||
Terminal string
|
Terminal string
|
||||||
TerminalArgs []string
|
|
||||||
Targets []Package
|
Targets []Package
|
||||||
Ignored []string
|
Ignored []string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ func TestUpgradeCommandBuilders(t *testing.T) {
|
|||||||
want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"},
|
want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "pacman never passes --ignore",
|
name: "pacman with ignored packages",
|
||||||
got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}),
|
got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}),
|
||||||
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed"},
|
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed", "--ignore", "linux"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dnf with ignored packages",
|
name: "dnf with ignored packages",
|
||||||
@@ -106,18 +106,6 @@ func TestUpgradeCommandBuilders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDropPacmanRepoIgnoresKeepsAURHolds(t *testing.T) {
|
|
||||||
pending := []Package{
|
|
||||||
{Name: "linux", Repo: RepoSystem},
|
|
||||||
{Name: "librewolf", Repo: RepoAUR},
|
|
||||||
}
|
|
||||||
got := dropPacmanRepoIgnores([]string{"linux", "librewolf", "not-pending"}, pending)
|
|
||||||
want := []string{"librewolf", "not-pending"}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("ignored = %#v, want %#v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAptUpgradeArgvHoldsIgnored(t *testing.T) {
|
func TestAptUpgradeArgvHoldsIgnored(t *testing.T) {
|
||||||
argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}})
|
argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}})
|
||||||
if len(argv) < 2 || argv[len(argv)-2] != "-c" {
|
if len(argv) < 2 || argv[len(argv)-2] != "-c" {
|
||||||
@@ -233,27 +221,3 @@ func TestUpgradeBackendsFiltersFlatpakOnly(t *testing.T) {
|
|||||||
t.Fatalf("upgradeBackends(mixed) = %#v, want dnf5 then flatpak", got)
|
t.Fatalf("upgradeBackends(mixed) = %#v, want dnf5 then flatpak", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrapInTerminal(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
term string
|
|
||||||
wantPrefix []string
|
|
||||||
}{
|
|
||||||
{"kitty", []string{"kitty", "--class", "com.danklinux.dms", "-T", "Title"}},
|
|
||||||
{"gnome-terminal", []string{"gnome-terminal", "--wait", "--title=Title"}},
|
|
||||||
{"foot", []string{"foot", "--app-id=com.danklinux.dms", "--title=Title"}},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := wrapInTerminal(tt.term, "Title", "echo hi", nil)
|
|
||||||
if len(got) < len(tt.wantPrefix) || !reflect.DeepEqual(got[:len(tt.wantPrefix)], tt.wantPrefix) {
|
|
||||||
t.Errorf("wrapInTerminal(%q) = %#v, want prefix %#v", tt.term, got, tt.wantPrefix)
|
|
||||||
}
|
|
||||||
tail := got[len(got)-3:]
|
|
||||||
if tail[0] != "sh" || tail[1] != "-c" {
|
|
||||||
t.Errorf("wrapInTerminal(%q) tail = %#v, want [sh -c <cmd>]", tt.term, tail)
|
|
||||||
}
|
|
||||||
if !strings.Contains(tail[2], "echo hi") {
|
|
||||||
t.Errorf("wrapInTerminal(%q) command %q does not contain shell command", tt.term, tail[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -928,8 +928,8 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
|
|||||||
if a.Opacity != nil {
|
if a.Opacity != nil {
|
||||||
lines = append(lines, fmt.Sprintf(" opacity %.2f", *a.Opacity))
|
lines = append(lines, fmt.Sprintf(" opacity %.2f", *a.Opacity))
|
||||||
}
|
}
|
||||||
if a.OpenFloating != nil {
|
if a.OpenFloating != nil && *a.OpenFloating {
|
||||||
lines = append(lines, fmt.Sprintf(" open-floating %t", *a.OpenFloating))
|
lines = append(lines, " open-floating true")
|
||||||
}
|
}
|
||||||
if a.OpenMaximized != nil && *a.OpenMaximized {
|
if a.OpenMaximized != nil && *a.OpenMaximized {
|
||||||
lines = append(lines, " open-maximized true")
|
lines = append(lines, " open-maximized true")
|
||||||
|
|||||||
@@ -200,9 +200,7 @@ in
|
|||||||
];
|
];
|
||||||
# DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS.
|
# DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS.
|
||||||
# Declare security.pam.services.dankshell only if you want to override that runtime fallback.
|
# Declare security.pam.services.dankshell only if you want to override that runtime fallback.
|
||||||
# Do not add pam_u2f or pam_fprintd here for security-key unlock, enable
|
# U2F and fingerprint are handled separately by DMS — do not add pam_u2f or pam_fprintd here.
|
||||||
# programs.dank-material-shell.lockscreen.securityKey.enable, which declares the
|
|
||||||
# dedicated dankshell-u2f service DMS drives on its own.
|
|
||||||
# security.pam.services.dankshell = {
|
# security.pam.services.dankshell = {
|
||||||
# # Example: add faillock
|
# # Example: add faillock
|
||||||
# faillock.enable = true;
|
# faillock.enable = true;
|
||||||
|
|||||||
@@ -23,15 +23,6 @@ in
|
|||||||
description = "Systemd target to bind to.";
|
description = "Systemd target to bind to.";
|
||||||
default = "graphical-session.target";
|
default = "graphical-session.target";
|
||||||
};
|
};
|
||||||
options.programs.dank-material-shell.lockscreen.securityKey = {
|
|
||||||
enable = lib.mkEnableOption "FIDO2/U2F security key unlock for the DMS lock screen via a dedicated dankshell-u2f PAM service";
|
|
||||||
package = lib.mkPackageOption pkgs "pam_u2f" { };
|
|
||||||
moduleArgs = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [ "cue" ];
|
|
||||||
description = "Arguments passed to pam_u2f.so in the dankshell-u2f PAM service.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
systemd.user.services.dms = lib.mkIf cfg.systemd.enable {
|
systemd.user.services.dms = lib.mkIf cfg.systemd.enable {
|
||||||
description = "DankMaterialShell";
|
description = "DankMaterialShell";
|
||||||
@@ -55,18 +46,6 @@ in
|
|||||||
inherit value;
|
inherit value;
|
||||||
}) common.plugins;
|
}) common.plugins;
|
||||||
|
|
||||||
# DMS's bundled U2F fallback stack references pam_u2f.so by name, which NixOS's
|
|
||||||
# libpam cannot resolve; the dedicated service below uses the absolute store path
|
|
||||||
# and is picked up automatically by the lock screen when present.
|
|
||||||
security.pam.services."dankshell-u2f" = lib.mkIf cfg.lockscreen.securityKey.enable {
|
|
||||||
text = ''
|
|
||||||
auth required ${cfg.lockscreen.securityKey.package}/lib/security/pam_u2f.so ${lib.concatStringsSep " " cfg.lockscreen.securityKey.moduleArgs}
|
|
||||||
account required pam_permit.so
|
|
||||||
password required pam_deny.so
|
|
||||||
session required pam_permit.so
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
services.power-profiles-daemon.enable = lib.mkDefault true;
|
services.power-profiles-daemon.enable = lib.mkDefault true;
|
||||||
services.accounts-daemon.enable = lib.mkDefault true;
|
services.accounts-daemon.enable = lib.mkDefault true;
|
||||||
services.geoclue2.enable = lib.mkDefault true;
|
services.geoclue2.enable = lib.mkDefault true;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ pkgs.testers.runNixOSTest {
|
|||||||
programs.dank-material-shell = {
|
programs.dank-material-shell = {
|
||||||
enable = true;
|
enable = true;
|
||||||
systemd.enable = true;
|
systemd.enable = true;
|
||||||
lockscreen.securityKey.enable = true;
|
|
||||||
plugins = {
|
plugins = {
|
||||||
TestPlugin = {
|
TestPlugin = {
|
||||||
src = pkgs.emptyDirectory;
|
src = pkgs.emptyDirectory;
|
||||||
@@ -40,7 +39,6 @@ pkgs.testers.runNixOSTest {
|
|||||||
machine.succeed("su -- danklinux -c 'dms --help >/dev/null'")
|
machine.succeed("su -- danklinux -c 'dms --help >/dev/null'")
|
||||||
machine.succeed("test -d /etc/xdg/quickshell/dms-plugins")
|
machine.succeed("test -d /etc/xdg/quickshell/dms-plugins")
|
||||||
machine.succeed("test -f /run/current-system/sw/lib/systemd/user/dms.service")
|
machine.succeed("test -f /run/current-system/sw/lib/systemd/user/dms.service")
|
||||||
machine.succeed("grep -q 'lib/security/pam_u2f.so cue' /etc/pam.d/dankshell-u2f")
|
|
||||||
|
|
||||||
payload = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
|
payload = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
|
||||||
t.assertIn("summary", payload)
|
t.assertIn("summary", payload)
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
The Wolverine
|
Marble Tabby
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ Singleton {
|
|||||||
|
|
||||||
property var appUsageRanking: {}
|
property var appUsageRanking: {}
|
||||||
property bool _saving: false
|
property bool _saving: false
|
||||||
property bool _hasLoaded: false
|
|
||||||
property bool _parseError: false
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -23,21 +21,15 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseSettings(content) {
|
function parseSettings(content) {
|
||||||
_parseError = false;
|
|
||||||
try {
|
try {
|
||||||
if (content && content.trim()) {
|
if (content && content.trim()) {
|
||||||
var settings = JSON.parse(content);
|
var settings = JSON.parse(content);
|
||||||
appUsageRanking = settings.appUsageRanking || {};
|
appUsageRanking = settings.appUsageRanking || {};
|
||||||
}
|
}
|
||||||
_hasLoaded = true;
|
} catch (e) {}
|
||||||
} catch (e) {
|
|
||||||
_parseError = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
if (_parseError || !_hasLoaded)
|
|
||||||
return;
|
|
||||||
settingsFile.setText(JSON.stringify({
|
settingsFile.setText(JSON.stringify({
|
||||||
"appUsageRanking": appUsageRanking
|
"appUsageRanking": appUsageRanking
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Singleton {
|
|||||||
id: root
|
id: root
|
||||||
readonly property var log: Log.scoped("CacheData")
|
readonly property var log: Log.scoped("CacheData")
|
||||||
|
|
||||||
readonly property int cacheConfigVersion: 2
|
readonly property int cacheConfigVersion: 1
|
||||||
|
|
||||||
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
||||||
|
|
||||||
@@ -19,21 +19,10 @@ Singleton {
|
|||||||
readonly property string _stateDir: Paths.strip(_stateUrl)
|
readonly property string _stateDir: Paths.strip(_stateUrl)
|
||||||
|
|
||||||
property bool _loading: false
|
property bool _loading: false
|
||||||
property bool _hasLoaded: false
|
|
||||||
property int _loadedCacheVersion: 0
|
|
||||||
|
|
||||||
readonly property var _pinKeys: ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"]
|
|
||||||
readonly property var _dataKeys: ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings"].concat(_pinKeys)
|
|
||||||
|
|
||||||
property string wallpaperLastPath: ""
|
property string wallpaperLastPath: ""
|
||||||
property string profileLastPath: ""
|
property string profileLastPath: ""
|
||||||
|
|
||||||
property var brightnessDevicePins: ({})
|
|
||||||
property var wifiNetworkPins: ({})
|
|
||||||
property var bluetoothDevicePins: ({})
|
|
||||||
property var audioInputDevicePins: ({})
|
|
||||||
property var audioOutputDevicePins: ({})
|
|
||||||
|
|
||||||
property var fileBrowserSettings: ({
|
property var fileBrowserSettings: ({
|
||||||
"wallpaper": {
|
"wallpaper": {
|
||||||
"lastPath": "",
|
"lastPath": "",
|
||||||
@@ -93,46 +82,8 @@ Singleton {
|
|||||||
|
|
||||||
function loadCache() {
|
function loadCache() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
try {
|
|
||||||
parseCache(cacheFile.text());
|
parseCache(cacheFile.text());
|
||||||
} finally {
|
|
||||||
_loading = false;
|
_loading = false;
|
||||||
_hasLoaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function set(key, value) {
|
|
||||||
if (_dataKeys.indexOf(key) < 0) {
|
|
||||||
log.warn("Unknown cache key:", key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
root[key] = value;
|
|
||||||
saveCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
function migratePins(pins) {
|
|
||||||
if (!pins)
|
|
||||||
return;
|
|
||||||
if (!_hasLoaded)
|
|
||||||
loadCache();
|
|
||||||
if (_loadedCacheVersion >= cacheConfigVersion)
|
|
||||||
return;
|
|
||||||
|
|
||||||
let migrated = false;
|
|
||||||
for (const key of _pinKeys) {
|
|
||||||
const legacy = pins[key];
|
|
||||||
if (!legacy || Object.keys(legacy).length === 0)
|
|
||||||
continue;
|
|
||||||
if (Object.keys(root[key] || {}).length > 0)
|
|
||||||
continue;
|
|
||||||
root[key] = legacy;
|
|
||||||
migrated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!migrated)
|
|
||||||
return;
|
|
||||||
log.info("Migrated device pins from settings.json");
|
|
||||||
saveCache();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCache(content) {
|
function parseCache(content) {
|
||||||
@@ -140,7 +91,6 @@ Singleton {
|
|||||||
try {
|
try {
|
||||||
if (content && content.trim()) {
|
if (content && content.trim()) {
|
||||||
const cache = JSON.parse(content);
|
const cache = JSON.parse(content);
|
||||||
_loadedCacheVersion = cache.configVersion || 0;
|
|
||||||
|
|
||||||
wallpaperLastPath = cache.wallpaperLastPath !== undefined ? cache.wallpaperLastPath : "";
|
wallpaperLastPath = cache.wallpaperLastPath !== undefined ? cache.wallpaperLastPath : "";
|
||||||
profileLastPath = cache.profileLastPath !== undefined ? cache.profileLastPath : "";
|
profileLastPath = cache.profileLastPath !== undefined ? cache.profileLastPath : "";
|
||||||
@@ -176,10 +126,6 @@ Singleton {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of _pinKeys) {
|
|
||||||
root[key] = cache[key] !== undefined ? cache[key] : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cache.configVersion === undefined) {
|
if (cache.configVersion === undefined) {
|
||||||
migrateFromUndefinedToV1(cache);
|
migrateFromUndefinedToV1(cache);
|
||||||
cleanupUnusedKeys();
|
cleanupUnusedKeys();
|
||||||
@@ -196,16 +142,12 @@ Singleton {
|
|||||||
function saveCache() {
|
function saveCache() {
|
||||||
if (_loading)
|
if (_loading)
|
||||||
return;
|
return;
|
||||||
const data = {
|
cacheFile.setText(JSON.stringify({
|
||||||
"wallpaperLastPath": wallpaperLastPath,
|
"wallpaperLastPath": wallpaperLastPath,
|
||||||
"profileLastPath": profileLastPath,
|
"profileLastPath": profileLastPath,
|
||||||
"fileBrowserSettings": fileBrowserSettings,
|
"fileBrowserSettings": fileBrowserSettings,
|
||||||
"configVersion": cacheConfigVersion
|
"configVersion": cacheConfigVersion
|
||||||
};
|
}, null, 2));
|
||||||
for (const key of _pinKeys) {
|
|
||||||
data[key] = root[key];
|
|
||||||
}
|
|
||||||
cacheFile.setText(JSON.stringify(data, null, 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateFromUndefinedToV1(cache) {
|
function migrateFromUndefinedToV1(cache) {
|
||||||
@@ -213,7 +155,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanupUnusedKeys() {
|
function cleanupUnusedKeys() {
|
||||||
const validKeys = _dataKeys.concat(["configVersion"]);
|
const validKeys = ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings", "configVersion"];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = cacheFile.text();
|
const content = cacheFile.text();
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ const DMS_ACTIONS = [
|
|||||||
{ id: "spawn dms ipc call keybinds open niri", label: "Keybinds Cheatsheet: Open", compositor: "niri" },
|
{ id: "spawn dms ipc call keybinds open niri", label: "Keybinds Cheatsheet: Open", compositor: "niri" },
|
||||||
{ id: "spawn dms ipc call keybinds close", label: "Keybinds Cheatsheet: Close" },
|
{ id: "spawn dms ipc call keybinds close", label: "Keybinds Cheatsheet: Close" },
|
||||||
{ id: "spawn dms ipc call lock lock", label: "Lock Screen" },
|
{ id: "spawn dms ipc call lock lock", label: "Lock Screen" },
|
||||||
{ id: "spawn dms ipc call lock lockAndOutputsOff", label: "Lock Screen & Outputs Off" },
|
|
||||||
{ id: "spawn dms ipc call lock demo", label: "Lock Screen: Demo" },
|
{ id: "spawn dms ipc call lock demo", label: "Lock Screen: Demo" },
|
||||||
{ id: "spawn dms ipc call inhibit toggle", label: "Idle Inhibit: Toggle" },
|
{ id: "spawn dms ipc call inhibit toggle", label: "Idle Inhibit: Toggle" },
|
||||||
{ id: "spawn dms ipc call inhibit enable", label: "Idle Inhibit: Enable" },
|
{ id: "spawn dms ipc call inhibit enable", label: "Idle Inhibit: Enable" },
|
||||||
|
|||||||
@@ -65,34 +65,15 @@ Singleton {
|
|||||||
|
|
||||||
function _openPopout(popout) {
|
function _openPopout(popout) {
|
||||||
if (popout.dashVisible !== undefined) {
|
if (popout.dashVisible !== undefined) {
|
||||||
let flagStayedTrue = popout.dashVisible === true;
|
if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing)
|
||||||
if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing) {
|
|
||||||
popout.dashVisible = false;
|
popout.dashVisible = false;
|
||||||
flagStayedTrue = false;
|
|
||||||
}
|
|
||||||
popout.dashVisible = true;
|
popout.dashVisible = true;
|
||||||
// Flag already true (e.g. retargeting to another monitor) won't re-fire
|
|
||||||
// the change handler, so drive the surface open explicitly.
|
|
||||||
if (flagStayedTrue)
|
|
||||||
_ensureSurfaceOpen(popout);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (popout.notificationHistoryVisible !== undefined) {
|
if (popout.notificationHistoryVisible !== undefined) {
|
||||||
const flagStayedTrue = popout.notificationHistoryVisible === true;
|
|
||||||
popout.notificationHistoryVisible = true;
|
popout.notificationHistoryVisible = true;
|
||||||
if (flagStayedTrue)
|
|
||||||
_ensureSurfaceOpen(popout);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_ensureSurfaceOpen(popout);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ensureSurfaceOpen(popout) {
|
|
||||||
if (typeof popout.present === "function") {
|
|
||||||
popout.present();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof popout.open === "function")
|
|
||||||
popout.open();
|
popout.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Singleton {
|
|||||||
id: root
|
id: root
|
||||||
readonly property var log: Log.scoped("SettingsData")
|
readonly property var log: Log.scoped("SettingsData")
|
||||||
|
|
||||||
readonly property int settingsConfigVersion: 13
|
readonly property int settingsConfigVersion: 12
|
||||||
|
|
||||||
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
||||||
|
|
||||||
@@ -240,8 +240,6 @@ Singleton {
|
|||||||
onBlurForegroundLayersChanged: saveSettings()
|
onBlurForegroundLayersChanged: saveSettings()
|
||||||
property real blurLayerOutlineOpacity: 0.12
|
property real blurLayerOutlineOpacity: 0.12
|
||||||
onBlurLayerOutlineOpacityChanged: saveSettings()
|
onBlurLayerOutlineOpacityChanged: saveSettings()
|
||||||
property bool blurBorderEnabled: true
|
|
||||||
onBlurBorderEnabledChanged: saveSettings()
|
|
||||||
property string blurBorderColor: "outline"
|
property string blurBorderColor: "outline"
|
||||||
onBlurBorderColorChanged: saveSettings()
|
onBlurBorderColorChanged: saveSettings()
|
||||||
property string blurBorderCustomColor: "#ffffff"
|
property string blurBorderCustomColor: "#ffffff"
|
||||||
@@ -773,6 +771,11 @@ Singleton {
|
|||||||
property bool fadeToDpmsEnabled: true
|
property bool fadeToDpmsEnabled: true
|
||||||
property int fadeToDpmsGracePeriod: 5
|
property int fadeToDpmsGracePeriod: 5
|
||||||
property string launchPrefix: ""
|
property string launchPrefix: ""
|
||||||
|
property var brightnessDevicePins: ({})
|
||||||
|
property var wifiNetworkPins: ({})
|
||||||
|
property var bluetoothDevicePins: ({})
|
||||||
|
property var audioInputDevicePins: ({})
|
||||||
|
property var audioOutputDevicePins: ({})
|
||||||
|
|
||||||
property bool gtkThemingEnabled: false
|
property bool gtkThemingEnabled: false
|
||||||
property bool qtThemingEnabled: false
|
property bool qtThemingEnabled: false
|
||||||
@@ -856,7 +859,6 @@ Singleton {
|
|||||||
property bool notificationOverlayEnabled: false
|
property bool notificationOverlayEnabled: false
|
||||||
property bool notificationPopupShadowEnabled: true
|
property bool notificationPopupShadowEnabled: true
|
||||||
property bool notificationPopupPrivacyMode: false
|
property bool notificationPopupPrivacyMode: false
|
||||||
property bool notificationForegroundLayers: true
|
|
||||||
property int overviewRows: 2
|
property int overviewRows: 2
|
||||||
property int overviewColumns: 5
|
property int overviewColumns: 5
|
||||||
property real overviewScale: 0.16
|
property real overviewScale: 0.16
|
||||||
@@ -893,12 +895,6 @@ Singleton {
|
|||||||
readonly property bool greeterU2fReady: Processes.greeterU2fReady
|
readonly property bool greeterU2fReady: Processes.greeterU2fReady
|
||||||
readonly property string greeterU2fReason: Processes.greeterU2fReason
|
readonly property string greeterU2fReason: Processes.greeterU2fReason
|
||||||
readonly property string greeterU2fSource: Processes.greeterU2fSource
|
readonly property string greeterU2fSource: Processes.greeterU2fSource
|
||||||
property string lockPamPath: ""
|
|
||||||
property bool lockPamInlineFprint: false
|
|
||||||
property bool lockPamInlineU2f: false
|
|
||||||
property bool lockPamExternallyManaged: false
|
|
||||||
property string lockU2fPamPath: ""
|
|
||||||
property bool greeterPamExternallyManaged: false
|
|
||||||
property string lockScreenInactiveColor: "#000000"
|
property string lockScreenInactiveColor: "#000000"
|
||||||
property int lockScreenNotificationMode: 0
|
property int lockScreenNotificationMode: 0
|
||||||
property bool lockScreenVideoEnabled: false
|
property bool lockScreenVideoEnabled: false
|
||||||
@@ -1736,7 +1732,6 @@ Singleton {
|
|||||||
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
|
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
|
||||||
|
|
||||||
const oldVersion = obj?.configVersion ?? 0;
|
const oldVersion = obj?.configVersion ?? 0;
|
||||||
const legacyPins = oldVersion < 13 ? Store.extractPins(obj) : null;
|
|
||||||
if (oldVersion < settingsConfigVersion) {
|
if (oldVersion < settingsConfigVersion) {
|
||||||
const migrated = Store.migrateToVersion(obj, settingsConfigVersion);
|
const migrated = Store.migrateToVersion(obj, settingsConfigVersion);
|
||||||
if (migrated) {
|
if (migrated) {
|
||||||
@@ -1744,8 +1739,6 @@ Singleton {
|
|||||||
obj = migrated;
|
obj = migrated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (legacyPins)
|
|
||||||
Qt.callLater(() => CacheData.migratePins(legacyPins));
|
|
||||||
|
|
||||||
if (obj?.lockScreenActiveMonitor !== undefined) {
|
if (obj?.lockScreenActiveMonitor !== undefined) {
|
||||||
var oldVal = obj.lockScreenActiveMonitor;
|
var oldVal = obj.lockScreenActiveMonitor;
|
||||||
|
|||||||
@@ -580,15 +580,11 @@ Singleton {
|
|||||||
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
|
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
|
||||||
readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers
|
readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers
|
||||||
readonly property bool transparentBlurLayers: BlurService.enabled && !foregroundLayers
|
readonly property bool transparentBlurLayers: BlurService.enabled && !foregroundLayers
|
||||||
readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true)
|
|
||||||
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
|
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
|
||||||
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
|
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
|
||||||
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
||||||
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
||||||
readonly property color nestedSurface: floatingSurfaceHigh
|
readonly property color nestedSurface: floatingSurfaceHigh
|
||||||
readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
|
||||||
readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
|
||||||
readonly property color notificationNestedSurface: notificationFloatingSurfaceHigh
|
|
||||||
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
|
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
|
||||||
readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
|
readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
|
||||||
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
|
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ Singleton {
|
|||||||
readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : ""
|
readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : ""
|
||||||
readonly property bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== ""
|
readonly property bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== ""
|
||||||
readonly property bool lockU2fCustomConfigDetected: pamModuleEnabled(dankshellU2fPamText, "pam_u2f")
|
readonly property bool lockU2fCustomConfigDetected: pamModuleEnabled(dankshellU2fPamText, "pam_u2f")
|
||||||
readonly property bool lockU2fCustomSourceDetected: (settingsRoot?.lockU2fPamPath || "") !== "" && customU2fPamWatcher.loaded
|
|
||||||
readonly property bool greeterPamHasFprint: greeterPamStackHasModule("pam_fprintd")
|
readonly property bool greeterPamHasFprint: greeterPamStackHasModule("pam_fprintd")
|
||||||
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
|
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
|
||||||
|
|
||||||
@@ -177,7 +176,7 @@ Singleton {
|
|||||||
readonly property bool lockU2fReady: {
|
readonly property bool lockU2fReady: {
|
||||||
if (forcedU2fAvailable !== null)
|
if (forcedU2fAvailable !== null)
|
||||||
return forcedU2fAvailable;
|
return forcedU2fAvailable;
|
||||||
return lockU2fCustomSourceDetected || lockU2fCustomConfigDetected || homeU2fKeysDetected;
|
return lockU2fCustomConfigDetected || homeU2fKeysDetected;
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property bool lockU2fCanEnable: {
|
readonly property bool lockU2fCanEnable: {
|
||||||
@@ -247,10 +246,6 @@ Singleton {
|
|||||||
readonly property var _pamProbeCommand: ["sh", "-c", "for module in pam_fprintd.so pam_u2f.so; do found=false; for dir in /usr/lib64/security /usr/lib/security /lib/security /lib/x86_64-linux-gnu/security /usr/lib/x86_64-linux-gnu/security /usr/lib/aarch64-linux-gnu/security /run/current-system/sw/lib/security; do if [ -f \"$dir/$module\" ]; then found=true; break; fi; done; printf '%s:%s\\n' \"$module\" \"$found\"; done"]
|
readonly property var _pamProbeCommand: ["sh", "-c", "for module in pam_fprintd.so pam_u2f.so; do found=false; for dir in /usr/lib64/security /usr/lib/security /lib/security /lib/x86_64-linux-gnu/security /usr/lib/x86_64-linux-gnu/security /usr/lib/aarch64-linux-gnu/security /run/current-system/sw/lib/security; do if [ -f \"$dir/$module\" ]; then found=true; break; fi; done; printf '%s:%s\\n' \"$module\" \"$found\"; done"]
|
||||||
|
|
||||||
function detectAuthCapabilities() {
|
function detectAuthCapabilities() {
|
||||||
// FileView cannot watch paths that do not exist yet, so reload the U2F PAM
|
|
||||||
dankshellU2fPamWatcher.reload();
|
|
||||||
u2fKeysWatcher.reload();
|
|
||||||
|
|
||||||
if (forcedFprintAvailable === null) {
|
if (forcedFprintAvailable === null) {
|
||||||
fingerprintProbeFinalized = false;
|
fingerprintProbeFinalized = false;
|
||||||
Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => {
|
Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => {
|
||||||
@@ -727,22 +722,14 @@ Singleton {
|
|||||||
FileView {
|
FileView {
|
||||||
id: dankshellU2fPamWatcher
|
id: dankshellU2fPamWatcher
|
||||||
path: "/etc/pam.d/dankshell-u2f"
|
path: "/etc/pam.d/dankshell-u2f"
|
||||||
watchChanges: true
|
|
||||||
printErrors: false
|
printErrors: false
|
||||||
onLoaded: root.dankshellU2fPamText = text()
|
onLoaded: root.dankshellU2fPamText = text()
|
||||||
onLoadFailed: root.dankshellU2fPamText = ""
|
onLoadFailed: root.dankshellU2fPamText = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
FileView {
|
|
||||||
id: customU2fPamWatcher
|
|
||||||
path: root.settingsRoot?.lockU2fPamPath || ""
|
|
||||||
printErrors: false
|
|
||||||
}
|
|
||||||
|
|
||||||
FileView {
|
FileView {
|
||||||
id: u2fKeysWatcher
|
id: u2fKeysWatcher
|
||||||
path: root.u2fKeysPath
|
path: root.u2fKeysPath
|
||||||
watchChanges: true
|
|
||||||
printErrors: false
|
printErrors: false
|
||||||
onLoaded: root.u2fKeysText = text()
|
onLoaded: root.u2fKeysText = text()
|
||||||
onLoadFailed: root.u2fKeysText = ""
|
onLoadFailed: root.u2fKeysText = ""
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ var SPEC = {
|
|||||||
blurEnabled: { def: false },
|
blurEnabled: { def: false },
|
||||||
blurForegroundLayers: { def: true },
|
blurForegroundLayers: { def: true },
|
||||||
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
|
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
|
||||||
blurBorderEnabled: { def: true },
|
|
||||||
blurBorderColor: { def: "outline" },
|
blurBorderColor: { def: "outline" },
|
||||||
blurBorderCustomColor: { def: "#ffffff" },
|
blurBorderCustomColor: { def: "#ffffff" },
|
||||||
blurBorderOpacity: { def: 0.35, coerce: percentToUnit },
|
blurBorderOpacity: { def: 0.35, coerce: percentToUnit },
|
||||||
@@ -234,7 +233,6 @@ var SPEC = {
|
|||||||
greeterLockDateFormat: { def: "", onChange: "markGreeterSyncPending" },
|
greeterLockDateFormat: { def: "", onChange: "markGreeterSyncPending" },
|
||||||
greeterFontFamily: { def: "", onChange: "markGreeterSyncPending" },
|
greeterFontFamily: { def: "", onChange: "markGreeterSyncPending" },
|
||||||
greeterWallpaperFillMode: { def: "", onChange: "markGreeterSyncPending" },
|
greeterWallpaperFillMode: { def: "", onChange: "markGreeterSyncPending" },
|
||||||
greeterPamExternallyManaged: { def: false, onChange: "markGreeterSyncPending" },
|
|
||||||
greeterSyncPending: { def: false },
|
greeterSyncPending: { def: false },
|
||||||
greeterSyncBaseline: { def: {} },
|
greeterSyncBaseline: { def: {} },
|
||||||
mediaSize: { def: 1 },
|
mediaSize: { def: 1 },
|
||||||
@@ -354,6 +352,11 @@ var SPEC = {
|
|||||||
fadeToDpmsEnabled: { def: true },
|
fadeToDpmsEnabled: { def: true },
|
||||||
fadeToDpmsGracePeriod: { def: 5 },
|
fadeToDpmsGracePeriod: { def: 5 },
|
||||||
launchPrefix: { def: "" },
|
launchPrefix: { def: "" },
|
||||||
|
brightnessDevicePins: { def: {} },
|
||||||
|
wifiNetworkPins: { def: {} },
|
||||||
|
bluetoothDevicePins: { def: {} },
|
||||||
|
audioInputDevicePins: { def: {} },
|
||||||
|
audioOutputDevicePins: { def: {} },
|
||||||
|
|
||||||
gtkThemingEnabled: { def: false, onChange: "regenSystemThemes" },
|
gtkThemingEnabled: { def: false, onChange: "regenSystemThemes" },
|
||||||
qtThemingEnabled: { def: false, onChange: "regenSystemThemes" },
|
qtThemingEnabled: { def: false, onChange: "regenSystemThemes" },
|
||||||
@@ -433,7 +436,6 @@ var SPEC = {
|
|||||||
notificationOverlayEnabled: { def: false },
|
notificationOverlayEnabled: { def: false },
|
||||||
notificationPopupShadowEnabled: { def: true },
|
notificationPopupShadowEnabled: { def: true },
|
||||||
notificationPopupPrivacyMode: { def: false },
|
notificationPopupPrivacyMode: { def: false },
|
||||||
notificationForegroundLayers: { def: true },
|
|
||||||
overviewRows: { def: 2, persist: false },
|
overviewRows: { def: 2, persist: false },
|
||||||
overviewColumns: { def: 5, persist: false },
|
overviewColumns: { def: 5, persist: false },
|
||||||
overviewScale: { def: 0.16, persist: false },
|
overviewScale: { def: 0.16, persist: false },
|
||||||
@@ -453,11 +455,6 @@ var SPEC = {
|
|||||||
maxFprintTries: { def: 15 },
|
maxFprintTries: { def: 15 },
|
||||||
enableU2f: { def: false, onChange: "scheduleAuthApply" },
|
enableU2f: { def: false, onChange: "scheduleAuthApply" },
|
||||||
u2fMode: { def: "or" },
|
u2fMode: { def: "or" },
|
||||||
lockPamPath: { def: "" },
|
|
||||||
lockPamInlineFprint: { def: false },
|
|
||||||
lockPamInlineU2f: { def: false },
|
|
||||||
lockPamExternallyManaged: { def: false },
|
|
||||||
lockU2fPamPath: { def: "" },
|
|
||||||
lockScreenInactiveColor: { def: "#000000" },
|
lockScreenInactiveColor: { def: "#000000" },
|
||||||
lockScreenNotificationMode: { def: 0 },
|
lockScreenNotificationMode: { def: 0 },
|
||||||
lockScreenVideoEnabled: { def: false },
|
lockScreenVideoEnabled: { def: false },
|
||||||
|
|||||||
@@ -2,21 +2,6 @@
|
|||||||
|
|
||||||
.import "./SettingsSpec.js" as SpecModule
|
.import "./SettingsSpec.js" as SpecModule
|
||||||
|
|
||||||
var PIN_KEYS = ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"];
|
|
||||||
|
|
||||||
function extractPins(obj) {
|
|
||||||
if (!obj) return null;
|
|
||||||
|
|
||||||
var pins = null;
|
|
||||||
for (var i = 0; i < PIN_KEYS.length; i++) {
|
|
||||||
var value = obj[PIN_KEYS[i]];
|
|
||||||
if (!value || Object.keys(value).length === 0) continue;
|
|
||||||
if (!pins) pins = {};
|
|
||||||
pins[PIN_KEYS[i]] = value;
|
|
||||||
}
|
|
||||||
return pins;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parse(root, jsonObj) {
|
function parse(root, jsonObj) {
|
||||||
var SPEC = SpecModule.SPEC;
|
var SPEC = SpecModule.SPEC;
|
||||||
|
|
||||||
@@ -278,17 +263,6 @@ function migrateToVersion(obj, targetVersion) {
|
|||||||
settings.configVersion = 12;
|
settings.configVersion = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentVersion < 13) {
|
|
||||||
console.info("Migrating settings from version", currentVersion, "to version 13");
|
|
||||||
console.info("Moving device and network pins to cache.json");
|
|
||||||
|
|
||||||
for (var p = 0; p < PIN_KEYS.length; p++) {
|
|
||||||
delete settings[PIN_KEYS[p]];
|
|
||||||
}
|
|
||||||
|
|
||||||
settings.configVersion = 13;
|
|
||||||
}
|
|
||||||
|
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+67
-62
@@ -1,5 +1,3 @@
|
|||||||
pragma ComponentBehavior: Bound
|
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import qs.Common
|
import qs.Common
|
||||||
@@ -55,7 +53,6 @@ Item {
|
|||||||
delegate: Loader {
|
delegate: Loader {
|
||||||
id: fadeWindowLoader
|
id: fadeWindowLoader
|
||||||
required property var modelData
|
required property var modelData
|
||||||
readonly property FadeToLockWindow loadedWindow: item as FadeToLockWindow
|
|
||||||
active: SettingsData.fadeToLockEnabled
|
active: SettingsData.fadeToLockEnabled
|
||||||
asynchronous: false
|
asynchronous: false
|
||||||
|
|
||||||
@@ -67,29 +64,29 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onFadeCancelled: {
|
onFadeCancelled: {
|
||||||
root.log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
|
log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: IdleService
|
target: IdleService
|
||||||
enabled: fadeWindowLoader.loadedWindow !== null
|
enabled: fadeWindowLoader.item !== null
|
||||||
|
|
||||||
function onFadeToLockRequested() {
|
function onFadeToLockRequested() {
|
||||||
if (fadeWindowLoader.loadedWindow) {
|
if (fadeWindowLoader.item) {
|
||||||
fadeWindowLoader.loadedWindow.startFade();
|
fadeWindowLoader.item.startFade();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCancelFadeToLock() {
|
function onCancelFadeToLock() {
|
||||||
if (fadeWindowLoader.loadedWindow) {
|
if (fadeWindowLoader.item) {
|
||||||
fadeWindowLoader.loadedWindow.cancelFade();
|
fadeWindowLoader.item.cancelFade();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDismissFadeToLock() {
|
function onDismissFadeToLock() {
|
||||||
if (fadeWindowLoader.loadedWindow) {
|
if (fadeWindowLoader.item) {
|
||||||
fadeWindowLoader.loadedWindow.dismiss();
|
fadeWindowLoader.item.dismiss();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +99,6 @@ Item {
|
|||||||
delegate: Loader {
|
delegate: Loader {
|
||||||
id: fadeDpmsWindowLoader
|
id: fadeDpmsWindowLoader
|
||||||
required property var modelData
|
required property var modelData
|
||||||
readonly property FadeToDpmsWindow loadedWindow: item as FadeToDpmsWindow
|
|
||||||
active: SettingsData.fadeToDpmsEnabled
|
active: SettingsData.fadeToDpmsEnabled
|
||||||
asynchronous: false
|
asynchronous: false
|
||||||
|
|
||||||
@@ -114,38 +110,30 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onFadeCancelled: {
|
onFadeCancelled: {
|
||||||
root.log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
|
log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: IdleService
|
target: IdleService
|
||||||
enabled: fadeDpmsWindowLoader.loadedWindow !== null
|
enabled: fadeDpmsWindowLoader.item !== null
|
||||||
|
|
||||||
function onFadeToDpmsRequested() {
|
function onFadeToDpmsRequested() {
|
||||||
if (fadeDpmsWindowLoader.loadedWindow) {
|
if (fadeDpmsWindowLoader.item) {
|
||||||
fadeDpmsWindowLoader.loadedWindow.startFade();
|
fadeDpmsWindowLoader.item.startFade();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCancelFadeToDpms() {
|
function onCancelFadeToDpms() {
|
||||||
if (fadeDpmsWindowLoader.loadedWindow) {
|
if (fadeDpmsWindowLoader.item) {
|
||||||
fadeDpmsWindowLoader.loadedWindow.cancelFade();
|
fadeDpmsWindowLoader.item.cancelFade();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRequestMonitorOn() {
|
function onRequestMonitorOn() {
|
||||||
if (!fadeDpmsWindowLoader.loadedWindow)
|
if (!fadeDpmsWindowLoader.item)
|
||||||
return;
|
return;
|
||||||
fadeDpmsWindowLoader.loadedWindow.cancelFade();
|
fadeDpmsWindowLoader.item.cancelFade();
|
||||||
}
|
|
||||||
|
|
||||||
function onMonitorsOffChanged() {
|
|
||||||
if (IdleService.monitorsOff)
|
|
||||||
return;
|
|
||||||
if (!fadeDpmsWindowLoader.loadedWindow)
|
|
||||||
return;
|
|
||||||
fadeDpmsWindowLoader.loadedWindow.dismiss();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +213,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
dockEnabled = true;
|
dockRecreateDebounce.start();
|
||||||
loginSoundTimer.start();
|
loginSoundTimer.start();
|
||||||
osdStartupTimer.start();
|
osdStartupTimer.start();
|
||||||
|
|
||||||
@@ -363,7 +351,9 @@ Item {
|
|||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications")
|
model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications")
|
||||||
|
|
||||||
delegate: NotificationPopupManager {}
|
delegate: NotificationPopupManager {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyLoader {
|
LazyLoader {
|
||||||
@@ -397,7 +387,6 @@ Item {
|
|||||||
LazyLoader {
|
LazyLoader {
|
||||||
id: wifiPasswordModalLoader
|
id: wifiPasswordModalLoader
|
||||||
active: false
|
active: false
|
||||||
readonly property WifiPasswordModal loadedModal: item as WifiPasswordModal
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
PopoutService.wifiPasswordModalLoader = wifiPasswordModalLoader;
|
PopoutService.wifiPasswordModalLoader = wifiPasswordModalLoader;
|
||||||
@@ -432,7 +421,6 @@ Item {
|
|||||||
LazyLoader {
|
LazyLoader {
|
||||||
id: polkitAuthModalLoader
|
id: polkitAuthModalLoader
|
||||||
active: false
|
active: false
|
||||||
readonly property PolkitAuthModal loadedModal: item as PolkitAuthModal
|
|
||||||
|
|
||||||
PolkitAuthModal {
|
PolkitAuthModal {
|
||||||
id: polkitAuthModal
|
id: polkitAuthModal
|
||||||
@@ -451,8 +439,8 @@ Item {
|
|||||||
if (PopoutService.systemUpdatePopout?.shouldBeVisible)
|
if (PopoutService.systemUpdatePopout?.shouldBeVisible)
|
||||||
return;
|
return;
|
||||||
polkitAuthModalLoader.active = true;
|
polkitAuthModalLoader.active = true;
|
||||||
if (polkitAuthModalLoader.loadedModal)
|
if (polkitAuthModalLoader.item)
|
||||||
polkitAuthModalLoader.loadedModal.show();
|
polkitAuthModalLoader.item.show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,20 +459,20 @@ Item {
|
|||||||
target: NetworkService
|
target: NetworkService
|
||||||
|
|
||||||
function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) {
|
function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) {
|
||||||
const alreadyShown = wifiPasswordModalLoader.loadedModal && wifiPasswordModalLoader.loadedModal.shouldBeVisible;
|
const alreadyShown = wifiPasswordModalLoader.item && wifiPasswordModalLoader.item.shouldBeVisible;
|
||||||
if (alreadyShown && token === root.lastCredentialsToken)
|
if (alreadyShown && token === lastCredentialsToken)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
wifiPasswordModalLoader.active = true;
|
wifiPasswordModalLoader.active = true;
|
||||||
if (!wifiPasswordModalLoader.loadedModal)
|
if (!wifiPasswordModalLoader.item)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (alreadyShown && root.lastCredentialsToken !== "" && root.lastCredentialsToken !== token)
|
if (alreadyShown && lastCredentialsToken !== "" && lastCredentialsToken !== token)
|
||||||
NetworkService.cancelCredentials(root.lastCredentialsToken);
|
NetworkService.cancelCredentials(lastCredentialsToken);
|
||||||
|
|
||||||
root.lastCredentialsToken = token;
|
lastCredentialsToken = token;
|
||||||
root.lastCredentialsTime = Date.now();
|
lastCredentialsTime = Date.now();
|
||||||
wifiPasswordModalLoader.loadedModal.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
|
wifiPasswordModalLoader.item.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,10 +769,10 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onAppPickerRequested(data) {
|
function onAppPickerRequested(data) {
|
||||||
root.log.debug("App picker requested with data:", JSON.stringify(data));
|
log.debug("App picker requested with data:", JSON.stringify(data));
|
||||||
|
|
||||||
if (!data || !data.target) {
|
if (!data || !data.target) {
|
||||||
root.log.warn("Invalid app picker request data");
|
log.warn("Invalid app picker request data");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,6 +889,7 @@ Item {
|
|||||||
|
|
||||||
delegate: DankSlideout {
|
delegate: DankSlideout {
|
||||||
id: notepadSlideout
|
id: notepadSlideout
|
||||||
|
modelData: item
|
||||||
title: I18n.tr("Notepad")
|
title: I18n.tr("Notepad")
|
||||||
slideoutWidth: 480
|
slideoutWidth: 480
|
||||||
expandable: true
|
expandable: true
|
||||||
@@ -996,8 +985,8 @@ Item {
|
|||||||
onSwitchUserRequested: {
|
onSwitchUserRequested: {
|
||||||
switchUserModalLoader.active = true;
|
switchUserModalLoader.active = true;
|
||||||
Qt.callLater(() => {
|
Qt.callLater(() => {
|
||||||
if (switchUserModalLoader.loadedModal)
|
if (switchUserModalLoader.item)
|
||||||
switchUserModalLoader.loadedModal.showFromPowerMenu();
|
switchUserModalLoader.item.showFromPowerMenu();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1011,7 +1000,6 @@ Item {
|
|||||||
id: switchUserModalLoader
|
id: switchUserModalLoader
|
||||||
|
|
||||||
active: false
|
active: false
|
||||||
readonly property SwitchUserModal loadedModal: item as SwitchUserModal
|
|
||||||
|
|
||||||
SwitchUserModal {
|
SwitchUserModal {
|
||||||
id: switchUserModal
|
id: switchUserModal
|
||||||
@@ -1067,6 +1055,7 @@ Item {
|
|||||||
model: SettingsData.getFilteredScreens("toast")
|
model: SettingsData.getFilteredScreens("toast")
|
||||||
|
|
||||||
delegate: Toast {
|
delegate: Toast {
|
||||||
|
modelData: item
|
||||||
visible: ToastService.toastVisible
|
visible: ToastService.toastVisible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1081,55 +1070,73 @@ Item {
|
|||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: VolumeOSD {}
|
delegate: VolumeOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: MediaVolumeOSD {}
|
delegate: MediaVolumeOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: MediaPlaybackOSD {}
|
delegate: MediaPlaybackOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: MicVolumeOSD {}
|
delegate: MicVolumeOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: BrightnessOSD {}
|
delegate: BrightnessOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: IdleInhibitorOSD {}
|
delegate: IdleInhibitorOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
|
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
|
||||||
|
|
||||||
delegate: PowerProfileOSD {}
|
delegate: PowerProfileOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: CapsLockOSD {}
|
delegate: CapsLockOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: AudioOutputOSD {}
|
delegate: AudioOutputOSD {
|
||||||
|
modelData: item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1146,7 +1153,6 @@ Item {
|
|||||||
Loader {
|
Loader {
|
||||||
id: greeterLoader
|
id: greeterLoader
|
||||||
active: false
|
active: false
|
||||||
readonly property GreeterModal loadedModal: item as GreeterModal
|
|
||||||
sourceComponent: GreeterModal {
|
sourceComponent: GreeterModal {
|
||||||
onGreeterCompleted: greeterLoader.active = false
|
onGreeterCompleted: greeterLoader.active = false
|
||||||
Component.onCompleted: show()
|
Component.onCompleted: show()
|
||||||
@@ -1155,8 +1161,8 @@ Item {
|
|||||||
Connections {
|
Connections {
|
||||||
target: FirstLaunchService
|
target: FirstLaunchService
|
||||||
function onGreeterRequested() {
|
function onGreeterRequested() {
|
||||||
if (greeterLoader.active && greeterLoader.loadedModal) {
|
if (greeterLoader.active && greeterLoader.item) {
|
||||||
greeterLoader.loadedModal.show();
|
greeterLoader.item.show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
greeterLoader.active = true;
|
greeterLoader.active = true;
|
||||||
@@ -1167,7 +1173,6 @@ Item {
|
|||||||
Loader {
|
Loader {
|
||||||
id: changelogLoader
|
id: changelogLoader
|
||||||
active: false
|
active: false
|
||||||
readonly property ChangelogModal loadedModal: item as ChangelogModal
|
|
||||||
sourceComponent: ChangelogModal {
|
sourceComponent: ChangelogModal {
|
||||||
onChangelogDismissed: changelogLoader.active = false
|
onChangelogDismissed: changelogLoader.active = false
|
||||||
Component.onCompleted: show()
|
Component.onCompleted: show()
|
||||||
@@ -1176,8 +1181,8 @@ Item {
|
|||||||
Connections {
|
Connections {
|
||||||
target: ChangelogService
|
target: ChangelogService
|
||||||
function onChangelogRequested() {
|
function onChangelogRequested() {
|
||||||
if (changelogLoader.active && changelogLoader.loadedModal) {
|
if (changelogLoader.active && changelogLoader.item) {
|
||||||
changelogLoader.loadedModal.show();
|
changelogLoader.item.show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
changelogLoader.active = true;
|
changelogLoader.active = true;
|
||||||
|
|||||||
@@ -531,7 +531,9 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function next(): void {
|
function next(): void {
|
||||||
MprisController.next();
|
if (MprisController.activePlayer && MprisController.activePlayer.canGoNext) {
|
||||||
|
MprisController.activePlayer.next();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop(): void {
|
function stop(): void {
|
||||||
|
|||||||
@@ -449,12 +449,7 @@ Item {
|
|||||||
searchQuery = query;
|
searchQuery = query;
|
||||||
requestSearch();
|
requestSearch();
|
||||||
|
|
||||||
if (autoSwitchedToFiles && !query.startsWith("/")) {
|
if (searchMode !== "plugins" && query.startsWith("/")) {
|
||||||
restorePreviousMode();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.startsWith("/")) {
|
|
||||||
var prefix = Utils.parseFileSearchPrefix(query);
|
var prefix = Utils.parseFileSearchPrefix(query);
|
||||||
var explicitType = prefix && prefix.type !== null ? prefix.type : null;
|
var explicitType = prefix && prefix.type !== null ? prefix.type : null;
|
||||||
var targetType = explicitType !== null ? explicitType : (SessionData.launcherLastFileSearchType || "all");
|
var targetType = explicitType !== null ? explicitType : (SessionData.launcherLastFileSearchType || "all");
|
||||||
|
|||||||
@@ -174,13 +174,10 @@ Item {
|
|||||||
spotlightContent.controller.selectedFlatIndex = 0;
|
spotlightContent.controller.selectedFlatIndex = 0;
|
||||||
spotlightContent.controller.selectedItem = null;
|
spotlightContent.controller.selectedItem = null;
|
||||||
spotlightContent.controller.historyIndex = -1;
|
spotlightContent.controller.historyIndex = -1;
|
||||||
if (targetQuery) {
|
spotlightContent.controller.searchQuery = targetQuery;
|
||||||
spotlightContent.controller.setSearchQuery(targetQuery);
|
|
||||||
} else {
|
|
||||||
spotlightContent.controller.searchQuery = "";
|
|
||||||
spotlightContent.controller.performSearch();
|
spotlightContent.controller.performSearch();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (spotlightContent.resetScroll) {
|
if (spotlightContent.resetScroll) {
|
||||||
spotlightContent.resetScroll();
|
spotlightContent.resetScroll();
|
||||||
}
|
}
|
||||||
@@ -274,7 +271,7 @@ Item {
|
|||||||
target: spotlightContent?.controller ?? null
|
target: spotlightContent?.controller ?? null
|
||||||
|
|
||||||
function onModeChanged(mode, userInitiated) {
|
function onModeChanged(mode, userInitiated) {
|
||||||
if (!userInitiated || !SettingsData.rememberLastMode)
|
if (!userInitiated || !SettingsData.rememberLastMode || (mode !== "all" && mode !== "apps"))
|
||||||
return;
|
return;
|
||||||
SessionData.setLauncherLastMode(mode);
|
SessionData.setLauncherLastMode(mode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ Item {
|
|||||||
Item {
|
Item {
|
||||||
id: listClip
|
id: listClip
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.topMargin: stickyHeader.visible ? 32 : 0
|
anchors.topMargin: BlurService.enabled && stickyHeader.visible ? 32 : 0
|
||||||
anchors.bottomMargin: bottomSectionHeader.visible ? bottomSectionHeader.height : 0
|
anchors.bottomMargin: bottomSectionHeader.visible ? bottomSectionHeader.height : 0
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
@@ -417,7 +417,7 @@ Item {
|
|||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
height: 32
|
height: 32
|
||||||
z: 101
|
z: 101
|
||||||
color: "transparent"
|
color: Theme.floatingSurface
|
||||||
visible: !root._bottomSectionHeaderActive && stickyHeaderSection !== null
|
visible: !root._bottomSectionHeaderActive && stickyHeaderSection !== null
|
||||||
|
|
||||||
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0
|
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ DankModal {
|
|||||||
property real holdProgress: 0
|
property real holdProgress: 0
|
||||||
property bool showHoldHint: false
|
property bool showHoldHint: false
|
||||||
property bool holdFromKeyboard: false
|
property bool holdFromKeyboard: false
|
||||||
property string committedAction: ""
|
property string pendingKeyAction: ""
|
||||||
|
|
||||||
readonly property bool needsConfirmation: SettingsData.powerActionConfirm
|
readonly property bool needsConfirmation: SettingsData.powerActionConfirm
|
||||||
readonly property int holdDurationMs: SettingsData.powerActionHoldDuration * 1000
|
readonly property int holdDurationMs: SettingsData.powerActionHoldDuration * 1000
|
||||||
@@ -39,16 +39,10 @@ DankModal {
|
|||||||
return action !== "lock" && action !== "restart";
|
return action !== "lock" && action !== "restart";
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionWakesOnKeyRelease(action) {
|
|
||||||
return action === "suspend" || action === "hibernate";
|
|
||||||
}
|
|
||||||
|
|
||||||
function startHold(action, actionIndex) {
|
function startHold(action, actionIndex) {
|
||||||
if (committedAction !== "")
|
|
||||||
return;
|
|
||||||
if (!needsConfirmation || !actionNeedsConfirm(action)) {
|
if (!needsConfirmation || !actionNeedsConfirm(action)) {
|
||||||
if (holdFromKeyboard && actionWakesOnKeyRelease(action)) {
|
if (holdFromKeyboard) {
|
||||||
commitAction(action, actionIndex);
|
pendingKeyAction = action;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
executeAction(action);
|
executeAction(action);
|
||||||
@@ -62,6 +56,7 @@ DankModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancelHold() {
|
function cancelHold() {
|
||||||
|
pendingKeyAction = "";
|
||||||
if (holdAction === "")
|
if (holdAction === "")
|
||||||
return;
|
return;
|
||||||
const wasHolding = holdProgress > 0;
|
const wasHolding = holdProgress > 0;
|
||||||
@@ -81,8 +76,8 @@ DankModal {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
holdTimer.stop();
|
holdTimer.stop();
|
||||||
if (holdFromKeyboard && actionWakesOnKeyRelease(holdAction)) {
|
if (holdFromKeyboard) {
|
||||||
commitAction(holdAction, holdActionIndex);
|
pendingKeyAction = holdAction;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const action = holdAction;
|
const action = holdAction;
|
||||||
@@ -92,25 +87,6 @@ DankModal {
|
|||||||
executeAction(action);
|
executeAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitAction(action, actionIndex) {
|
|
||||||
holdTimer.stop();
|
|
||||||
holdAction = "";
|
|
||||||
holdActionIndex = actionIndex;
|
|
||||||
committedAction = action;
|
|
||||||
commitFallbackTimer.restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
function executeCommittedAction() {
|
|
||||||
if (committedAction === "")
|
|
||||||
return;
|
|
||||||
commitFallbackTimer.stop();
|
|
||||||
const action = committedAction;
|
|
||||||
committedAction = "";
|
|
||||||
holdActionIndex = -1;
|
|
||||||
holdProgress = 0;
|
|
||||||
executeAction(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
signal switchUserRequested
|
signal switchUserRequested
|
||||||
|
|
||||||
function executeAction(action) {
|
function executeAction(action) {
|
||||||
@@ -152,12 +128,6 @@ DankModal {
|
|||||||
onTriggered: root.showHoldHint = false
|
onTriggered: root.showHoldHint = false
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: commitFallbackTimer
|
|
||||||
interval: 5000
|
|
||||||
onTriggered: root.executeCommittedAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCentered() {
|
function openCentered() {
|
||||||
parentBounds = Qt.rect(0, 0, 0, 0);
|
parentBounds = Qt.rect(0, 0, 0, 0);
|
||||||
parentScreen = null;
|
parentScreen = null;
|
||||||
@@ -316,8 +286,7 @@ DankModal {
|
|||||||
holdProgress = 0;
|
holdProgress = 0;
|
||||||
showHoldHint = false;
|
showHoldHint = false;
|
||||||
holdFromKeyboard = false;
|
holdFromKeyboard = false;
|
||||||
committedAction = "";
|
pendingKeyAction = "";
|
||||||
commitFallbackTimer.stop();
|
|
||||||
updateVisibleActions();
|
updateVisibleActions();
|
||||||
const defaultIndex = getDefaultActionIndex();
|
const defaultIndex = getDefaultActionIndex();
|
||||||
selectedIndex = defaultIndex;
|
selectedIndex = defaultIndex;
|
||||||
@@ -340,10 +309,6 @@ DankModal {
|
|||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (committedAction !== "") {
|
|
||||||
event.accepted = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
holdFromKeyboard = true;
|
holdFromKeyboard = true;
|
||||||
if (SettingsData.powerMenuGridLayout) {
|
if (SettingsData.powerMenuGridLayout) {
|
||||||
handleGridNavigation(event, true);
|
handleGridNavigation(event, true);
|
||||||
@@ -356,11 +321,6 @@ DankModal {
|
|||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (committedAction !== "") {
|
|
||||||
event.accepted = true;
|
|
||||||
executeCommittedAction();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (SettingsData.powerMenuGridLayout) {
|
if (SettingsData.powerMenuGridLayout) {
|
||||||
handleGridNavigation(event, false);
|
handleGridNavigation(event, false);
|
||||||
} else {
|
} else {
|
||||||
@@ -371,7 +331,16 @@ DankModal {
|
|||||||
function handleListNavigation(event, isPressed) {
|
function handleListNavigation(event, isPressed) {
|
||||||
if (!isPressed) {
|
if (!isPressed) {
|
||||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
||||||
|
if (pendingKeyAction !== "") {
|
||||||
|
const action = pendingKeyAction;
|
||||||
|
pendingKeyAction = "";
|
||||||
|
holdAction = "";
|
||||||
|
holdActionIndex = -1;
|
||||||
|
holdProgress = 0;
|
||||||
|
executeAction(action);
|
||||||
|
} else {
|
||||||
cancelHold();
|
cancelHold();
|
||||||
|
}
|
||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -465,7 +434,16 @@ DankModal {
|
|||||||
function handleGridNavigation(event, isPressed) {
|
function handleGridNavigation(event, isPressed) {
|
||||||
if (!isPressed) {
|
if (!isPressed) {
|
||||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
||||||
|
if (pendingKeyAction !== "") {
|
||||||
|
const action = pendingKeyAction;
|
||||||
|
pendingKeyAction = "";
|
||||||
|
holdAction = "";
|
||||||
|
holdActionIndex = -1;
|
||||||
|
holdProgress = 0;
|
||||||
|
executeAction(action);
|
||||||
|
} else {
|
||||||
cancelHold();
|
cancelHold();
|
||||||
|
}
|
||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -834,7 +812,6 @@ DankModal {
|
|||||||
|
|
||||||
Row {
|
Row {
|
||||||
id: hintRow
|
id: hintRow
|
||||||
readonly property bool selectedNeedsHold: root.actionNeedsConfirm(root.getActionAtIndex(root.selectedIndex))
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
anchors.bottomMargin: Theme.spacingS
|
anchors.bottomMargin: Theme.spacingS
|
||||||
@@ -849,13 +826,7 @@ DankModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DankIcon {
|
DankIcon {
|
||||||
name: {
|
name: root.showHoldHint ? "warning" : "touch_app"
|
||||||
if (root.showHoldHint)
|
|
||||||
return "warning";
|
|
||||||
if (!hintRow.selectedNeedsHold)
|
|
||||||
return "bolt";
|
|
||||||
return "touch_app";
|
|
||||||
}
|
|
||||||
size: Theme.fontSizeSmall
|
size: Theme.fontSizeSmall
|
||||||
color: root.showHoldHint ? Theme.warning : Theme.surfaceTextSecondary
|
color: root.showHoldHint ? Theme.warning : Theme.surfaceTextSecondary
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
@@ -865,12 +836,8 @@ DankModal {
|
|||||||
readonly property real totalMs: SettingsData.powerActionHoldDuration * 1000
|
readonly property real totalMs: SettingsData.powerActionHoldDuration * 1000
|
||||||
readonly property int remainingMs: Math.ceil(totalMs * (1 - root.holdProgress))
|
readonly property int remainingMs: Math.ceil(totalMs * (1 - root.holdProgress))
|
||||||
text: {
|
text: {
|
||||||
if (root.committedAction !== "")
|
|
||||||
return I18n.tr("Release to confirm");
|
|
||||||
if (root.showHoldHint)
|
if (root.showHoldHint)
|
||||||
return I18n.tr("Hold longer to confirm");
|
return I18n.tr("Hold longer to confirm");
|
||||||
if (!hintRow.selectedNeedsHold)
|
|
||||||
return I18n.tr("Activates immediately");
|
|
||||||
if (root.holdProgress > 0) {
|
if (root.holdProgress > 0) {
|
||||||
if (totalMs < 1000)
|
if (totalMs < 1000)
|
||||||
return I18n.tr("Hold to confirm (%1 ms)").arg(remainingMs);
|
return I18n.tr("Hold to confirm (%1 ms)").arg(remainingMs);
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ FloatingWindow {
|
|||||||
signal closingModal
|
signal closingModal
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
if (visible && !backingWindowVisible) {
|
|
||||||
visible = false;
|
|
||||||
}
|
|
||||||
visible = true;
|
visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +49,7 @@ FloatingWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
if (visible && backingWindowVisible) {
|
visible = !visible;
|
||||||
hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTabIndex(tabIndex: int) {
|
function setTabIndex(tabIndex: int) {
|
||||||
@@ -68,12 +61,13 @@ FloatingWindow {
|
|||||||
|
|
||||||
function showWithTab(tabIndex: int) {
|
function showWithTab(tabIndex: int) {
|
||||||
setTabIndex(tabIndex);
|
setTabIndex(tabIndex);
|
||||||
show();
|
visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showWithTabName(tabName: string) {
|
function showWithTabName(tabName: string) {
|
||||||
setTabIndex(sidebar.resolveTabIndex(tabName));
|
var idx = sidebar.resolveTabIndex(tabName);
|
||||||
show();
|
setTabIndex(idx);
|
||||||
|
visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTabIndex(tabName: string): int {
|
function resolveTabIndex(tabName: string): int {
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ FloatingWindow {
|
|||||||
condInitialised.triState = 0;
|
condInitialised.triState = 0;
|
||||||
opacityEnabled.checked = false;
|
opacityEnabled.checked = false;
|
||||||
opacitySlider.value = 100;
|
opacitySlider.value = 100;
|
||||||
floatingCond.triState = 0;
|
|
||||||
floatingToggle.checked = false;
|
floatingToggle.checked = false;
|
||||||
maximizedToggle.checked = false;
|
maximizedToggle.checked = false;
|
||||||
maximizedToEdgesToggle.checked = false;
|
maximizedToEdgesToggle.checked = false;
|
||||||
@@ -165,7 +164,6 @@ FloatingWindow {
|
|||||||
opacityEnabled.checked = hasOpacity;
|
opacityEnabled.checked = hasOpacity;
|
||||||
opacitySlider.value = hasOpacity ? Math.round(actions.opacity * 100) : 100;
|
opacitySlider.value = hasOpacity ? Math.round(actions.opacity * 100) : 100;
|
||||||
|
|
||||||
floatingCond.triState = triFromBool(actions.openFloating);
|
|
||||||
floatingToggle.checked = actions.openFloating || false;
|
floatingToggle.checked = actions.openFloating || false;
|
||||||
maximizedToggle.checked = actions.openMaximized || false;
|
maximizedToggle.checked = actions.openMaximized || false;
|
||||||
maximizedToEdgesToggle.checked = actions.openMaximizedToEdges || false;
|
maximizedToEdgesToggle.checked = actions.openMaximizedToEdges || false;
|
||||||
@@ -320,9 +318,7 @@ FloatingWindow {
|
|||||||
|
|
||||||
if (opacityEnabled.checked)
|
if (opacityEnabled.checked)
|
||||||
actions.opacity = opacitySlider.value / 100;
|
actions.opacity = opacitySlider.value / 100;
|
||||||
if (isNiri)
|
if (floatingToggle.checked)
|
||||||
applyCond(actions, "openFloating", floatingCond.triState);
|
|
||||||
else if (floatingToggle.checked)
|
|
||||||
actions.openFloating = true;
|
actions.openFloating = true;
|
||||||
if (maximizedToggle.checked)
|
if (maximizedToggle.checked)
|
||||||
actions.openMaximized = true;
|
actions.openMaximized = true;
|
||||||
@@ -947,17 +943,6 @@ FloatingWindow {
|
|||||||
title: I18n.tr("Window Opening")
|
title: I18n.tr("Window Opening")
|
||||||
}
|
}
|
||||||
|
|
||||||
Flow {
|
|
||||||
width: parent.width
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
visible: isNiri
|
|
||||||
|
|
||||||
MatchCond {
|
|
||||||
id: floatingCond
|
|
||||||
label: I18n.tr("Float")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Flow {
|
Flow {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: Theme.spacingL
|
spacing: Theme.spacingL
|
||||||
@@ -965,7 +950,6 @@ FloatingWindow {
|
|||||||
CheckboxRow {
|
CheckboxRow {
|
||||||
id: floatingToggle
|
id: floatingToggle
|
||||||
label: I18n.tr("Float")
|
label: I18n.tr("Float")
|
||||||
visible: !isNiri
|
|
||||||
}
|
}
|
||||||
CheckboxRow {
|
CheckboxRow {
|
||||||
id: maximizedToggle
|
id: maximizedToggle
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
|
import QtQuick.Effects
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import qs.Common
|
import qs.Common
|
||||||
@@ -32,8 +33,6 @@ Variants {
|
|||||||
|
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
visible: root.contentReady && !root.surfaceBounce
|
|
||||||
|
|
||||||
updatesEnabled: root.renderActive || root._settleFrames > 0
|
updatesEnabled: root.renderActive || root._settleFrames > 0
|
||||||
|
|
||||||
mask: Region {
|
mask: Region {
|
||||||
@@ -56,96 +55,55 @@ Variants {
|
|||||||
}
|
}
|
||||||
|
|
||||||
property string source: SessionData.getMonitorWallpaper(modelData.name) || ""
|
property string source: SessionData.getMonitorWallpaper(modelData.name) || ""
|
||||||
readonly property bool isColorSource: source.startsWith("#")
|
property bool isColorSource: source.startsWith("#")
|
||||||
readonly property string displaySource: {
|
|
||||||
if (!source || isColorSource)
|
Connections {
|
||||||
return "";
|
target: SessionData
|
||||||
return source.startsWith("file://") ? source : encodeFileUrl(source);
|
function onIsLightModeChanged() {
|
||||||
|
if (SessionData.perModeWallpaper) {
|
||||||
|
var newSource = SessionData.getMonitorWallpaper(modelData.name) || "";
|
||||||
|
if (newSource !== root.source) {
|
||||||
|
root.source = newSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
property bool contentReady: false
|
|
||||||
property bool surfaceBounce: false
|
|
||||||
|
|
||||||
// Live stack is captured into frozenLayer once stable, then unloaded; if the capture stalls the stack just stays live.
|
function getFillMode(modeName) {
|
||||||
property bool liveActive: false
|
switch (modeName) {
|
||||||
property bool frozenValid: false
|
case "Stretch":
|
||||||
property string _frozenSource: ""
|
return Image.Stretch;
|
||||||
property bool loadFailed: false
|
case "Fit":
|
||||||
property int _freezeWaitFrames: 0
|
case "PreserveAspectFit":
|
||||||
|
return Image.PreserveAspectFit;
|
||||||
readonly property var backingWindow: Window.window
|
case "Fill":
|
||||||
readonly property bool renderActive: !source || liveActive || _freezeWaitFrames > 0
|
case "PreserveAspectCrop":
|
||||||
property int _settleFrames: 3
|
return Image.PreserveAspectCrop;
|
||||||
|
case "Tile":
|
||||||
readonly property int maxTextureSize: 8192
|
return Image.Tile;
|
||||||
readonly property int textureWidth: Math.min(modelData.width, maxTextureSize)
|
case "TileVertically":
|
||||||
readonly property int textureHeight: Math.min(modelData.height, maxTextureSize)
|
return Image.TileVertically;
|
||||||
|
case "TileHorizontally":
|
||||||
|
return Image.TileHorizontally;
|
||||||
|
case "Pad":
|
||||||
|
return Image.Pad;
|
||||||
|
default:
|
||||||
|
return Image.PreserveAspectCrop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
if (!displaySource) {
|
isInitialized = true;
|
||||||
contentReady = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
liveActive = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onDisplaySourceChanged: {
|
property bool isInitialized: false
|
||||||
invalidate();
|
property real transitionProgress: 0
|
||||||
loadFailed = false;
|
readonly property bool transitioning: transitionAnimation.running
|
||||||
_freezeWaitFrames = 0;
|
property bool effectActive: false
|
||||||
if (!displaySource) {
|
property bool useNextForEffect: false
|
||||||
liveActive = false;
|
readonly property var backingWindow: Window.window
|
||||||
frozenValid = false;
|
readonly property bool renderActive: !source || effectActive || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading
|
||||||
_frozenSource = "";
|
property int _settleFrames: 3
|
||||||
contentReady = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
liveActive = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function regenerate() {
|
|
||||||
invalidate();
|
|
||||||
if (!displaySource)
|
|
||||||
return;
|
|
||||||
if (liveActive) {
|
|
||||||
scheduleFreeze();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
liveActive = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDisplayable() {
|
|
||||||
contentReady = true;
|
|
||||||
if (liveLoader.item?.currentFailed) {
|
|
||||||
if (!frozenValid)
|
|
||||||
loadFailed = true;
|
|
||||||
liveActive = false;
|
|
||||||
}
|
|
||||||
invalidate();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFreeze() {
|
|
||||||
if (!liveLoader.item?.stable)
|
|
||||||
return;
|
|
||||||
frozenLayer.scheduleUpdate();
|
|
||||||
_freezeWaitFrames = 3;
|
|
||||||
_settleFrames = 3;
|
|
||||||
// No wedge watchdog: an occluded surface may never produce frames, the freeze just waits
|
|
||||||
backingWindow?.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
function completeFreeze() {
|
|
||||||
const live = liveLoader.item;
|
|
||||||
if (!live || !live.stable)
|
|
||||||
return;
|
|
||||||
frozenValid = true;
|
|
||||||
_frozenSource = displaySource;
|
|
||||||
liveActive = false;
|
|
||||||
log.info("froze blur layer for", modelData.name);
|
|
||||||
invalidate();
|
|
||||||
}
|
|
||||||
|
|
||||||
onTextureWidthChanged: regenerate()
|
|
||||||
onTextureHeightChanged: regenerate()
|
|
||||||
|
|
||||||
function invalidate() {
|
function invalidate() {
|
||||||
_settleFrames = 3;
|
_settleFrames = 3;
|
||||||
@@ -178,9 +136,9 @@ Variants {
|
|||||||
interval: 0
|
interval: 0
|
||||||
repeat: false
|
repeat: false
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
root.surfaceBounce = true;
|
blurWallpaperWindow.visible = false;
|
||||||
Qt.callLater(() => {
|
Qt.callLater(() => {
|
||||||
root.surfaceBounce = false;
|
blurWallpaperWindow.visible = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,35 +150,29 @@ Variants {
|
|||||||
root._settleFrames--;
|
root._settleFrames--;
|
||||||
root._wedgeBounced = false;
|
root._wedgeBounced = false;
|
||||||
wedgeWatchdog.stop();
|
wedgeWatchdog.stop();
|
||||||
if (root._freezeWaitFrames > 0 && --root._freezeWaitFrames === 0)
|
|
||||||
root.completeFreeze();
|
|
||||||
}
|
}
|
||||||
function onVisibleChanged() {
|
function onVisibleChanged() {
|
||||||
root.invalidate();
|
root.invalidate();
|
||||||
}
|
}
|
||||||
function onWidthChanged() {
|
function onWidthChanged() {
|
||||||
root.regenerate();
|
root.invalidate();
|
||||||
}
|
}
|
||||||
function onHeightChanged() {
|
function onHeightChanged() {
|
||||||
root.regenerate();
|
root.invalidate();
|
||||||
}
|
|
||||||
function onResourcesLost() {
|
|
||||||
root.frozenValid = false;
|
|
||||||
root.regenerate();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: Quickshell
|
target: Quickshell
|
||||||
function onScreensChanged() {
|
function onScreensChanged() {
|
||||||
root.regenerate();
|
root.invalidate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: SettingsData
|
target: SettingsData
|
||||||
function onWallpaperFillModeChanged() {
|
function onWallpaperFillModeChanged() {
|
||||||
root.regenerate();
|
root.invalidate();
|
||||||
}
|
}
|
||||||
function onEffectiveWallpaperBackgroundColorChanged() {
|
function onEffectiveWallpaperBackgroundColorChanged() {
|
||||||
root.invalidate();
|
root.invalidate();
|
||||||
@@ -229,26 +181,18 @@ Variants {
|
|||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: SessionData
|
target: SessionData
|
||||||
function onIsLightModeChanged() {
|
|
||||||
if (SessionData.perModeWallpaper) {
|
|
||||||
var newSource = SessionData.getMonitorWallpaper(modelData.name) || "";
|
|
||||||
if (newSource !== root.source) {
|
|
||||||
root.source = newSource;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function onMonitorWallpaperFillModesChanged() {
|
function onMonitorWallpaperFillModesChanged() {
|
||||||
root.regenerate();
|
root.invalidate();
|
||||||
}
|
}
|
||||||
function onPerMonitorWallpaperChanged() {
|
function onPerMonitorWallpaperChanged() {
|
||||||
root.regenerate();
|
root.invalidate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme changes repaint DankBackdrop but nothing else wakes the render loop
|
// Theme changes repaint DankBackdrop but nothing else wakes the render loop
|
||||||
Connections {
|
Connections {
|
||||||
target: Theme
|
target: Theme
|
||||||
enabled: root.isColorSource || root.loadFailed
|
enabled: root.isColorSource || currentWallpaper.status === Image.Error
|
||||||
function onPrimaryChanged() {
|
function onPrimaryChanged() {
|
||||||
root.invalidate();
|
root.invalidate();
|
||||||
}
|
}
|
||||||
@@ -266,23 +210,87 @@ Variants {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
function handleTransitionLoadError(failedSource) {
|
||||||
target: liveLoader.item
|
log.warn("failed to load candidate wallpaper for", modelData.name + ":", failedSource);
|
||||||
function onBecameDisplayable() {
|
transitionDelayTimer.stop();
|
||||||
root.handleDisplayable();
|
transitionAnimation.stop();
|
||||||
|
root.useNextForEffect = false;
|
||||||
|
root.effectActive = false;
|
||||||
|
root.transitionProgress = 0.0;
|
||||||
|
nextWallpaper.source = "";
|
||||||
}
|
}
|
||||||
function onStableChanged() {
|
|
||||||
if (liveLoader.item.stable)
|
onSourceChanged: {
|
||||||
root.scheduleFreeze();
|
invalidate();
|
||||||
|
if (!source || source.startsWith("#")) {
|
||||||
|
setWallpaperImmediate("");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
function onTransitioningChanged() {
|
|
||||||
root.invalidate();
|
const formattedSource = source.startsWith("file://") ? source : encodeFileUrl(source);
|
||||||
|
|
||||||
|
if (!isInitialized || !currentWallpaper.source) {
|
||||||
|
setWallpaperImmediate(formattedSource);
|
||||||
|
isInitialized = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (CompositorService.isNiri && SessionData.isSwitchingMode) {
|
||||||
|
setWallpaperImmediate(formattedSource);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changeWallpaper(formattedSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWallpaperImmediate(newSource) {
|
||||||
|
transitionDelayTimer.stop();
|
||||||
|
transitionAnimation.stop();
|
||||||
|
root.transitionProgress = 0.0;
|
||||||
|
root.effectActive = false;
|
||||||
|
currentWallpaper.source = newSource;
|
||||||
|
nextWallpaper.source = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTransition() {
|
||||||
|
root.useNextForEffect = true;
|
||||||
|
root.effectActive = true;
|
||||||
|
if (srcNext.scheduleUpdate)
|
||||||
|
srcNext.scheduleUpdate();
|
||||||
|
transitionDelayTimer.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: transitionDelayTimer
|
||||||
|
interval: 16
|
||||||
|
repeat: false
|
||||||
|
onTriggered: transitionAnimation.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeWallpaper(newPath) {
|
||||||
|
if (newPath === currentWallpaper.source)
|
||||||
|
return;
|
||||||
|
if (!newPath || newPath.startsWith("#"))
|
||||||
|
return;
|
||||||
|
if (root.transitioning) {
|
||||||
|
transitionAnimation.stop();
|
||||||
|
root.transitionProgress = 0;
|
||||||
|
root.effectActive = false;
|
||||||
|
currentWallpaper.source = nextWallpaper.source;
|
||||||
|
nextWallpaper.source = "";
|
||||||
|
}
|
||||||
|
if (!currentWallpaper.source) {
|
||||||
|
setWallpaperImmediate(newPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWallpaper.source = newPath;
|
||||||
|
|
||||||
|
if (nextWallpaper.status === Image.Ready)
|
||||||
|
root.startTransition();
|
||||||
}
|
}
|
||||||
|
|
||||||
Loader {
|
Loader {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
active: !root.source || root.isColorSource || root.loadFailed
|
active: !root.source || root.isColorSource || currentWallpaper.status === Image.Error
|
||||||
asynchronous: true
|
asynchronous: true
|
||||||
|
|
||||||
sourceComponent: DankBackdrop {
|
sourceComponent: DankBackdrop {
|
||||||
@@ -290,43 +298,123 @@ Variants {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ShaderEffectSource {
|
readonly property int maxTextureSize: 8192
|
||||||
id: frozenLayer
|
property int textureWidth: Math.min(modelData.width, maxTextureSize)
|
||||||
|
property int textureHeight: Math.min(modelData.height, maxTextureSize)
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: currentWallpaper
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
sourceItem: liveContainer
|
visible: false
|
||||||
live: false
|
opacity: 1
|
||||||
|
asynchronous: true
|
||||||
|
retainWhileLoading: true
|
||||||
|
smooth: true
|
||||||
|
cache: true
|
||||||
|
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
|
||||||
|
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(modelData.name))
|
||||||
|
|
||||||
|
onStatusChanged: {
|
||||||
|
if (status === Image.Error) {
|
||||||
|
log.warn("failed to load active wallpaper for", modelData.name + ":", source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: nextWallpaper
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: false
|
||||||
|
opacity: 0
|
||||||
|
asynchronous: true
|
||||||
|
retainWhileLoading: true
|
||||||
|
smooth: true
|
||||||
|
cache: true
|
||||||
|
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
|
||||||
|
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(modelData.name))
|
||||||
|
|
||||||
|
onStatusChanged: {
|
||||||
|
if (status === Image.Error) {
|
||||||
|
root.handleTransitionLoadError(source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status !== Image.Ready)
|
||||||
|
return;
|
||||||
|
if (!root.transitioning) {
|
||||||
|
root.startTransition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShaderEffectSource {
|
||||||
|
id: srcNext
|
||||||
|
sourceItem: root.effectActive ? nextWallpaper : null
|
||||||
|
hideSource: root.effectActive
|
||||||
|
live: root.effectActive
|
||||||
mipmap: false
|
mipmap: false
|
||||||
recursive: false
|
recursive: false
|
||||||
smooth: true
|
|
||||||
visible: root.frozenValid || root.liveActive
|
|
||||||
textureSize: Qt.size(root.textureWidth, root.textureHeight)
|
textureSize: Qt.size(root.textureWidth, root.textureHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: dummyRect
|
||||||
|
width: 1
|
||||||
|
height: 1
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
}
|
||||||
|
|
||||||
|
ShaderEffectSource {
|
||||||
|
id: srcDummy
|
||||||
|
sourceItem: dummyRect
|
||||||
|
hideSource: true
|
||||||
|
live: false
|
||||||
|
mipmap: false
|
||||||
|
recursive: false
|
||||||
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: liveContainer
|
id: blurredLayer
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
visible: root.liveActive
|
|
||||||
|
|
||||||
Loader {
|
MultiEffect {
|
||||||
id: liveLoader
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
active: root.liveActive
|
source: currentWallpaper
|
||||||
asynchronous: false
|
visible: currentWallpaper.source !== ""
|
||||||
|
blurEnabled: true
|
||||||
// Cached images reach Ready synchronously during creation, before Connections retargets
|
blur: 0.8
|
||||||
onLoaded: {
|
blurMax: 75
|
||||||
if (item.displayableNow)
|
opacity: 1 - root.transitionProgress
|
||||||
root.handleDisplayable();
|
autoPaddingEnabled: false
|
||||||
if (item.stable)
|
|
||||||
root.scheduleFreeze();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceComponent: BlurredWallpaperLive {
|
MultiEffect {
|
||||||
wallpaperSource: root.displaySource
|
anchors.fill: parent
|
||||||
initialSource: root._frozenSource
|
source: root.useNextForEffect ? srcNext : srcDummy
|
||||||
screenName: modelData.name
|
visible: nextWallpaper.source !== "" && root.useNextForEffect
|
||||||
blurTextureSize: Qt.size(root.textureWidth, root.textureHeight)
|
blurEnabled: true
|
||||||
}
|
blur: 0.8
|
||||||
|
blurMax: 75
|
||||||
|
opacity: root.transitionProgress
|
||||||
|
autoPaddingEnabled: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NumberAnimation {
|
||||||
|
id: transitionAnimation
|
||||||
|
target: root
|
||||||
|
property: "transitionProgress"
|
||||||
|
from: 0.0
|
||||||
|
to: 1.0
|
||||||
|
duration: 1000
|
||||||
|
easing.type: Easing.InOutCubic
|
||||||
|
onFinished: {
|
||||||
|
if (nextWallpaper.source && nextWallpaper.status === Image.Ready)
|
||||||
|
currentWallpaper.source = nextWallpaper.source;
|
||||||
|
root.useNextForEffect = false;
|
||||||
|
nextWallpaper.source = "";
|
||||||
|
root.transitionProgress = 0.0;
|
||||||
|
root.effectActive = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Effects
|
|
||||||
import qs.Common
|
|
||||||
import qs.Services
|
|
||||||
|
|
||||||
Item {
|
|
||||||
id: root
|
|
||||||
|
|
||||||
readonly property var log: Log.scoped("BlurredWallpaperLive")
|
|
||||||
|
|
||||||
required property string wallpaperSource
|
|
||||||
required property string initialSource
|
|
||||||
required property string screenName
|
|
||||||
required property size blurTextureSize
|
|
||||||
|
|
||||||
readonly property bool currentFailed: currentWallpaper.status === Image.Error
|
|
||||||
readonly property bool displayableNow: currentWallpaper.status === Image.Ready || currentWallpaper.status === Image.Error
|
|
||||||
readonly property bool stable: isInitialized && !effectActive && !transitionAnimation.running && !transitionDelayTimer.running && currentWallpaper.status === Image.Ready && currentWallpaper.source.toString() === wallpaperSource && !nextWallpaper.source.toString()
|
|
||||||
|
|
||||||
signal becameDisplayable
|
|
||||||
|
|
||||||
property bool isInitialized: false
|
|
||||||
property real transitionProgress: 0
|
|
||||||
readonly property bool transitioning: transitionAnimation.running
|
|
||||||
property bool effectActive: false
|
|
||||||
property bool useNextForEffect: false
|
|
||||||
|
|
||||||
function getFillMode(modeName) {
|
|
||||||
switch (modeName) {
|
|
||||||
case "Stretch":
|
|
||||||
return Image.Stretch;
|
|
||||||
case "Fit":
|
|
||||||
case "PreserveAspectFit":
|
|
||||||
return Image.PreserveAspectFit;
|
|
||||||
case "Fill":
|
|
||||||
case "PreserveAspectCrop":
|
|
||||||
return Image.PreserveAspectCrop;
|
|
||||||
case "Tile":
|
|
||||||
return Image.Tile;
|
|
||||||
case "TileVertically":
|
|
||||||
return Image.TileVertically;
|
|
||||||
case "TileHorizontally":
|
|
||||||
return Image.TileHorizontally;
|
|
||||||
case "Pad":
|
|
||||||
return Image.Pad;
|
|
||||||
default:
|
|
||||||
return Image.PreserveAspectCrop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
|
||||||
if (initialSource && initialSource !== wallpaperSource && !(CompositorService.isNiri && SessionData.isSwitchingMode)) {
|
|
||||||
currentWallpaper.source = initialSource;
|
|
||||||
isInitialized = true;
|
|
||||||
changeWallpaper(wallpaperSource);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentWallpaper.source = wallpaperSource;
|
|
||||||
isInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
onWallpaperSourceChanged: {
|
|
||||||
if (!isInitialized)
|
|
||||||
return;
|
|
||||||
if (!wallpaperSource) {
|
|
||||||
setWallpaperImmediate("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!currentWallpaper.source.toString()) {
|
|
||||||
setWallpaperImmediate(wallpaperSource);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (CompositorService.isNiri && SessionData.isSwitchingMode) {
|
|
||||||
setWallpaperImmediate(wallpaperSource);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
changeWallpaper(wallpaperSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTransitionLoadError(failedSource) {
|
|
||||||
log.warn("failed to load candidate wallpaper for", screenName + ":", failedSource);
|
|
||||||
transitionDelayTimer.stop();
|
|
||||||
transitionAnimation.stop();
|
|
||||||
useNextForEffect = false;
|
|
||||||
effectActive = false;
|
|
||||||
transitionProgress = 0.0;
|
|
||||||
nextWallpaper.source = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function setWallpaperImmediate(newSource) {
|
|
||||||
transitionDelayTimer.stop();
|
|
||||||
transitionAnimation.stop();
|
|
||||||
transitionProgress = 0.0;
|
|
||||||
effectActive = false;
|
|
||||||
currentWallpaper.source = newSource;
|
|
||||||
nextWallpaper.source = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function startTransition() {
|
|
||||||
useNextForEffect = true;
|
|
||||||
effectActive = true;
|
|
||||||
if (srcNext.scheduleUpdate)
|
|
||||||
srcNext.scheduleUpdate();
|
|
||||||
transitionDelayTimer.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeWallpaper(newPath) {
|
|
||||||
if (!newPath)
|
|
||||||
return;
|
|
||||||
if (newPath === currentWallpaper.source.toString())
|
|
||||||
return;
|
|
||||||
if (transitioning) {
|
|
||||||
transitionAnimation.stop();
|
|
||||||
transitionProgress = 0;
|
|
||||||
effectActive = false;
|
|
||||||
currentWallpaper.source = nextWallpaper.source;
|
|
||||||
nextWallpaper.source = "";
|
|
||||||
}
|
|
||||||
if (!currentWallpaper.source.toString()) {
|
|
||||||
setWallpaperImmediate(newPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextWallpaper.source = newPath;
|
|
||||||
|
|
||||||
if (nextWallpaper.status === Image.Ready)
|
|
||||||
startTransition();
|
|
||||||
}
|
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: transitionDelayTimer
|
|
||||||
interval: 16
|
|
||||||
repeat: false
|
|
||||||
onTriggered: transitionAnimation.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
Image {
|
|
||||||
id: currentWallpaper
|
|
||||||
anchors.fill: parent
|
|
||||||
visible: false
|
|
||||||
opacity: 1
|
|
||||||
asynchronous: true
|
|
||||||
retainWhileLoading: true
|
|
||||||
smooth: true
|
|
||||||
cache: true
|
|
||||||
sourceSize: root.blurTextureSize
|
|
||||||
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(root.screenName))
|
|
||||||
|
|
||||||
onStatusChanged: {
|
|
||||||
if (status === Image.Error) {
|
|
||||||
root.log.warn("failed to load active wallpaper for", root.screenName + ":", source);
|
|
||||||
}
|
|
||||||
if (status === Image.Ready || status === Image.Error) {
|
|
||||||
root.becameDisplayable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Image {
|
|
||||||
id: nextWallpaper
|
|
||||||
anchors.fill: parent
|
|
||||||
visible: false
|
|
||||||
opacity: 0
|
|
||||||
asynchronous: true
|
|
||||||
retainWhileLoading: true
|
|
||||||
smooth: true
|
|
||||||
cache: true
|
|
||||||
sourceSize: root.blurTextureSize
|
|
||||||
fillMode: root.getFillMode(SessionData.isGreeterMode ? GreetdSettings.wallpaperFillMode : SessionData.getMonitorWallpaperFillMode(root.screenName))
|
|
||||||
|
|
||||||
onStatusChanged: {
|
|
||||||
if (status === Image.Error) {
|
|
||||||
root.handleTransitionLoadError(source);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (status !== Image.Ready)
|
|
||||||
return;
|
|
||||||
if (!root.transitioning) {
|
|
||||||
root.startTransition();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ShaderEffectSource {
|
|
||||||
id: srcNext
|
|
||||||
sourceItem: root.effectActive ? nextWallpaper : null
|
|
||||||
hideSource: root.effectActive
|
|
||||||
live: root.effectActive
|
|
||||||
mipmap: false
|
|
||||||
recursive: false
|
|
||||||
textureSize: root.blurTextureSize
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: dummyRect
|
|
||||||
width: 1
|
|
||||||
height: 1
|
|
||||||
visible: false
|
|
||||||
color: "transparent"
|
|
||||||
}
|
|
||||||
|
|
||||||
ShaderEffectSource {
|
|
||||||
id: srcDummy
|
|
||||||
sourceItem: dummyRect
|
|
||||||
hideSource: true
|
|
||||||
live: false
|
|
||||||
mipmap: false
|
|
||||||
recursive: false
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
|
||||||
id: blurredLayer
|
|
||||||
anchors.fill: parent
|
|
||||||
|
|
||||||
MultiEffect {
|
|
||||||
anchors.fill: parent
|
|
||||||
source: currentWallpaper
|
|
||||||
visible: currentWallpaper.source !== ""
|
|
||||||
blurEnabled: true
|
|
||||||
blur: 0.8
|
|
||||||
blurMax: 75
|
|
||||||
opacity: 1 - root.transitionProgress
|
|
||||||
autoPaddingEnabled: false
|
|
||||||
}
|
|
||||||
|
|
||||||
MultiEffect {
|
|
||||||
anchors.fill: parent
|
|
||||||
source: root.useNextForEffect ? srcNext : srcDummy
|
|
||||||
visible: nextWallpaper.source !== "" && root.useNextForEffect
|
|
||||||
blurEnabled: true
|
|
||||||
blur: 0.8
|
|
||||||
blurMax: 75
|
|
||||||
opacity: root.transitionProgress
|
|
||||||
autoPaddingEnabled: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NumberAnimation {
|
|
||||||
id: transitionAnimation
|
|
||||||
target: root
|
|
||||||
property: "transitionProgress"
|
|
||||||
from: 0.0
|
|
||||||
to: 1.0
|
|
||||||
duration: 1000
|
|
||||||
easing.type: Easing.InOutCubic
|
|
||||||
onFinished: {
|
|
||||||
if (nextWallpaper.source && nextWallpaper.status === Image.Ready)
|
|
||||||
currentWallpaper.source = nextWallpaper.source;
|
|
||||||
root.useNextForEffect = false;
|
|
||||||
nextWallpaper.source = "";
|
|
||||||
root.transitionProgress = 0.0;
|
|
||||||
root.effectActive = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,9 +44,7 @@ PluginComponent {
|
|||||||
|
|
||||||
implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2
|
implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.nestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
border.color: Theme.outlineMedium
|
|
||||||
border.width: Theme.layerOutlineWidth
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: detailColumn
|
id: detailColumn
|
||||||
@@ -134,11 +132,7 @@ PluginComponent {
|
|||||||
width: connButtonRow.implicitWidth + Theme.spacingM * 2
|
width: connButtonRow.implicitWidth + Theme.spacingM * 2
|
||||||
|
|
||||||
readonly property bool isConnected: TailscaleService.connected
|
readonly property bool isConnected: TailscaleService.connected
|
||||||
color: {
|
color: isConnected ? (connButtonArea.containsMouse ? Theme.errorHover : Theme.surfaceLight) : (connButtonArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight)
|
||||||
if (!connButtonArea.containsMouse)
|
|
||||||
return Theme.surfaceLight;
|
|
||||||
return isConnected ? Theme.errorHover : Theme.primaryHoverLight;
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
id: connButtonRow
|
id: connButtonRow
|
||||||
@@ -336,15 +330,13 @@ PluginComponent {
|
|||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
required property int index
|
||||||
|
|
||||||
readonly property bool isSelf: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "")
|
|
||||||
readonly property bool isExpanded: detailRoot.expandedHostname === modelData.hostname
|
|
||||||
|
|
||||||
width: peerListColumn.width
|
width: peerListColumn.width
|
||||||
height: peerCardColumn.implicitHeight + Theme.spacingS * 2
|
height: peerCardColumn.implicitHeight + Theme.spacingS * 2
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: peerMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
color: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "") ? Theme.primaryHoverLight : Theme.surfaceContainerHighest
|
||||||
border.color: isSelf ? Theme.primary : Theme.outlineLight
|
|
||||||
border.width: isSelf ? 2 : 1
|
property bool isSelf: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "")
|
||||||
|
property bool isExpanded: detailRoot.expandedHostname === modelData.hostname
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: peerCardColumn
|
id: peerCardColumn
|
||||||
@@ -362,14 +354,14 @@ PluginComponent {
|
|||||||
width: 8
|
width: 8
|
||||||
height: 8
|
height: 8
|
||||||
radius: 4
|
radius: 4
|
||||||
color: modelData.online ? Theme.success : Theme.surfaceVariantText
|
color: modelData.online ? "#4caf50" : Theme.surfaceVariantText
|
||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: modelData.hostname || ""
|
text: modelData.hostname || ""
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
font.weight: isSelf ? Font.Medium : Font.Normal
|
font.weight: Font.Bold
|
||||||
color: Theme.surfaceText
|
color: Theme.surfaceText
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
@@ -378,7 +370,7 @@ PluginComponent {
|
|||||||
StyledText {
|
StyledText {
|
||||||
visible: isSelf
|
visible: isSelf
|
||||||
text: I18n.tr("This device", "Label for the user's own device in Tailscale")
|
text: I18n.tr("This device", "Label for the user's own device in Tailscale")
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: 10
|
||||||
color: Theme.primary
|
color: Theme.primary
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
}
|
}
|
||||||
@@ -417,7 +409,7 @@ PluginComponent {
|
|||||||
}
|
}
|
||||||
return parts.join(" \u2022 ");
|
return parts.join(" \u2022 ");
|
||||||
}
|
}
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: 10
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
width: parent.width
|
width: parent.width
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
@@ -437,7 +429,7 @@ PluginComponent {
|
|||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: modelData.dnsName || ""
|
text: modelData.dnsName || ""
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: 10
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
@@ -455,14 +447,14 @@ PluginComponent {
|
|||||||
StyledText {
|
StyledText {
|
||||||
visible: (modelData.tags || []).length > 0
|
visible: (modelData.tags || []).length > 0
|
||||||
text: I18n.tr("Tags: %1", "Tailscale device tags").arg((modelData.tags || []).join(", "))
|
text: I18n.tr("Tags: %1", "Tailscale device tags").arg((modelData.tags || []).join(", "))
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: 10
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
visible: (modelData.owner || "").length > 0
|
visible: (modelData.owner || "").length > 0
|
||||||
text: I18n.tr("Owner: %1", "Tailscale device owner").arg(modelData.owner || "")
|
text: I18n.tr("Owner: %1", "Tailscale device owner").arg(modelData.owner || "")
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: 10
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,12 +77,6 @@ DankPopout {
|
|||||||
|
|
||||||
readonly property color _containerBg: Theme.nestedSurface
|
readonly property color _containerBg: Theme.nestedSurface
|
||||||
|
|
||||||
// Defer open one tick so screen-change geometry settles before the surface
|
|
||||||
// maps; a synchronous open churns the surface and loses the blur on a switch.
|
|
||||||
function present() {
|
|
||||||
Qt.callLater(open);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openWithSection(section) {
|
function openWithSection(section) {
|
||||||
StateUtils.openWithSection(root, section);
|
StateUtils.openWithSection(root, section);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPinnedInputs() {
|
function getPinnedInputs() {
|
||||||
const pins = CacheData.audioInputDevicePins || {};
|
const pins = SettingsData.audioInputDevicePins || {};
|
||||||
return normalizePinList(pins["preferredInput"]);
|
return normalizePinList(pins["preferredInput"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ Rectangle {
|
|||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
|
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
|
||||||
onClicked: {
|
onClicked: {
|
||||||
const pins = JSON.parse(JSON.stringify(CacheData.audioInputDevicePins || {}));
|
const pins = JSON.parse(JSON.stringify(SettingsData.audioInputDevicePins || {}));
|
||||||
let pinnedList = audioContent.normalizePinList(pins["preferredInput"]);
|
let pinnedList = audioContent.normalizePinList(pins["preferredInput"]);
|
||||||
const pinIndex = pinnedList.indexOf(modelData.name);
|
const pinIndex = pinnedList.indexOf(modelData.name);
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ Rectangle {
|
|||||||
else
|
else
|
||||||
delete pins["preferredInput"];
|
delete pins["preferredInput"];
|
||||||
|
|
||||||
CacheData.set("audioInputDevicePins", pins);
|
SettingsData.set("audioInputDevicePins", pins);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPinnedOutputs() {
|
function getPinnedOutputs() {
|
||||||
const pins = CacheData.audioOutputDevicePins || {};
|
const pins = SettingsData.audioOutputDevicePins || {};
|
||||||
return normalizePinList(pins["preferredOutput"]);
|
return normalizePinList(pins["preferredOutput"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ Rectangle {
|
|||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
|
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
|
||||||
onClicked: {
|
onClicked: {
|
||||||
const pins = JSON.parse(JSON.stringify(CacheData.audioOutputDevicePins || {}));
|
const pins = JSON.parse(JSON.stringify(SettingsData.audioOutputDevicePins || {}));
|
||||||
let pinnedList = audioContent.normalizePinList(pins["preferredOutput"]);
|
let pinnedList = audioContent.normalizePinList(pins["preferredOutput"]);
|
||||||
const pinIndex = pinnedList.indexOf(modelData.name);
|
const pinIndex = pinnedList.indexOf(modelData.name);
|
||||||
|
|
||||||
@@ -341,7 +341,7 @@ Rectangle {
|
|||||||
else
|
else
|
||||||
delete pins["preferredOutput"];
|
delete pins["preferredOutput"];
|
||||||
|
|
||||||
CacheData.set("audioOutputDevicePins", pins);
|
SettingsData.set("audioOutputDevicePins", pins);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPinnedDevices() {
|
function getPinnedDevices() {
|
||||||
const pins = CacheData.bluetoothDevicePins || {};
|
const pins = SettingsData.bluetoothDevicePins || {};
|
||||||
return normalizePinList(pins["preferredDevice"]);
|
return normalizePinList(pins["preferredDevice"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +400,7 @@ Rectangle {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: {
|
onClicked: {
|
||||||
const pins = JSON.parse(JSON.stringify(CacheData.bluetoothDevicePins || {}));
|
const pins = JSON.parse(JSON.stringify(SettingsData.bluetoothDevicePins || {}));
|
||||||
let pinnedList = root.normalizePinList(pins["preferredDevice"]);
|
let pinnedList = root.normalizePinList(pins["preferredDevice"]);
|
||||||
const pinIndex = pinnedList.indexOf(pairedDelegate.modelData.address);
|
const pinIndex = pinnedList.indexOf(pairedDelegate.modelData.address);
|
||||||
|
|
||||||
@@ -418,7 +418,7 @@ Rectangle {
|
|||||||
delete pins["preferredDevice"];
|
delete pins["preferredDevice"];
|
||||||
}
|
}
|
||||||
|
|
||||||
CacheData.set("bluetoothDevicePins", pins);
|
SettingsData.set("bluetoothDevicePins", pins);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Rectangle {
|
|||||||
|
|
||||||
const pinKey = getScreenPinKey();
|
const pinKey = getScreenPinKey();
|
||||||
if (pinKey.length > 0) {
|
if (pinKey.length > 0) {
|
||||||
const pins = CacheData.brightnessDevicePins || {};
|
const pins = SettingsData.brightnessDevicePins || {};
|
||||||
const pinnedDevice = pins[pinKey];
|
const pinnedDevice = pins[pinKey];
|
||||||
if (pinnedDevice && pinnedDevice.length > 0) {
|
if (pinnedDevice && pinnedDevice.length > 0) {
|
||||||
const found = devices.find(d => d.name === pinnedDevice);
|
const found = devices.find(d => d.name === pinnedDevice);
|
||||||
@@ -85,12 +85,12 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
const pinKey = getScreenPinKey();
|
const pinKey = getScreenPinKey();
|
||||||
if (pinKey.length > 0) {
|
if (pinKey.length > 0) {
|
||||||
const pins = CacheData.brightnessDevicePins || {};
|
const pins = SettingsData.brightnessDevicePins || {};
|
||||||
const existing = pins[pinKey];
|
const existing = pins[pinKey];
|
||||||
if (existing && existing !== deviceName) {
|
if (existing && existing !== deviceName) {
|
||||||
const next = JSON.parse(JSON.stringify(pins));
|
const next = JSON.parse(JSON.stringify(pins));
|
||||||
delete next[pinKey];
|
delete next[pinKey];
|
||||||
CacheData.set("brightnessDevicePins", next);
|
SettingsData.set("brightnessDevicePins", next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
root.currentDeviceName = deviceName;
|
root.currentDeviceName = deviceName;
|
||||||
@@ -106,7 +106,7 @@ Rectangle {
|
|||||||
const pinKey = getScreenPinKey();
|
const pinKey = getScreenPinKey();
|
||||||
if (!pinKey || !deviceName)
|
if (!pinKey || !deviceName)
|
||||||
return false;
|
return false;
|
||||||
const pins = CacheData.brightnessDevicePins || {};
|
const pins = SettingsData.brightnessDevicePins || {};
|
||||||
return pins[pinKey] === deviceName;
|
return pins[pinKey] === deviceName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,13 +114,13 @@ Rectangle {
|
|||||||
const pinKey = getScreenPinKey();
|
const pinKey = getScreenPinKey();
|
||||||
if (!pinKey || !deviceName)
|
if (!pinKey || !deviceName)
|
||||||
return;
|
return;
|
||||||
const pins = JSON.parse(JSON.stringify(CacheData.brightnessDevicePins || {}));
|
const pins = JSON.parse(JSON.stringify(SettingsData.brightnessDevicePins || {}));
|
||||||
if (pins[pinKey] === deviceName) {
|
if (pins[pinKey] === deviceName) {
|
||||||
delete pins[pinKey];
|
delete pins[pinKey];
|
||||||
} else {
|
} else {
|
||||||
pins[pinKey] = deviceName;
|
pins[pinKey] = deviceName;
|
||||||
}
|
}
|
||||||
CacheData.set("brightnessDevicePins", pins);
|
SettingsData.set("brightnessDevicePins", pins);
|
||||||
}
|
}
|
||||||
|
|
||||||
implicitHeight: {
|
implicitHeight: {
|
||||||
@@ -269,7 +269,7 @@ Rectangle {
|
|||||||
|
|
||||||
readonly property bool selected: !!(modelData && modelData.name === root.currentDeviceName)
|
readonly property bool selected: !!(modelData && modelData.name === root.currentDeviceName)
|
||||||
readonly property bool devicePinnedHere: {
|
readonly property bool devicePinnedHere: {
|
||||||
CacheData.brightnessDevicePins;
|
SettingsData.brightnessDevicePins;
|
||||||
return root.isDevicePinnedToScreen(modelData ? modelData.name : "");
|
return root.isDevicePinnedToScreen(modelData ? modelData.name : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPinnedNetworks() {
|
function getPinnedNetworks() {
|
||||||
const pins = CacheData.wifiNetworkPins || {};
|
const pins = SettingsData.wifiNetworkPins || {};
|
||||||
return normalizePinList(pins["preferredWifi"]);
|
return normalizePinList(pins["preferredWifi"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,7 +710,7 @@ Rectangle {
|
|||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
|
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
|
||||||
onClicked: {
|
onClicked: {
|
||||||
const pins = JSON.parse(JSON.stringify(CacheData.wifiNetworkPins || {}));
|
const pins = JSON.parse(JSON.stringify(SettingsData.wifiNetworkPins || {}));
|
||||||
let pinnedList = root.normalizePinList(pins["preferredWifi"]);
|
let pinnedList = root.normalizePinList(pins["preferredWifi"]);
|
||||||
const pinIndex = pinnedList.indexOf(modelData.ssid);
|
const pinIndex = pinnedList.indexOf(modelData.ssid);
|
||||||
|
|
||||||
@@ -727,7 +727,7 @@ Rectangle {
|
|||||||
else
|
else
|
||||||
delete pins["preferredWifi"];
|
delete pins["preferredWifi"];
|
||||||
|
|
||||||
CacheData.set("wifiNetworkPins", pins);
|
SettingsData.set("wifiNetworkPins", pins);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Row {
|
|||||||
if (screenName && screenName.length > 0) {
|
if (screenName && screenName.length > 0) {
|
||||||
const screen = Quickshell.screens.find(s => s.name === screenName);
|
const screen = Quickshell.screens.find(s => s.name === screenName);
|
||||||
const pinKey = screen ? SettingsData.getScreenDisplayName(screen) : screenName;
|
const pinKey = screen ? SettingsData.getScreenDisplayName(screen) : screenName;
|
||||||
const pins = CacheData.brightnessDevicePins || {};
|
const pins = SettingsData.brightnessDevicePins || {};
|
||||||
const pinnedDevice = pins[pinKey];
|
const pinnedDevice = pins[pinKey];
|
||||||
if (pinnedDevice && pinnedDevice.length > 0) {
|
if (pinnedDevice && pinnedDevice.length > 0) {
|
||||||
const found = DisplayService.devices.find(dev => dev.name === pinnedDevice);
|
const found = DisplayService.devices.find(dev => dev.name === pinnedDevice);
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ Item {
|
|||||||
property var centerWidgets: []
|
property var centerWidgets: []
|
||||||
property int totalWidgets: 0
|
property int totalWidgets: 0
|
||||||
property real totalSize: 0
|
property real totalSize: 0
|
||||||
property real contentStart: 0
|
|
||||||
property real contentSize: 0
|
|
||||||
|
|
||||||
function updateLayout() {
|
function updateLayout() {
|
||||||
if (SettingsData.centeringMode === "geometric") {
|
if (SettingsData.centeringMode === "geometric") {
|
||||||
@@ -38,25 +36,6 @@ Item {
|
|||||||
} else {
|
} else {
|
||||||
applyIndexLayout();
|
applyIndexLayout();
|
||||||
}
|
}
|
||||||
updateContentExtent();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateContentExtent() {
|
|
||||||
if (centerWidgets.length === 0) {
|
|
||||||
contentStart = 0;
|
|
||||||
contentSize = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let start = Infinity;
|
|
||||||
let end = -Infinity;
|
|
||||||
for (const widget of centerWidgets) {
|
|
||||||
const pos = isVertical ? widget.y : widget.x;
|
|
||||||
const size = isVertical ? widget.height : widget.width;
|
|
||||||
start = Math.min(start, pos);
|
|
||||||
end = Math.max(end, pos + size);
|
|
||||||
}
|
|
||||||
contentStart = start;
|
|
||||||
contentSize = end - start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyGeometricLayout() {
|
function applyGeometricLayout() {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Item {
|
|||||||
readonly property real _barInsetPaddingAuto: _barIsVertical ? Theme.spacingXS : _edgeBaseMargin
|
readonly property real _barInsetPaddingAuto: _barIsVertical ? Theme.spacingXS : _edgeBaseMargin
|
||||||
readonly property real _barInsetPadding: _barInsetPaddingRaw < 0 ? _barInsetPaddingAuto : _barInsetPaddingRaw
|
readonly property real _barInsetPadding: _barInsetPaddingRaw < 0 ? _barInsetPaddingAuto : _barInsetPaddingRaw
|
||||||
// Connected-frame Bar Inset Padding: absolute free-end inset (auto < 0 = frameThickness, 0 = edge-to-edge).
|
// Connected-frame Bar Inset Padding: absolute free-end inset (auto < 0 = frameThickness, 0 = edge-to-edge).
|
||||||
// FrameExclusions already moves a free bar end inward by frameThickness so use frame inset to mangage the gap
|
// Any amount beyond the frameThickness baseline is also added at the bar-clearance ends (all ends).
|
||||||
readonly property real _frameInsetResolved: SettingsData.frameBarInsetPadding < 0 ? SettingsData.frameThickness : SettingsData.frameBarInsetPadding
|
readonly property real _frameInsetResolved: SettingsData.frameBarInsetPadding < 0 ? SettingsData.frameThickness : SettingsData.frameBarInsetPadding
|
||||||
readonly property real _frameInsetExtra: Math.max(0, _frameInsetResolved - SettingsData.frameThickness)
|
readonly property real _frameInsetExtra: Math.max(0, _frameInsetResolved - SettingsData.frameThickness)
|
||||||
|
|
||||||
@@ -47,28 +47,28 @@ Item {
|
|||||||
if (_barIsVertical)
|
if (_barIsVertical)
|
||||||
return _edgeBaseMargin;
|
return _edgeBaseMargin;
|
||||||
if (_usesFrameBarChrome)
|
if (_usesFrameBarChrome)
|
||||||
return hasAdjacentLeftBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : _frameInsetExtra;
|
return hasAdjacentLeftBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
|
||||||
return Math.max(0, _barInsetPadding);
|
return Math.max(0, _barInsetPadding);
|
||||||
}
|
}
|
||||||
readonly property real _rightMargin: {
|
readonly property real _rightMargin: {
|
||||||
if (_barIsVertical)
|
if (_barIsVertical)
|
||||||
return _edgeBaseMargin;
|
return _edgeBaseMargin;
|
||||||
if (_usesFrameBarChrome)
|
if (_usesFrameBarChrome)
|
||||||
return hasAdjacentRightBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : _frameInsetExtra;
|
return hasAdjacentRightBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
|
||||||
return Math.max(0, _barInsetPadding);
|
return Math.max(0, _barInsetPadding);
|
||||||
}
|
}
|
||||||
readonly property real _topMargin: {
|
readonly property real _topMargin: {
|
||||||
if (!_barIsVertical)
|
if (!_barIsVertical)
|
||||||
return 0;
|
return 0;
|
||||||
if (_usesFrameBarChrome)
|
if (_usesFrameBarChrome)
|
||||||
return hasAdjacentTopBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : _frameInsetExtra;
|
return hasAdjacentTopBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
|
||||||
return Math.max(0, _barInsetPadding);
|
return Math.max(0, _barInsetPadding);
|
||||||
}
|
}
|
||||||
readonly property real _bottomMargin: {
|
readonly property real _bottomMargin: {
|
||||||
if (!_barIsVertical)
|
if (!_barIsVertical)
|
||||||
return 0;
|
return 0;
|
||||||
if (_usesFrameBarChrome)
|
if (_usesFrameBarChrome)
|
||||||
return hasAdjacentBottomBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : _frameInsetExtra;
|
return hasAdjacentBottomBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
|
||||||
return Math.max(0, _barInsetPadding);
|
return Math.max(0, _barInsetPadding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -833,30 +833,16 @@ PanelWindow {
|
|||||||
const pos = section.mapToItem(barWindow.contentItem, 0, 0);
|
const pos = section.mapToItem(barWindow.contentItem, 0, 0);
|
||||||
const implW = section.implicitWidth || 0;
|
const implW = section.implicitWidth || 0;
|
||||||
const implH = section.implicitHeight || 0;
|
const implH = section.implicitHeight || 0;
|
||||||
const contentSize = isCenter ? (section.contentSize || 0) : 0;
|
|
||||||
|
|
||||||
let offsetX = isCenter && !barWindow.isVertical ? (section.width - implW) / 2 : 0;
|
const offsetX = isCenter && !barWindow.isVertical ? (section.width - implW) / 2 : 0;
|
||||||
let offsetY = !barWindow.isVertical ? (section.height - implH) / 2 : (isCenter ? (section.height - implH) / 2 : 0);
|
const offsetY = !barWindow.isVertical ? (section.height - implH) / 2 : (isCenter ? (section.height - implH) / 2 : 0);
|
||||||
let w = implW;
|
|
||||||
let h = implH;
|
|
||||||
|
|
||||||
// index centering lays content out asymmetrically; use the real extent
|
|
||||||
if (contentSize > 0) {
|
|
||||||
if (barWindow.isVertical) {
|
|
||||||
offsetY = section.contentStart;
|
|
||||||
h = contentSize;
|
|
||||||
} else {
|
|
||||||
offsetX = section.contentStart;
|
|
||||||
w = contentSize;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const edgePad = 2;
|
const edgePad = 2;
|
||||||
return {
|
return {
|
||||||
"x": pos.x + offsetX - edgePad,
|
"x": pos.x + offsetX - edgePad,
|
||||||
"y": pos.y + offsetY - edgePad,
|
"y": pos.y + offsetY - edgePad,
|
||||||
"w": w + edgePad * 2,
|
"w": implW + edgePad * 2,
|
||||||
"h": h + edgePad * 2
|
"h": implH + edgePad * 2
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ DankPopout {
|
|||||||
color: "transparent"
|
color: "transparent"
|
||||||
focus: true
|
focus: true
|
||||||
|
|
||||||
readonly property bool upgradeRunsInTerminal: SystemUpdateService.useCustomCommand || (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true)
|
readonly property bool hasTerminalBackend: (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true)
|
||||||
|
|
||||||
property int nowUnix: Math.floor(Date.now() / 1000)
|
property int nowUnix: Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
@@ -272,7 +272,7 @@ DankPopout {
|
|||||||
includeAUR: SettingsData.updaterAllowAUR,
|
includeAUR: SettingsData.updaterAllowAUR,
|
||||||
terminal: SessionData.terminalOverride
|
terminal: SessionData.terminalOverride
|
||||||
};
|
};
|
||||||
if (updaterPanel.upgradeRunsInTerminal) {
|
if (updaterPanel.hasTerminalBackend) {
|
||||||
systemUpdatePopout._reopenAfterUpgrade = true;
|
systemUpdatePopout._reopenAfterUpgrade = true;
|
||||||
SystemUpdateService.runUpdates(opts);
|
SystemUpdateService.runUpdates(opts);
|
||||||
systemUpdatePopout.close();
|
systemUpdatePopout.close();
|
||||||
@@ -336,10 +336,7 @@ DankPopout {
|
|||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
id: statusText
|
id: statusText
|
||||||
anchors.left: parent.left
|
anchors.fill: parent
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.top: parent.top
|
|
||||||
anchors.bottom: ignoredSection.top
|
|
||||||
anchors.margins: Theme.spacingM
|
anchors.margins: Theme.spacingM
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
@@ -364,10 +361,7 @@ DankPopout {
|
|||||||
|
|
||||||
DankListView {
|
DankListView {
|
||||||
id: packagesList
|
id: packagesList
|
||||||
anchors.left: parent.left
|
anchors.fill: parent
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.top: parent.top
|
|
||||||
anchors.bottom: ignoredSection.top
|
|
||||||
anchors.margins: Theme.spacingS
|
anchors.margins: Theme.spacingS
|
||||||
visible: !SystemUpdateService.isUpgrading && SystemUpdateService.updateCount > 0 && !SystemUpdateService.hasError && !SystemUpdateService.isChecking
|
visible: !SystemUpdateService.isUpgrading && SystemUpdateService.updateCount > 0 && !SystemUpdateService.hasError && !SystemUpdateService.isChecking
|
||||||
clip: true
|
clip: true
|
||||||
@@ -473,128 +467,18 @@ DankPopout {
|
|||||||
iconName: "visibility_off"
|
iconName: "visibility_off"
|
||||||
iconSize: 16
|
iconSize: 16
|
||||||
iconColor: Theme.surfaceVariantText
|
iconColor: Theme.surfaceVariantText
|
||||||
visible: rowHoverHandler.hovered && SystemUpdateService.canIgnorePackage(packageRow.modelData)
|
visible: rowHoverHandler.hovered
|
||||||
tooltipText: I18n.tr("Ignore this package")
|
tooltipText: I18n.tr("Ignore this package")
|
||||||
onClicked: SystemUpdateService.ignorePackage(packageRow.modelData.name)
|
onClicked: SystemUpdateService.ignorePackage(packageRow.modelData.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
|
||||||
id: ignoredSection
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.bottom: parent.bottom
|
|
||||||
anchors.margins: Theme.spacingS
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
readonly property var ignoredNames: SettingsData.updaterIgnoredPackages || []
|
|
||||||
readonly property bool shown: ignoredNames.length > 0 && !SystemUpdateService.isUpgrading && !SystemUpdateService.isChecking
|
|
||||||
property bool expanded: false
|
|
||||||
|
|
||||||
visible: shown
|
|
||||||
height: shown ? implicitHeight : 0
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: ignoredToggle
|
|
||||||
width: parent.width
|
|
||||||
height: 32
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: ignoredToggleArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
id: ignoredToggleIcon
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
name: "visibility_off"
|
|
||||||
size: 16
|
|
||||||
color: Theme.surfaceVariantText
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.left: ignoredToggleIcon.right
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: I18n.tr("Ignored (%1)").arg(ignoredSection.ignoredNames.length)
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceVariantText
|
|
||||||
}
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
name: ignoredSection.expanded ? "expand_less" : "expand_more"
|
|
||||||
size: 16
|
|
||||||
color: Theme.surfaceVariantText
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: ignoredToggleArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: ignoredSection.expanded = !ignoredSection.expanded
|
|
||||||
}
|
|
||||||
|
|
||||||
Behavior on color {
|
|
||||||
ColorAnimation {
|
|
||||||
duration: Theme.shortDuration
|
|
||||||
easing.type: Theme.standardEasing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankListView {
|
|
||||||
width: parent.width
|
|
||||||
height: ignoredSection.expanded ? Math.min(contentHeight, 150) : 0
|
|
||||||
visible: ignoredSection.expanded
|
|
||||||
clip: true
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
model: ignoredSection.ignoredNames
|
|
||||||
|
|
||||||
delegate: Rectangle {
|
|
||||||
id: ignoredRow
|
|
||||||
width: ListView.view.width
|
|
||||||
height: 32
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: Theme.surfaceLight
|
|
||||||
|
|
||||||
required property string modelData
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: Theme.spacingM
|
|
||||||
anchors.right: restoreButton.left
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: ignoredRow.modelData
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
|
|
||||||
DankActionButton {
|
|
||||||
id: restoreButton
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingXS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
buttonSize: 24
|
|
||||||
iconName: "visibility"
|
|
||||||
iconSize: 16
|
|
||||||
iconColor: Theme.surfaceVariantText
|
|
||||||
tooltipText: I18n.tr("Stop ignoring %1").arg(ignoredRow.modelData)
|
|
||||||
onClicked: SystemUpdateService.unignorePackage(ignoredRow.modelData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: Theme.spacingM
|
anchors.margins: Theme.spacingM
|
||||||
spacing: Theme.spacingS
|
spacing: Theme.spacingS
|
||||||
visible: SystemUpdateService.isUpgrading && updaterPanel.upgradeRunsInTerminal
|
visible: SystemUpdateService.isUpgrading && updaterPanel.hasTerminalBackend
|
||||||
|
|
||||||
DankIcon {
|
DankIcon {
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
@@ -625,7 +509,7 @@ DankPopout {
|
|||||||
DankFlickable {
|
DankFlickable {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: Theme.spacingM
|
anchors.margins: Theme.spacingM
|
||||||
visible: SystemUpdateService.isUpgrading && !updaterPanel.upgradeRunsInTerminal
|
visible: SystemUpdateService.isUpgrading && !updaterPanel.hasTerminalBackend
|
||||||
contentWidth: width
|
contentWidth: width
|
||||||
contentHeight: logText.implicitHeight
|
contentHeight: logText.implicitHeight
|
||||||
clip: true
|
clip: true
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ BasePill {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onPressed: function (mouse) {
|
onPressed: mouse => root.triggerRipple(this, mouse.x, mouse.y)
|
||||||
root.triggerRipple(this, mouse.x, mouse.y);
|
onClicked: function (mouse) {
|
||||||
switch (mouse.button) {
|
switch (mouse.button) {
|
||||||
case Qt.RightButton:
|
case Qt.RightButton:
|
||||||
openContextMenu();
|
openContextMenu();
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ BasePill {
|
|||||||
const pinKey = getScreenPinKey();
|
const pinKey = getScreenPinKey();
|
||||||
if (!pinKey)
|
if (!pinKey)
|
||||||
return "";
|
return "";
|
||||||
const pins = CacheData.brightnessDevicePins || {};
|
const pins = SettingsData.brightnessDevicePins || {};
|
||||||
return pins[pinKey] || "";
|
return pins[pinKey] || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,33 @@ BasePill {
|
|||||||
readonly property bool _hoverPreview: MprisController.isFirefoxYoutubeHoverPreview(activePlayer)
|
readonly property bool _hoverPreview: MprisController.isFirefoxYoutubeHoverPreview(activePlayer)
|
||||||
readonly property bool _isPlaying: !!activePlayer && activePlayer.playbackState === 1 && !_hoverPreview
|
readonly property bool _isPlaying: !!activePlayer && activePlayer.playbackState === 1 && !_hoverPreview
|
||||||
|
|
||||||
|
property string _stableTitle: ""
|
||||||
|
property string _stableArtist: ""
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: root.activePlayer
|
||||||
|
function onTrackTitleChanged() {
|
||||||
|
root._syncMeta();
|
||||||
|
}
|
||||||
|
function onTrackArtistChanged() {
|
||||||
|
root._syncMeta();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onActivePlayerChanged: _syncMeta()
|
||||||
|
|
||||||
|
function _syncMeta() {
|
||||||
|
if (!activePlayer) {
|
||||||
|
_stableTitle = "";
|
||||||
|
_stableArtist = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (MprisController.isFirefoxYoutubeHoverPreview(activePlayer))
|
||||||
|
return;
|
||||||
|
_stableTitle = activePlayer.trackTitle || "";
|
||||||
|
_stableArtist = activePlayer.trackArtist || "";
|
||||||
|
}
|
||||||
|
|
||||||
readonly property bool __isChromeBrowser: {
|
readonly property bool __isChromeBrowser: {
|
||||||
if (!activePlayer?.identity)
|
if (!activePlayer?.identity)
|
||||||
return false;
|
return false;
|
||||||
@@ -102,7 +129,7 @@ BasePill {
|
|||||||
if (deltaY > 0) {
|
if (deltaY > 0) {
|
||||||
MprisController.previousOrRewind();
|
MprisController.previousOrRewind();
|
||||||
} else {
|
} else {
|
||||||
MprisController.next();
|
activePlayer.next();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scrollAccumulatorY += deltaY;
|
scrollAccumulatorY += deltaY;
|
||||||
@@ -110,7 +137,7 @@ BasePill {
|
|||||||
if (scrollAccumulatorY > 0) {
|
if (scrollAccumulatorY > 0) {
|
||||||
MprisController.previousOrRewind();
|
MprisController.previousOrRewind();
|
||||||
} else {
|
} else {
|
||||||
MprisController.next();
|
activePlayer.next();
|
||||||
}
|
}
|
||||||
scrollAccumulatorY = 0;
|
scrollAccumulatorY = 0;
|
||||||
}
|
}
|
||||||
@@ -239,7 +266,7 @@ BasePill {
|
|||||||
} else if (mouse.button === Qt.MiddleButton) {
|
} else if (mouse.button === Qt.MiddleButton) {
|
||||||
MprisController.previousOrRewind();
|
MprisController.previousOrRewind();
|
||||||
} else if (mouse.button === Qt.RightButton) {
|
} else if (mouse.button === Qt.RightButton) {
|
||||||
MprisController.next();
|
activePlayer.next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,10 +310,10 @@ BasePill {
|
|||||||
readonly property bool isWebMedia: lowerIdentity.includes("firefox") || lowerIdentity.includes("chrome") || lowerIdentity.includes("chromium") || lowerIdentity.includes("edge") || lowerIdentity.includes("safari")
|
readonly property bool isWebMedia: lowerIdentity.includes("firefox") || lowerIdentity.includes("chrome") || lowerIdentity.includes("chromium") || lowerIdentity.includes("edge") || lowerIdentity.includes("safari")
|
||||||
|
|
||||||
property string displayText: {
|
property string displayText: {
|
||||||
if (!activePlayer || !MprisController.stableTitle)
|
if (!activePlayer || !root._stableTitle)
|
||||||
return "";
|
return "";
|
||||||
const title = MprisController.stableTitle;
|
const title = isWebMedia ? root._stableTitle : (root._stableTitle || "Unknown Track");
|
||||||
const subtitle = isWebMedia ? (MprisController.stableArtist || cachedIdentity) : MprisController.stableArtist;
|
const subtitle = isWebMedia ? (root._stableArtist || cachedIdentity) : (root._stableArtist || "");
|
||||||
return subtitle.length > 0 ? title + " • " + subtitle : title;
|
return subtitle.length > 0 ? title + " • " + subtitle : title;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,45 +344,9 @@ BasePill {
|
|||||||
id: mediaText
|
id: mediaText
|
||||||
readonly property bool onScreen: Window.window?.visible ?? false
|
readonly property bool onScreen: Window.window?.visible ?? false
|
||||||
property bool needsScrolling: implicitWidth > textContainer.width && SettingsData.scrollTitleEnabled
|
property bool needsScrolling: implicitWidth > textContainer.width && SettingsData.scrollTitleEnabled
|
||||||
readonly property bool scrollActive: needsScrolling && textContainer.visible && onScreen && root._isPlaying
|
|
||||||
readonly property real maxScrollOffset: Math.max(0, implicitWidth - textContainer.width + 5)
|
|
||||||
property real scrollOffset: 0
|
property real scrollOffset: 0
|
||||||
property int scrollDirection: 1
|
|
||||||
property real scrollHoldMs: 2000
|
|
||||||
property real textShift: 0
|
property real textShift: 0
|
||||||
|
|
||||||
function resetScroll() {
|
|
||||||
scrollOffset = 0;
|
|
||||||
scrollDirection = 1;
|
|
||||||
scrollHoldMs = 2000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepScroll(deltaMs) {
|
|
||||||
if (scrollHoldMs > 0) {
|
|
||||||
scrollHoldMs -= deltaMs;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = scrollOffset + scrollDirection * deltaMs / 60;
|
|
||||||
if (next >= maxScrollOffset) {
|
|
||||||
scrollOffset = maxScrollOffset;
|
|
||||||
scrollDirection = -1;
|
|
||||||
scrollHoldMs = 2000;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (next <= 0) {
|
|
||||||
scrollOffset = 0;
|
|
||||||
scrollDirection = 1;
|
|
||||||
scrollHoldMs = 2000;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollOffset = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
onScrollActiveChanged: {
|
|
||||||
if (!scrollActive)
|
|
||||||
resetScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: textContainer.displayText
|
text: textContainer.displayText
|
||||||
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
||||||
@@ -365,38 +356,40 @@ BasePill {
|
|||||||
opacity: 1
|
opacity: 1
|
||||||
|
|
||||||
onTextChanged: {
|
onTextChanged: {
|
||||||
resetScroll();
|
scrollOffset = 0;
|
||||||
textShift = 0;
|
textShift = 0;
|
||||||
textChangeAnimation.restart();
|
textChangeAnimation.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timer stepping, not NumberAnimation — a running animation commits frames every vsync (#2863).
|
SequentialAnimation {
|
||||||
// When cava frames are already driving renders, scroll steps ride those ticks instead —
|
id: scrollAnimation
|
||||||
// two unsynchronized tick sources nearly double the surface commit rate (#2863).
|
running: mediaText.needsScrolling && textContainer.visible && mediaText.onScreen && root._isPlaying
|
||||||
Timer {
|
loops: Animation.Infinite
|
||||||
id: scrollTimer
|
onStopped: mediaText.scrollOffset = 0
|
||||||
|
|
||||||
interval: 60
|
PauseAnimation {
|
||||||
repeat: true
|
duration: 2000
|
||||||
running: mediaText.scrollActive
|
|
||||||
onTriggered: {
|
|
||||||
if (cavaTickWatch.running)
|
|
||||||
return;
|
|
||||||
mediaText.stepScroll(60);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
NumberAnimation {
|
||||||
id: cavaTickWatch
|
target: mediaText
|
||||||
interval: 150
|
property: "scrollOffset"
|
||||||
|
from: 0
|
||||||
|
to: mediaText.implicitWidth - textContainer.width + 5
|
||||||
|
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
|
||||||
|
easing.type: Easing.Linear
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
PauseAnimation {
|
||||||
target: CavaService
|
duration: 2000
|
||||||
enabled: mediaText.scrollActive && SettingsData.audioVisualizerEnabled && CavaService.cavaAvailable
|
}
|
||||||
function onValuesChanged() {
|
|
||||||
cavaTickWatch.restart();
|
NumberAnimation {
|
||||||
mediaText.stepScroll(40);
|
target: mediaText
|
||||||
|
property: "scrollOffset"
|
||||||
|
to: 0
|
||||||
|
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
|
||||||
|
easing.type: Easing.Linear
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +520,7 @@ BasePill {
|
|||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (activePlayer) {
|
if (activePlayer) {
|
||||||
MprisController.next();
|
activePlayer.next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,42 +35,44 @@ BasePill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onRightClicked: {
|
onRightClicked: (rx, ry) => {
|
||||||
const screen = root.parentScreen || Screen;
|
const screen = root.parentScreen || Screen;
|
||||||
if (!screen)
|
if (!screen)
|
||||||
return;
|
return;
|
||||||
|
const globalPos = root.visualContent.mapToItem(null, 0, 0);
|
||||||
const isVertical = root.axis?.isVertical ?? false;
|
const isVertical = root.axis?.isVertical ?? false;
|
||||||
const edge = root.axis?.edge ?? "top";
|
const edge = root.axis?.edge ?? "top";
|
||||||
const gap = Math.max(Theme.spacingXS, root.barSpacing ?? Theme.spacingXS);
|
const gap = Math.max(Theme.spacingXS, root.barSpacing ?? Theme.spacingXS);
|
||||||
const barOffset = root.barThickness + root.barSpacing + gap;
|
const barOffset = root.barThickness + root.barSpacing + gap;
|
||||||
const localPos = root.visualContent.mapToItem(null, root.visualContent.width / 2, root.visualContent.height / 2);
|
|
||||||
|
|
||||||
let anchorX;
|
let anchorX;
|
||||||
let anchorY;
|
let anchorY;
|
||||||
|
let anchorEdge;
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
anchorX = edge === "left" ? barOffset : screen.width - barOffset;
|
anchorY = globalPos.y - (screen.y || 0) + root.visualContent.height / 2;
|
||||||
anchorY = localPos.y;
|
if (edge === "left") {
|
||||||
|
anchorX = barOffset;
|
||||||
|
anchorEdge = "top";
|
||||||
} else {
|
} else {
|
||||||
anchorX = localPos.x;
|
anchorX = screen.width - barOffset;
|
||||||
anchorY = edge === "bottom" ? screen.height - barOffset : barOffset;
|
anchorEdge = "top";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
anchorX = globalPos.x - (screen.x || 0) + root.visualContent.width / 2;
|
||||||
|
if (edge === "bottom") {
|
||||||
|
anchorY = screen.height - barOffset;
|
||||||
|
anchorEdge = "bottom";
|
||||||
|
} else {
|
||||||
|
anchorY = barOffset;
|
||||||
|
anchorEdge = "top";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dndPopupLoader.active = true;
|
dndPopupLoader.active = true;
|
||||||
const popup = dndPopupLoader.item;
|
const popup = dndPopupLoader.item;
|
||||||
if (!popup)
|
if (!popup)
|
||||||
return;
|
return;
|
||||||
|
popup.showAt(anchorX, anchorY, screen, anchorEdge);
|
||||||
popup.showAt(anchorX, anchorY, isVertical, edge, screen);
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
acceptedButtons: Qt.MiddleButton
|
|
||||||
onPressed: mouse => {
|
|
||||||
root.triggerRipple(this, mouse.x, mouse.y);
|
|
||||||
SessionData.setDoNotDisturb(!SessionData.doNotDisturb);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Loader {
|
Loader {
|
||||||
|
|||||||
@@ -1250,8 +1250,13 @@ BasePill {
|
|||||||
|
|
||||||
if (root.isVerticalOrientation) {
|
if (root.isVerticalOrientation) {
|
||||||
const edge = root.axis?.edge;
|
const edge = root.axis?.edge;
|
||||||
const targetX = edge === "left" ? overflowMenu.anchorPos.x : overflowMenu.anchorPos.x - alignedWidth;
|
if (edge === "left") {
|
||||||
return Math.max(10, Math.min(overflowMenu.width - alignedWidth - 10, targetX));
|
const targetX = overflowMenu.anchorPos.x;
|
||||||
|
return Math.max(left, Math.min(right, targetX));
|
||||||
|
} else {
|
||||||
|
const targetX = overflowMenu.anchorPos.x - alignedWidth;
|
||||||
|
return Math.max(left, Math.min(right, targetX));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const want = overflowMenu.anchorPos.x - alignedWidth / 2;
|
const want = overflowMenu.anchorPos.x - alignedWidth / 2;
|
||||||
return Math.max(left, Math.min(right, want));
|
return Math.max(left, Math.min(right, want));
|
||||||
@@ -1266,8 +1271,13 @@ BasePill {
|
|||||||
const want = overflowMenu.anchorPos.y - alignedHeight / 2;
|
const want = overflowMenu.anchorPos.y - alignedHeight / 2;
|
||||||
return Math.max(top, Math.min(bottom, want));
|
return Math.max(top, Math.min(bottom, want));
|
||||||
} else {
|
} else {
|
||||||
const targetY = root.isAtBottom ? overflowMenu.anchorPos.y - alignedHeight : overflowMenu.anchorPos.y;
|
if (root.isAtBottom) {
|
||||||
return Math.max(10, Math.min(overflowMenu.height - alignedHeight - 10, targetY));
|
const targetY = overflowMenu.anchorPos.y - alignedHeight;
|
||||||
|
return Math.max(top, Math.min(bottom, targetY));
|
||||||
|
} else {
|
||||||
|
const targetY = overflowMenu.anchorPos.y;
|
||||||
|
return Math.max(top, Math.min(bottom, targetY));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})(), overflowMenu.dpr)
|
})(), overflowMenu.dpr)
|
||||||
|
|
||||||
@@ -1742,11 +1752,7 @@ BasePill {
|
|||||||
id: trayMenuContainer
|
id: trayMenuContainer
|
||||||
|
|
||||||
readonly property real rawWidth: Math.min(500, Math.max(250, menuColumn.implicitWidth + Theme.spacingS * 2))
|
readonly property real rawWidth: Math.min(500, Math.max(250, menuColumn.implicitWidth + Theme.spacingS * 2))
|
||||||
readonly property real rawHeight: {
|
readonly property real rawHeight: Math.max(40, menuColumn.implicitHeight + Theme.spacingS * 2)
|
||||||
const desiredHeight = Math.max(40, menuColumn.implicitHeight + Theme.spacingS * 2);
|
|
||||||
const maxHeight = Math.max(40, menuWindow.maskHeight - 20);
|
|
||||||
return Math.min(desiredHeight, maxHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property real alignedWidth: Theme.px(rawWidth, menuWindow.dpr)
|
readonly property real alignedWidth: Theme.px(rawWidth, menuWindow.dpr)
|
||||||
readonly property real alignedHeight: Theme.px(rawHeight, menuWindow.dpr)
|
readonly property real alignedHeight: Theme.px(rawHeight, menuWindow.dpr)
|
||||||
@@ -1760,8 +1766,13 @@ BasePill {
|
|||||||
|
|
||||||
if (menuRoot.isVertical) {
|
if (menuRoot.isVertical) {
|
||||||
const edge = menuRoot.axis?.edge;
|
const edge = menuRoot.axis?.edge;
|
||||||
const targetX = edge === "left" ? menuWindow.anchorPos.x : menuWindow.anchorPos.x - alignedWidth;
|
if (edge === "left") {
|
||||||
return Math.max(10, Math.min(menuWindow.width - alignedWidth - 10, targetX));
|
const targetX = menuWindow.anchorPos.x;
|
||||||
|
return Math.max(left, Math.min(right, targetX));
|
||||||
|
} else {
|
||||||
|
const targetX = menuWindow.anchorPos.x - alignedWidth;
|
||||||
|
return Math.max(left, Math.min(right, targetX));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const want = menuWindow.anchorPos.x - alignedWidth / 2;
|
const want = menuWindow.anchorPos.x - alignedWidth / 2;
|
||||||
return Math.max(left, Math.min(right, want));
|
return Math.max(left, Math.min(right, want));
|
||||||
@@ -1776,8 +1787,13 @@ BasePill {
|
|||||||
const want = menuWindow.anchorPos.y - alignedHeight / 2;
|
const want = menuWindow.anchorPos.y - alignedHeight / 2;
|
||||||
return Math.max(top, Math.min(bottom, want));
|
return Math.max(top, Math.min(bottom, want));
|
||||||
} else {
|
} else {
|
||||||
const targetY = menuRoot.isAtBottom ? menuWindow.anchorPos.y - alignedHeight : menuWindow.anchorPos.y;
|
if (menuRoot.isAtBottom) {
|
||||||
return Math.max(10, Math.min(menuWindow.height - alignedHeight - 10, targetY));
|
const targetY = menuWindow.anchorPos.y - alignedHeight;
|
||||||
|
return Math.max(top, Math.min(bottom, targetY));
|
||||||
|
} else {
|
||||||
|
const targetY = menuWindow.anchorPos.y;
|
||||||
|
return Math.max(top, Math.min(bottom, targetY));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})(), menuWindow.dpr)
|
})(), menuWindow.dpr)
|
||||||
|
|
||||||
@@ -1836,19 +1852,13 @@ BasePill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DankFlickable {
|
|
||||||
id: menuFlickable
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.spacingS
|
|
||||||
contentWidth: width
|
|
||||||
contentHeight: menuColumn.implicitHeight
|
|
||||||
clip: true
|
|
||||||
interactive: contentHeight > height
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: menuColumn
|
id: menuColumn
|
||||||
|
|
||||||
width: menuFlickable.width
|
width: parent.width - Theme.spacingS * 2
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: Theme.spacingS
|
||||||
spacing: 1
|
spacing: 1
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -1871,7 +1881,7 @@ BasePill {
|
|||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
color: Theme.surfaceTextMedium
|
color: Theme.surfaceTextMedium
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
width: parent.width - Theme.spacingS * 2 - (Theme.iconSizeSmall + Theme.spacingS)
|
width: parent.width - Theme.spacingS * 2 - 24
|
||||||
}
|
}
|
||||||
|
|
||||||
DankIcon {
|
DankIcon {
|
||||||
@@ -1883,7 +1893,7 @@ BasePill {
|
|||||||
return "push_pin";
|
return "push_pin";
|
||||||
return root.isManualHiddenTrayItem(menuRoot.trayItem) ? "visibility" : "visibility_off";
|
return root.isManualHiddenTrayItem(menuRoot.trayItem) ? "visibility" : "visibility_off";
|
||||||
}
|
}
|
||||||
size: Theme.iconSizeSmall
|
size: 16
|
||||||
color: Theme.widgetTextColor
|
color: Theme.widgetTextColor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1930,7 +1940,7 @@ BasePill {
|
|||||||
|
|
||||||
DankIcon {
|
DankIcon {
|
||||||
name: "arrow_back"
|
name: "arrow_back"
|
||||||
size: Theme.iconSizeSmall
|
size: 16
|
||||||
color: Theme.widgetTextColor
|
color: Theme.widgetTextColor
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
@@ -2008,11 +2018,11 @@ BasePill {
|
|||||||
visible: !menuEntry?.isSeparator
|
visible: !menuEntry?.isSeparator
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: Theme.iconSizeSmall
|
width: 16
|
||||||
height: Theme.iconSizeSmall
|
height: 16
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0
|
visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0
|
||||||
radius: menuEntry?.buttonType === 2 ? width / 2 : 2
|
radius: menuEntry?.buttonType === 2 ? 8 : 2
|
||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: Theme.outline
|
border.color: Theme.outline
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
@@ -2029,23 +2039,23 @@ BasePill {
|
|||||||
DankIcon {
|
DankIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
name: "check"
|
name: "check"
|
||||||
size: Theme.iconSizeSmall - 6
|
size: 10
|
||||||
color: Theme.primaryText
|
color: Theme.primaryText
|
||||||
visible: menuEntry?.buttonType === 1 && menuEntry?.checkState === 2
|
visible: menuEntry?.buttonType === 1 && menuEntry?.checkState === 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
width: Theme.iconSizeSmall
|
width: 16
|
||||||
height: Theme.iconSizeSmall
|
height: 16
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
visible: (menuEntry?.icon ?? "") !== ""
|
visible: (menuEntry?.icon ?? "") !== ""
|
||||||
|
|
||||||
Image {
|
Image {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
source: menuEntry?.icon || ""
|
source: menuEntry?.icon || ""
|
||||||
sourceSize.width: Theme.iconSizeSmall
|
sourceSize.width: 16
|
||||||
sourceSize.height: Theme.iconSizeSmall
|
sourceSize.height: 16
|
||||||
fillMode: Image.PreserveAspectFit
|
fillMode: Image.PreserveAspectFit
|
||||||
smooth: true
|
smooth: true
|
||||||
}
|
}
|
||||||
@@ -2062,14 +2072,14 @@ BasePill {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
width: Theme.iconSizeSmall
|
width: 16
|
||||||
height: Theme.iconSizeSmall
|
height: 16
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
DankIcon {
|
DankIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
name: "chevron_right"
|
name: "chevron_right"
|
||||||
size: Theme.iconSizeSmall - 2
|
size: 14
|
||||||
color: Theme.widgetTextColor
|
color: Theme.widgetTextColor
|
||||||
visible: menuEntry?.hasChildren ?? false
|
visible: menuEntry?.hasChildren ?? false
|
||||||
}
|
}
|
||||||
@@ -2082,7 +2092,6 @@ BasePill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) {
|
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) {
|
||||||
if (!screen)
|
if (!screen)
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ Item {
|
|||||||
property int dropdownType: 0
|
property int dropdownType: 0
|
||||||
property var activePlayer: null
|
property var activePlayer: null
|
||||||
property var allPlayers: []
|
property var allPlayers: []
|
||||||
// Chromium keeps dead MPRIS services registered w/empty metadata; avoid listing them
|
|
||||||
readonly property var selectablePlayers: (allPlayers || []).filter(p => p && !MprisController.isIdle(p))
|
|
||||||
property point anchorPos: Qt.point(0, 0)
|
property point anchorPos: Qt.point(0, 0)
|
||||||
property bool isRightEdge: false
|
property bool isRightEdge: false
|
||||||
property var targetWindow: null
|
property var targetWindow: null
|
||||||
@@ -66,22 +64,6 @@ Item {
|
|||||||
panelExited();
|
panelExited();
|
||||||
}
|
}
|
||||||
|
|
||||||
property real _wheelAccum: 0
|
|
||||||
|
|
||||||
function volumeWheel(wheelEvent) {
|
|
||||||
if (!volumeAvailable)
|
|
||||||
return;
|
|
||||||
wheelEvent.accepted = true;
|
|
||||||
_wheelAccum += wheelEvent.angleDelta.y;
|
|
||||||
const notches = _wheelAccum > 0 ? Math.floor(_wheelAccum / 120) : Math.ceil(_wheelAccum / 120);
|
|
||||||
if (notches === 0)
|
|
||||||
return;
|
|
||||||
_wheelAccum -= notches * 120;
|
|
||||||
SessionData.suppressOSDTemporarily();
|
|
||||||
const next = currentVolume + notches * AudioService.wheelVolumeStep / 100;
|
|
||||||
root.volumeChanged(Math.max(0, Math.min(1, next)));
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property Item __activePanel: {
|
readonly property Item __activePanel: {
|
||||||
switch (dropdownType) {
|
switch (dropdownType) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -162,7 +144,6 @@ Item {
|
|||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
onEntered: panelAreaEntered()
|
onEntered: panelAreaEntered()
|
||||||
onExited: panelAreaExited()
|
onExited: panelAreaExited()
|
||||||
onWheel: wheelEvent => root.volumeWheel(wheelEvent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
@@ -222,7 +203,6 @@ Item {
|
|||||||
|
|
||||||
onEntered: panelAreaEntered()
|
onEntered: panelAreaEntered()
|
||||||
onExited: panelAreaExited()
|
onExited: panelAreaExited()
|
||||||
onWheel: wheelEvent => root.volumeWheel(wheelEvent)
|
|
||||||
onPressed: mouse => updateVolume(mouse)
|
onPressed: mouse => updateVolume(mouse)
|
||||||
onPositionChanged: mouse => {
|
onPositionChanged: mouse => {
|
||||||
if (pressed)
|
if (pressed)
|
||||||
@@ -448,7 +428,7 @@ Item {
|
|||||||
id: playersPanel
|
id: playersPanel
|
||||||
visible: dropdownType === 3
|
visible: dropdownType === 3
|
||||||
width: 240
|
width: 240
|
||||||
height: Math.max(180, Math.min(240, (root.selectablePlayers?.length || 0) * 50 + 80))
|
height: Math.max(180, Math.min(240, (allPlayers?.length || 0) * 50 + 80))
|
||||||
x: isRightEdge ? anchorPos.x : anchorPos.x - width
|
x: isRightEdge ? anchorPos.x : anchorPos.x - width
|
||||||
y: anchorPos.y - height / 2
|
y: anchorPos.y - height / 2
|
||||||
radius: Theme.cornerRadius * 2
|
radius: Theme.cornerRadius * 2
|
||||||
@@ -505,7 +485,7 @@ Item {
|
|||||||
anchors.margins: Theme.spacingM
|
anchors.margins: Theme.spacingM
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: I18n.tr("Media Players (") + (root.selectablePlayers?.length || 0) + ")"
|
text: I18n.tr("Media Players (") + (allPlayers?.length || 0) + ")"
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
color: Theme.surfaceText
|
color: Theme.surfaceText
|
||||||
@@ -526,7 +506,7 @@ Item {
|
|||||||
spacing: Theme.spacingS
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.selectablePlayers || []
|
model: allPlayers || []
|
||||||
delegate: Rectangle {
|
delegate: Rectangle {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
required property int index
|
||||||
@@ -602,4 +582,5 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ Item {
|
|||||||
playersExpanded = false;
|
playersExpanded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
readonly property bool isRightEdge: {
|
readonly property bool isRightEdge: {
|
||||||
if (barPosition === SettingsData.Position.Right)
|
if (barPosition === SettingsData.Position.Right)
|
||||||
return true;
|
return true;
|
||||||
@@ -340,26 +342,6 @@ Item {
|
|||||||
root.maybeFinishSwitch();
|
root.maybeFinishSwitch();
|
||||||
}
|
}
|
||||||
|
|
||||||
BgBlurLayer {
|
|
||||||
id: layerA
|
|
||||||
front: bgContainer._showA
|
|
||||||
onLoaded: bgContainer.promote(layerA)
|
|
||||||
}
|
|
||||||
|
|
||||||
BgBlurLayer {
|
|
||||||
id: layerB
|
|
||||||
front: !bgContainer._showA
|
|
||||||
onLoaded: bgContainer.promote(layerB)
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: Theme.surface
|
|
||||||
opacity: 0.3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
component BgBlurLayer: ClippingRectangle {
|
component BgBlurLayer: ClippingRectangle {
|
||||||
id: layer
|
id: layer
|
||||||
property alias art: layerImg.source
|
property alias art: layerImg.source
|
||||||
@@ -408,6 +390,26 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BgBlurLayer {
|
||||||
|
id: layerA
|
||||||
|
front: bgContainer._showA
|
||||||
|
onLoaded: bgContainer.promote(layerA)
|
||||||
|
}
|
||||||
|
|
||||||
|
BgBlurLayer {
|
||||||
|
id: layerB
|
||||||
|
front: !bgContainer._showA
|
||||||
|
onLoaded: bgContainer.promote(layerB)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surface
|
||||||
|
opacity: 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
spacing: Theme.spacingM
|
spacing: Theme.spacingM
|
||||||
@@ -467,7 +469,7 @@ Item {
|
|||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: MprisController.stableTitle || I18n.tr("Unknown Track")
|
text: activePlayer?.trackTitle || I18n.tr("Unknown Track")
|
||||||
font.pixelSize: Theme.fontSizeLarge
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
color: Theme.surfaceText
|
color: Theme.surfaceText
|
||||||
@@ -479,7 +481,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: MprisController.stableArtist || I18n.tr("Unknown Artist")
|
text: activePlayer?.trackArtist || I18n.tr("Unknown Artist")
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
color: Theme.surfaceTextMedium
|
color: Theme.surfaceTextMedium
|
||||||
width: parent.width
|
width: parent.width
|
||||||
@@ -660,12 +662,11 @@ Item {
|
|||||||
weight: 500
|
weight: 500
|
||||||
}
|
}
|
||||||
|
|
||||||
StateLayer {
|
MouseArea {
|
||||||
id: playPauseArea
|
anchors.fill: parent
|
||||||
disabled: !root.activePlayer || !root.activePlayer.canTogglePlaying
|
hoverEnabled: true
|
||||||
stateColor: root.onAccent
|
cursorShape: Qt.PointingHandCursor
|
||||||
cornerRadius: parent.radius
|
onClicked: activePlayer && activePlayer.togglePlaying()
|
||||||
onClicked: root.activePlayer.togglePlaying()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ElevationShadow {
|
ElevationShadow {
|
||||||
@@ -705,7 +706,7 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: MprisController.next()
|
onClicked: activePlayer && activePlayer.next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -797,17 +798,16 @@ Item {
|
|||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (playersExpanded) {
|
if (playersExpanded) {
|
||||||
const players = (root.allPlayers || []).filter(p => p && !MprisController.isIdle(p));
|
if (allPlayers && allPlayers.length > 1) {
|
||||||
if (players.length > 1) {
|
|
||||||
let currentIndex = -1;
|
let currentIndex = -1;
|
||||||
for (let i = 0; i < players.length; i++) {
|
for (let i = 0; i < allPlayers.length; i++) {
|
||||||
if (players[i] === root.activePlayer) {
|
if (allPlayers[i] === activePlayer) {
|
||||||
currentIndex = i;
|
currentIndex = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const nextIndex = (currentIndex + 1) % players.length;
|
const nextIndex = (currentIndex + 1) % allPlayers.length;
|
||||||
MprisController.setActivePlayer(players[nextIndex]);
|
MprisController.setActivePlayer(allPlayers[nextIndex]);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -884,15 +884,19 @@ Item {
|
|||||||
onClicked: {
|
onClicked: {
|
||||||
toggleMute();
|
toggleMute();
|
||||||
}
|
}
|
||||||
property real wheelAccum: 0
|
|
||||||
onWheel: wheelEvent => {
|
onWheel: wheelEvent => {
|
||||||
|
SessionData.suppressOSDTemporarily();
|
||||||
|
const delta = wheelEvent.angleDelta.y;
|
||||||
|
const current = (currentVolume * 100) || 0;
|
||||||
|
const maxVol = usePlayerVolume ? 100 : AudioService.sinkMaxVolume;
|
||||||
|
const newVolume = delta > 0 ? Math.min(maxVol, current + 5) : Math.max(0, current - 5);
|
||||||
|
|
||||||
|
if (usePlayerVolume) {
|
||||||
|
activePlayer.volume = newVolume / 100;
|
||||||
|
} else if (AudioService.sink?.audio) {
|
||||||
|
AudioService.sink.audio.volume = newVolume / 100;
|
||||||
|
}
|
||||||
wheelEvent.accepted = true;
|
wheelEvent.accepted = true;
|
||||||
wheelAccum += wheelEvent.angleDelta.y;
|
|
||||||
const notches = wheelAccum > 0 ? Math.floor(wheelAccum / 120) : Math.ceil(wheelAccum / 120);
|
|
||||||
if (notches === 0)
|
|
||||||
return;
|
|
||||||
wheelAccum -= notches * 120;
|
|
||||||
root.adjustVolume(notches * AudioService.wheelVolumeStep);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1079,7 +1079,7 @@ Rectangle {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: I18n.tr("Add a task...", "placeholder in the new-task input field")
|
text: I18n.tr("Add a task...", "placeholder in the new-task input field")
|
||||||
color: Theme.outlineButton
|
color: Theme.onSurface_38
|
||||||
visible: taskInput.text.length === 0
|
visible: taskInput.text.length === 0
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Card {
|
|||||||
topPadding: Theme.spacingL
|
topPadding: Theme.spacingL
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: MprisController.stableTitle || I18n.tr("Unknown")
|
text: activePlayer?.trackTitle || I18n.tr("Unknown")
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
color: Theme.surfaceText
|
color: Theme.surfaceText
|
||||||
@@ -99,7 +99,7 @@ Card {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: MprisController.stableArtist || I18n.tr("Unknown Artist")
|
text: activePlayer?.trackArtist || I18n.tr("Unknown Artist")
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
color: Theme.surfaceTextMedium
|
color: Theme.surfaceTextMedium
|
||||||
width: parent.width
|
width: parent.width
|
||||||
@@ -190,7 +190,7 @@ Card {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: MprisController.next()
|
onClicked: activePlayer?.next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
@@ -18,7 +19,6 @@ Card {
|
|||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
spacing: Theme.spacingS
|
spacing: Theme.spacingS
|
||||||
visible: !WeatherService.weather.available
|
visible: !WeatherService.weather.available
|
||||||
z: 1
|
|
||||||
|
|
||||||
DankSpinner {
|
DankSpinner {
|
||||||
size: 24
|
size: 24
|
||||||
@@ -42,9 +42,9 @@ Card {
|
|||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
DankButton {
|
Button {
|
||||||
text: I18n.tr("Refresh")
|
text: I18n.tr("Refresh")
|
||||||
buttonHeight: 32
|
flat: true
|
||||||
visible: !WeatherService.weather.loading
|
visible: !WeatherService.weather.loading
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
onClicked: WeatherService.forceRefresh()
|
onClicked: WeatherService.forceRefresh()
|
||||||
|
|||||||
@@ -141,27 +141,52 @@ Item {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
DankActionButton {
|
DankIcon {
|
||||||
id: refreshButtonTwo
|
id: refreshButtonTwo
|
||||||
|
name: "refresh"
|
||||||
|
size: Theme.iconSize - 4
|
||||||
|
color: Theme.withAlpha(Theme.surfaceText, 0.4)
|
||||||
|
anchors.top: parent.top
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
iconName: isRefreshing ? "" : "refresh"
|
smoothTransform: isRefreshing
|
||||||
iconColor: Theme.withAlpha(Theme.surfaceText, 0.4)
|
|
||||||
tooltipText: I18n.tr("Refresh Weather")
|
|
||||||
tooltipSide: "left"
|
|
||||||
enabled: !isRefreshing
|
|
||||||
|
|
||||||
property bool isRefreshing: false
|
property bool isRefreshing: false
|
||||||
|
enabled: !isRefreshing
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: refreshButtonMouseAreaTwo
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
|
||||||
|
enabled: parent.enabled
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: hoverDelayTwo
|
||||||
|
interval: 300
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
refreshButtonTooltipTwo.show(I18n.tr("Refresh Weather"), refreshButtonTwo, 0, 0, "left");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onEntered: {
|
||||||
|
hoverDelayTwo.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: {
|
||||||
|
hoverDelayTwo.stop();
|
||||||
|
refreshButtonTooltipTwo.hide();
|
||||||
|
}
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
isRefreshing = true;
|
refreshButtonTwo.isRefreshing = true;
|
||||||
WeatherService.forceRefresh();
|
WeatherService.forceRefresh();
|
||||||
refreshTimerTwo.restart();
|
refreshTimerTwo.restart();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DankSpinner {
|
DankTooltipV2 {
|
||||||
anchors.centerIn: parent
|
id: refreshButtonTooltipTwo
|
||||||
size: refreshButtonTwo.iconSize
|
|
||||||
visible: refreshButtonTwo.isRefreshing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
@@ -169,6 +194,14 @@ Item {
|
|||||||
interval: 2000
|
interval: 2000
|
||||||
onTriggered: refreshButtonTwo.isRefreshing = false
|
onTriggered: refreshButtonTwo.isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RotationAnimator on rotation {
|
||||||
|
running: refreshButtonTwo.isRefreshing
|
||||||
|
from: 0
|
||||||
|
to: 360
|
||||||
|
duration: 1000
|
||||||
|
loops: Animation.Infinite
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -831,28 +864,52 @@ Item {
|
|||||||
onClicked: SessionData.setWeatherHourlyDetailed(!SessionData.weatherHourlyDetailed)
|
onClicked: SessionData.setWeatherHourlyDetailed(!SessionData.weatherHourlyDetailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
DankActionButton {
|
DankIcon {
|
||||||
id: refreshButton
|
id: refreshButton
|
||||||
|
name: "refresh"
|
||||||
|
size: Theme.iconSize - 4
|
||||||
|
color: Theme.withAlpha(Theme.surfaceText, 0.4)
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
iconName: isRefreshing ? "" : "refresh"
|
smoothTransform: isRefreshing
|
||||||
iconColor: Theme.withAlpha(Theme.surfaceText, 0.4)
|
|
||||||
tooltipText: I18n.tr("Refresh Weather")
|
|
||||||
tooltipSide: "left"
|
|
||||||
enabled: !isRefreshing
|
|
||||||
|
|
||||||
property bool isRefreshing: false
|
property bool isRefreshing: false
|
||||||
|
enabled: !isRefreshing
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: refreshButtonMouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
|
||||||
|
enabled: parent.enabled
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: hoverDelay
|
||||||
|
interval: 300
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
refreshButtonTooltip.show(I18n.tr("Refresh Weather"), refreshButton, 0, 0, "left");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onEntered: {
|
||||||
|
hoverDelay.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: {
|
||||||
|
hoverDelay.stop();
|
||||||
|
refreshButtonTooltip.hide();
|
||||||
|
}
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
isRefreshing = true;
|
refreshButton.isRefreshing = true;
|
||||||
WeatherService.forceRefresh();
|
WeatherService.forceRefresh();
|
||||||
refreshTimer.restart();
|
refreshTimer.restart();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DankSpinner {
|
DankTooltipV2 {
|
||||||
anchors.centerIn: parent
|
id: refreshButtonTooltip
|
||||||
size: refreshButton.iconSize
|
|
||||||
visible: refreshButton.isRefreshing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
@@ -860,6 +917,14 @@ Item {
|
|||||||
interval: 2000
|
interval: 2000
|
||||||
onTriggered: refreshButton.isRefreshing = false
|
onTriggered: refreshButton.isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RotationAnimator on rotation {
|
||||||
|
running: refreshButton.isRefreshing
|
||||||
|
from: 0
|
||||||
|
to: 360
|
||||||
|
duration: 1000
|
||||||
|
loops: Animation.Infinite
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,7 +936,7 @@ Item {
|
|||||||
id: dailyLoader
|
id: dailyLoader
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
sourceComponent: dailyComponent
|
sourceComponent: dailyComponent
|
||||||
active: root.visible && root.available && width > 0 && height > 0
|
active: root.visible && root.available
|
||||||
visible: !root.showHourly
|
visible: !root.showHourly
|
||||||
asynchronous: true
|
asynchronous: true
|
||||||
opacity: 0
|
opacity: 0
|
||||||
@@ -888,7 +953,7 @@ Item {
|
|||||||
id: hourlyLoader
|
id: hourlyLoader
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
sourceComponent: hourlyComponent
|
sourceComponent: hourlyComponent
|
||||||
active: root.visible && root.available && width > 0 && height > 0
|
active: root.visible && root.available
|
||||||
visible: root.showHourly
|
visible: root.showHourly
|
||||||
asynchronous: true
|
asynchronous: true
|
||||||
opacity: 0
|
opacity: 0
|
||||||
@@ -973,7 +1038,6 @@ Item {
|
|||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
property real hWheelAccum: 0
|
|
||||||
onWheel: wheel => {
|
onWheel: wheel => {
|
||||||
if (wheel.modifiers & Qt.ShiftModifier) {
|
if (wheel.modifiers & Qt.ShiftModifier) {
|
||||||
if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) {
|
if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) {
|
||||||
@@ -985,15 +1049,6 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (wheel.angleDelta.x !== 0) {
|
|
||||||
hWheelAccum += wheel.angleDelta.x;
|
|
||||||
const steps = Math.trunc(hWheelAccum / 120);
|
|
||||||
hWheelAccum -= steps * 120;
|
|
||||||
if (steps !== 0)
|
|
||||||
hourlyList.currentIndex = Math.max(0, Math.min(hourlyList.model - 1, hourlyList.currentIndex - steps));
|
|
||||||
wheel.accepted = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
wheel.accepted = false;
|
wheel.accepted = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1041,7 +1096,6 @@ Item {
|
|||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
property real hWheelAccum: 0
|
|
||||||
onWheel: wheel => {
|
onWheel: wheel => {
|
||||||
if (wheel.modifiers & Qt.ShiftModifier) {
|
if (wheel.modifiers & Qt.ShiftModifier) {
|
||||||
if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) {
|
if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) {
|
||||||
@@ -1053,15 +1107,6 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (wheel.angleDelta.x !== 0) {
|
|
||||||
hWheelAccum += wheel.angleDelta.x;
|
|
||||||
const steps = Math.trunc(hWheelAccum / 120);
|
|
||||||
hWheelAccum -= steps * 120;
|
|
||||||
if (steps !== 0)
|
|
||||||
dailyList.currentIndex = Math.max(0, Math.min(dailyList.model - 1, dailyList.currentIndex - steps));
|
|
||||||
wheel.accepted = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
wheel.accepted = false;
|
wheel.accepted = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,19 +340,7 @@ Variants {
|
|||||||
return ConnectedModeState.dockRetractActiveForSide(dock._dockScreenName, dock.connectedBarSide);
|
return ConnectedModeState.dockRetractActiveForSide(dock._dockScreenName, dock.connectedBarSide);
|
||||||
}
|
}
|
||||||
|
|
||||||
property bool startupRevealDone: false
|
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: startupRevealTimer
|
|
||||||
interval: 200
|
|
||||||
running: true
|
|
||||||
onTriggered: dock.startupRevealDone = true
|
|
||||||
}
|
|
||||||
|
|
||||||
property bool reveal: {
|
property bool reveal: {
|
||||||
if (!startupRevealDone)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (_modalRetractActive)
|
if (_modalRetractActive)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -438,55 +426,20 @@ Variants {
|
|||||||
parent: dock.contentItem
|
parent: dock.contentItem
|
||||||
visible: false
|
visible: false
|
||||||
readonly property bool expanded: dock.reveal
|
readonly property bool expanded: dock.reveal
|
||||||
readonly property bool chrome: dock.usesConnectedFrameChrome
|
|
||||||
readonly property bool atEndEdge: SettingsData.dockPosition === SettingsData.Position.Bottom || SettingsData.dockPosition === SettingsData.Position.Right
|
|
||||||
readonly property real innerReach: borderThickness + (SettingsData.appsDockEnlargeOnHover ? animationHeadroom : 0)
|
|
||||||
readonly property real bodyX: dockBackground.x + dockContainer.x + dockMouseArea.x + dockCore.x + dockSlide.x
|
|
||||||
readonly property real bodyY: dockBackground.y + dockContainer.y + dockMouseArea.y + dockCore.y + dockSlide.y
|
|
||||||
x: {
|
x: {
|
||||||
if (chrome) {
|
|
||||||
const baseX = dockCore.x + dockMouseArea.x;
|
const baseX = dockCore.x + dockMouseArea.x;
|
||||||
if (isVertical && atEndEdge)
|
if (isVertical && SettingsData.dockPosition === SettingsData.Position.Right)
|
||||||
return baseX - (expanded ? animationHeadroom + borderThickness + dock.horizontalConnectorExtent : 0);
|
return baseX - (expanded ? animationHeadroom + borderThickness + dock.horizontalConnectorExtent : 0);
|
||||||
return baseX - (expanded ? borderThickness + dock.horizontalConnectorExtent : 0);
|
return baseX - (expanded ? borderThickness + dock.horizontalConnectorExtent : 0);
|
||||||
}
|
}
|
||||||
if (!isVertical)
|
|
||||||
return dockCore.x + dockMouseArea.x - (expanded ? borderThickness : 0);
|
|
||||||
if (!atEndEdge)
|
|
||||||
return 0;
|
|
||||||
return expanded ? Math.min(bodyX - innerReach, dock.width - 1) : dock.width - 1;
|
|
||||||
}
|
|
||||||
y: {
|
y: {
|
||||||
if (chrome) {
|
|
||||||
const baseY = dockCore.y + dockMouseArea.y;
|
const baseY = dockCore.y + dockMouseArea.y;
|
||||||
if (!isVertical && atEndEdge)
|
if (!isVertical && SettingsData.dockPosition === SettingsData.Position.Bottom)
|
||||||
return baseY - (expanded ? animationHeadroom + borderThickness + dock.verticalConnectorExtent : 0);
|
return baseY - (expanded ? animationHeadroom + borderThickness + dock.verticalConnectorExtent : 0);
|
||||||
return baseY - (expanded ? borderThickness + dock.verticalConnectorExtent : 0);
|
return baseY - (expanded ? borderThickness + dock.verticalConnectorExtent : 0);
|
||||||
}
|
}
|
||||||
if (isVertical)
|
width: dockMouseArea.width + (isVertical && expanded ? animationHeadroom : 0) + (expanded ? borderThickness * 2 + dock.horizontalConnectorExtent * 2 : 0)
|
||||||
return dockCore.y + dockMouseArea.y - (expanded ? borderThickness : 0);
|
height: dockMouseArea.height + (!isVertical && expanded ? animationHeadroom : 0) + (expanded ? borderThickness * 2 + dock.verticalConnectorExtent * 2 : 0)
|
||||||
if (!atEndEdge)
|
|
||||||
return 0;
|
|
||||||
return expanded ? Math.min(bodyY - innerReach, dock.height - 1) : dock.height - 1;
|
|
||||||
}
|
|
||||||
width: {
|
|
||||||
if (chrome)
|
|
||||||
return dockMouseArea.width + (isVertical && expanded ? animationHeadroom : 0) + (expanded ? borderThickness * 2 + dock.horizontalConnectorExtent * 2 : 0);
|
|
||||||
if (!isVertical)
|
|
||||||
return dockMouseArea.width + (expanded ? borderThickness * 2 : 0);
|
|
||||||
if (!expanded)
|
|
||||||
return 1;
|
|
||||||
return atEndEdge ? dock.width - x : Math.max(bodyX + dockBackground.width + innerReach, 1);
|
|
||||||
}
|
|
||||||
height: {
|
|
||||||
if (chrome)
|
|
||||||
return dockMouseArea.height + (!isVertical && expanded ? animationHeadroom : 0) + (expanded ? borderThickness * 2 + dock.verticalConnectorExtent * 2 : 0);
|
|
||||||
if (isVertical)
|
|
||||||
return dockMouseArea.height + (expanded ? borderThickness * 2 : 0);
|
|
||||||
if (!expanded)
|
|
||||||
return 1;
|
|
||||||
return atEndEdge ? dock.height - y : Math.max(bodyY + dockBackground.height + innerReach, 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mask: Region {
|
mask: Region {
|
||||||
@@ -675,7 +628,6 @@ Variants {
|
|||||||
id: dockContainer
|
id: dockContainer
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
clip: false
|
clip: false
|
||||||
opacity: dock.startupRevealDone ? 1 : 0
|
|
||||||
|
|
||||||
transform: Translate {
|
transform: Translate {
|
||||||
id: dockSlide
|
id: dockSlide
|
||||||
|
|||||||
@@ -479,8 +479,6 @@ Item {
|
|||||||
|
|
||||||
delegate: Item {
|
delegate: Item {
|
||||||
id: delegateItem
|
id: delegateItem
|
||||||
required property var modelData
|
|
||||||
required property int index
|
|
||||||
|
|
||||||
property var dockButton: {
|
property var dockButton: {
|
||||||
switch (itemData.type) {
|
switch (itemData.type) {
|
||||||
@@ -601,7 +599,7 @@ Item {
|
|||||||
height: delegateItem.height
|
height: delegateItem.height
|
||||||
actualIconSize: root.iconSize
|
actualIconSize: root.iconSize
|
||||||
dockApps: root
|
dockApps: root
|
||||||
index: delegateItem.index
|
index: model.index
|
||||||
}
|
}
|
||||||
|
|
||||||
DockTrashButton {
|
DockTrashButton {
|
||||||
@@ -626,7 +624,7 @@ Item {
|
|||||||
appData: itemData
|
appData: itemData
|
||||||
contextMenu: root.contextMenu
|
contextMenu: root.contextMenu
|
||||||
dockApps: root
|
dockApps: root
|
||||||
index: delegateItem.index
|
index: model.index
|
||||||
parentDockScreen: root.dockScreen
|
parentDockScreen: root.dockScreen
|
||||||
showWindowTitle: itemData?.type === "window" || itemData?.type === "grouped"
|
showWindowTitle: itemData?.type === "window" || itemData?.type === "grouped"
|
||||||
windowTitle: {
|
windowTitle: {
|
||||||
|
|||||||
@@ -73,14 +73,14 @@ Item {
|
|||||||
readonly property bool greeterPamHasFprint: greeterPamStackHasFprint && (!fprintdProbeComplete || fprintdHasDevice)
|
readonly property bool greeterPamHasFprint: greeterPamStackHasFprint && (!fprintdProbeComplete || fprintdHasDevice)
|
||||||
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
|
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
|
||||||
readonly property bool greeterExternalAuthAvailable: (greeterPamHasFprint && GreetdSettings.greeterEnableFprint) || (greeterPamHasU2f && GreetdSettings.greeterEnableU2f)
|
readonly property bool greeterExternalAuthAvailable: (greeterPamHasFprint && GreetdSettings.greeterEnableFprint) || (greeterPamHasU2f && GreetdSettings.greeterEnableU2f)
|
||||||
readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f
|
readonly property bool greeterPamHasExternalAuth: greeterPamStackHasFprint || greeterPamHasU2f
|
||||||
readonly property bool externalAuthInProgress: awaitingExternalAuth || (Greetd.state !== GreetdState.Inactive && passwordSubmitRequested && greeterPamHasExternalAuth && !pendingPasswordResponse)
|
readonly property bool externalAuthInProgress: awaitingExternalAuth || (Greetd.state !== GreetdState.Inactive && passwordSubmitRequested && greeterPamHasExternalAuth && !pendingPasswordResponse)
|
||||||
readonly property string externalAuthStatusMessage: {
|
readonly property string externalAuthStatusMessage: {
|
||||||
if (!externalAuthInProgress)
|
if (!externalAuthInProgress)
|
||||||
return "";
|
return "";
|
||||||
if (greeterPamHasFprint && greeterPamHasU2f)
|
if (greeterPamStackHasFprint && greeterPamHasU2f)
|
||||||
return I18n.tr("Awaiting fingerprint or security key authentication");
|
return I18n.tr("Awaiting fingerprint or security key authentication");
|
||||||
if (greeterPamHasFprint)
|
if (greeterPamStackHasFprint)
|
||||||
return I18n.tr("Awaiting fingerprint authentication");
|
return I18n.tr("Awaiting fingerprint authentication");
|
||||||
return I18n.tr("Awaiting security key authentication");
|
return I18n.tr("Awaiting security key authentication");
|
||||||
}
|
}
|
||||||
@@ -1773,7 +1773,6 @@ Item {
|
|||||||
const idx = GreeterState.sessionList.indexOf(value);
|
const idx = GreeterState.sessionList.indexOf(value);
|
||||||
if (idx < 0)
|
if (idx < 0)
|
||||||
return;
|
return;
|
||||||
GreeterState.sessionManuallySelected = true;
|
|
||||||
GreeterState.currentSessionIndex = idx;
|
GreeterState.currentSessionIndex = idx;
|
||||||
GreeterState.selectedSession = GreeterState.sessionExecs[idx];
|
GreeterState.selectedSession = GreeterState.sessionExecs[idx];
|
||||||
GreeterState.selectedSessionPath = GreeterState.sessionPaths[idx];
|
GreeterState.selectedSessionPath = GreeterState.sessionPaths[idx];
|
||||||
@@ -1786,8 +1785,6 @@ Item {
|
|||||||
property string currentSessionName: GreeterState.sessionList[GreeterState.currentSessionIndex] || ""
|
property string currentSessionName: GreeterState.sessionList[GreeterState.currentSessionIndex] || ""
|
||||||
|
|
||||||
function finalizeSessionSelection() {
|
function finalizeSessionSelection() {
|
||||||
if (GreeterState.sessionManuallySelected)
|
|
||||||
return;
|
|
||||||
if (GreeterState.sessionList.length === 0)
|
if (GreeterState.sessionList.length === 0)
|
||||||
return;
|
return;
|
||||||
if (!GreetdMemory.memoryReady)
|
if (!GreetdMemory.memoryReady)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ Singleton {
|
|||||||
property var sessionPaths: []
|
property var sessionPaths: []
|
||||||
property var sessionDesktopIds: []
|
property var sessionDesktopIds: []
|
||||||
property int currentSessionIndex: 0
|
property int currentSessionIndex: 0
|
||||||
property bool sessionManuallySelected: false
|
|
||||||
property var availableUsers: []
|
property var availableUsers: []
|
||||||
property int selectedUserIndex: -1
|
property int selectedUserIndex: -1
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import QtQuick
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Services
|
|
||||||
|
|
||||||
PanelWindow {
|
PanelWindow {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property bool active: false
|
property bool active: false
|
||||||
property bool _completed: false
|
|
||||||
|
|
||||||
signal fadeCompleted
|
signal fadeCompleted
|
||||||
signal fadeCancelled
|
signal fadeCancelled
|
||||||
@@ -37,8 +35,7 @@ PanelWindow {
|
|||||||
opacity: 0
|
opacity: 0
|
||||||
|
|
||||||
onOpacityChanged: {
|
onOpacityChanged: {
|
||||||
if (opacity >= 0.99 && root.active && !root._completed) {
|
if (opacity >= 0.99 && root.active) {
|
||||||
root._completed = true;
|
|
||||||
root.fadeCompleted();
|
root.fadeCompleted();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +58,6 @@ PanelWindow {
|
|||||||
function startFade() {
|
function startFade() {
|
||||||
if (!SettingsData.fadeToDpmsEnabled)
|
if (!SettingsData.fadeToDpmsEnabled)
|
||||||
return;
|
return;
|
||||||
_completed = false;
|
|
||||||
active = true;
|
active = true;
|
||||||
fadeOverlay.opacity = 0.0;
|
fadeOverlay.opacity = 0.0;
|
||||||
fadeSeq.stop();
|
fadeSeq.stop();
|
||||||
@@ -69,24 +65,10 @@ PanelWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancelFade() {
|
function cancelFade() {
|
||||||
dismiss();
|
|
||||||
fadeCancelled();
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismiss() {
|
|
||||||
fadeSeq.stop();
|
fadeSeq.stop();
|
||||||
fadeOverlay.opacity = 0.0;
|
fadeOverlay.opacity = 0.0;
|
||||||
active = false;
|
active = false;
|
||||||
_completed = false;
|
fadeCancelled();
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: IdleService
|
|
||||||
function onIsShellLockedChanged() {
|
|
||||||
if (IdleService.isShellLocked)
|
|
||||||
return;
|
|
||||||
root.dismiss();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
|
|||||||
@@ -27,14 +27,11 @@ Item {
|
|||||||
function hide() {
|
function hide() {
|
||||||
if (isKeyboardActive && keyboard !== null) {
|
if (isKeyboardActive && keyboard !== null) {
|
||||||
keyboard.destroy();
|
keyboard.destroy();
|
||||||
keyboard = null;
|
|
||||||
isKeyboardActive = false;
|
isKeyboardActive = false;
|
||||||
} else
|
} else
|
||||||
log.debug("The keyboard is already hidden");
|
log.debug("The keyboard is already hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onDestruction: hide()
|
|
||||||
|
|
||||||
// private
|
// private
|
||||||
property Item keyboard: null
|
property Item keyboard: null
|
||||||
Component {
|
Component {
|
||||||
|
|||||||
@@ -10,61 +10,38 @@ import qs.Services
|
|||||||
Scope {
|
Scope {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
readonly property var log: Log.scoped("Lock")
|
|
||||||
|
|
||||||
property string sharedPasswordBuffer: ""
|
property string sharedPasswordBuffer: ""
|
||||||
property bool shouldLock: false
|
property bool shouldLock: false
|
||||||
|
|
||||||
onSharedPasswordBufferChanged: {
|
|
||||||
if (!powerOffFadeTimer.running)
|
|
||||||
return;
|
|
||||||
cancelPowerOffFade();
|
|
||||||
}
|
|
||||||
|
|
||||||
onShouldLockChanged: {
|
onShouldLockChanged: {
|
||||||
IdleService.isShellLocked = shouldLock;
|
IdleService.isShellLocked = shouldLock;
|
||||||
|
if (shouldLock && lockPowerOffArmed) {
|
||||||
|
lockStateCheck.restart();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
property bool lockInitiatedLocally: false
|
Timer {
|
||||||
property bool pendingLock: false
|
id: lockStateCheck
|
||||||
readonly property int maxLockRetries: 3
|
interval: 100
|
||||||
property int lockRetryAttempts: 0
|
repeat: false
|
||||||
property bool lockRetryPending: false
|
onTriggered: {
|
||||||
property bool lockPowerOffArmed: false
|
if (sessionLock.locked && lockPowerOffArmed) {
|
||||||
property bool lockWakeAllowed: false
|
pendingLock = false;
|
||||||
property bool customLockerSpawned: false
|
|
||||||
readonly property bool powerOffOnLock: SettingsData.lockScreenPowerOffMonitorsOnLock || IdleService.lockPowerOffRequested
|
|
||||||
property real powerOffFadeTarget: 0
|
|
||||||
property bool powerOffFadeInstant: false
|
|
||||||
|
|
||||||
function beginPowerOff() {
|
|
||||||
if (!SettingsData.fadeToDpmsEnabled || SettingsData.fadeToDpmsGracePeriod <= 0) {
|
|
||||||
applyMonitorsOff();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
powerOffFadeInstant = false;
|
|
||||||
powerOffFadeTarget = 1;
|
|
||||||
powerOffFadeTimer.restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMonitorsOff() {
|
|
||||||
IdleService.monitorsOff = true;
|
IdleService.monitorsOff = true;
|
||||||
CompositorService.powerOffMonitors();
|
CompositorService.powerOffMonitors();
|
||||||
lockWakeAllowed = false;
|
lockWakeAllowed = false;
|
||||||
lockWakeDebounce.restart();
|
lockWakeDebounce.restart();
|
||||||
|
lockPowerOffArmed = false;
|
||||||
dpmsReapplyTimer.start();
|
dpmsReapplyTimer.start();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function resetPowerOffFade() {
|
|
||||||
powerOffFadeTimer.stop();
|
|
||||||
powerOffFadeInstant = true;
|
|
||||||
powerOffFadeTarget = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelPowerOffFade() {
|
property bool lockInitiatedLocally: false
|
||||||
resetPowerOffFade();
|
property bool pendingLock: false
|
||||||
IdleService.lockPowerOffRequested = false;
|
property bool lockPowerOffArmed: false
|
||||||
}
|
property bool lockWakeAllowed: false
|
||||||
|
property bool customLockerSpawned: false
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
IdleService.lockComponent = this;
|
IdleService.lockComponent = this;
|
||||||
@@ -88,7 +65,6 @@ Scope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function spawnCustomLocker() {
|
function spawnCustomLocker() {
|
||||||
IdleService.lockPowerOffRequested = false;
|
|
||||||
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionLock]);
|
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionLock]);
|
||||||
// The custom locker manages its own surface; DMS never engages
|
// The custom locker manages its own surface; DMS never engages
|
||||||
// WlSessionLock here, so isShellLocked stays false and the fade
|
// WlSessionLock here, so isShellLocked stays false and the fade
|
||||||
@@ -105,24 +81,6 @@ Scope {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetLockRetry() {
|
|
||||||
lockRetryAttempts = 0;
|
|
||||||
lockRetryPending = false;
|
|
||||||
lockRetryTimer.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLockLost() {
|
|
||||||
if (lockRetryAttempts >= maxLockRetries) {
|
|
||||||
log.error("Compositor refused session lock", maxLockRetries, "times - resetting lock state");
|
|
||||||
forceReset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lockRetryAttempts++;
|
|
||||||
lockRetryPending = true;
|
|
||||||
lockRetryTimer.restart();
|
|
||||||
log.warn("Session lock lost while lock requested - retry", lockRetryAttempts, "/", maxLockRetries);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lock() {
|
function lock() {
|
||||||
if (SettingsData.customPowerActionLock?.length > 0) {
|
if (SettingsData.customPowerActionLock?.length > 0) {
|
||||||
spawnCustomLocker();
|
spawnCustomLocker();
|
||||||
@@ -131,9 +89,8 @@ Scope {
|
|||||||
if (shouldLock || pendingLock)
|
if (shouldLock || pendingLock)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
resetLockRetry();
|
|
||||||
lockInitiatedLocally = true;
|
lockInitiatedLocally = true;
|
||||||
lockPowerOffArmed = powerOffOnLock;
|
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
|
||||||
|
|
||||||
if (!SessionService.active && SessionService.loginctlAvailable && SettingsData.loginctlLockIntegration) {
|
if (!SessionService.active && SessionService.loginctlAvailable && SettingsData.loginctlLockIntegration) {
|
||||||
pendingLock = true;
|
pendingLock = true;
|
||||||
@@ -145,20 +102,9 @@ Scope {
|
|||||||
notifyLoginctl(true);
|
notifyLoginctl(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lockAndOutputsOff() {
|
|
||||||
IdleService.lockPowerOffRequested = true;
|
|
||||||
if (sessionLock.secure) {
|
|
||||||
beginPowerOff();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lockPowerOffArmed = true;
|
|
||||||
lock();
|
|
||||||
}
|
|
||||||
|
|
||||||
function unlock() {
|
function unlock() {
|
||||||
if (!shouldLock)
|
if (!shouldLock)
|
||||||
return;
|
return;
|
||||||
resetLockRetry();
|
|
||||||
lockInitiatedLocally = false;
|
lockInitiatedLocally = false;
|
||||||
notifyLoginctl(false);
|
notifyLoginctl(false);
|
||||||
shouldLock = false;
|
shouldLock = false;
|
||||||
@@ -169,9 +115,6 @@ Scope {
|
|||||||
pendingLock = false;
|
pendingLock = false;
|
||||||
shouldLock = false;
|
shouldLock = false;
|
||||||
customLockerSpawned = false;
|
customLockerSpawned = false;
|
||||||
resetLockRetry();
|
|
||||||
resetPowerOffFade();
|
|
||||||
IdleService.lockPowerOffRequested = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function activate() {
|
function activate() {
|
||||||
@@ -192,7 +135,7 @@ Scope {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lockInitiatedLocally = false;
|
lockInitiatedLocally = false;
|
||||||
lockPowerOffArmed = powerOffOnLock;
|
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
|
||||||
shouldLock = true;
|
shouldLock = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,17 +151,11 @@ Scope {
|
|||||||
shouldLock = false;
|
shouldLock = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSessionResumed() {
|
|
||||||
if (!shouldLock || sessionLock.locked)
|
|
||||||
return;
|
|
||||||
resumeRelockTimer.restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLoginctlStateChanged() {
|
function onLoginctlStateChanged() {
|
||||||
if (SessionService.active && pendingLock) {
|
if (SessionService.active && pendingLock) {
|
||||||
pendingLock = false;
|
pendingLock = false;
|
||||||
lockInitiatedLocally = true;
|
lockInitiatedLocally = true;
|
||||||
lockPowerOffArmed = powerOffOnLock;
|
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
|
||||||
shouldLock = true;
|
shouldLock = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -226,7 +163,7 @@ Scope {
|
|||||||
if (handleLoginctlCustomLock())
|
if (handleLoginctlCustomLock())
|
||||||
return;
|
return;
|
||||||
lockInitiatedLocally = false;
|
lockInitiatedLocally = false;
|
||||||
lockPowerOffArmed = powerOffOnLock;
|
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
|
||||||
shouldLock = true;
|
shouldLock = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,11 +175,6 @@ Scope {
|
|||||||
function onLockRequested() {
|
function onLockRequested() {
|
||||||
lock();
|
lock();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMonitorsOffChanged() {
|
|
||||||
if (!IdleService.monitorsOff)
|
|
||||||
root.resetPowerOffFade();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Pam {
|
Pam {
|
||||||
@@ -255,7 +187,7 @@ Scope {
|
|||||||
WlSessionLock {
|
WlSessionLock {
|
||||||
id: sessionLock
|
id: sessionLock
|
||||||
|
|
||||||
locked: shouldLock && !lockRetryPending
|
locked: shouldLock
|
||||||
|
|
||||||
WlSessionLockSurface {
|
WlSessionLockSurface {
|
||||||
id: lockSurface
|
id: lockSurface
|
||||||
@@ -282,78 +214,32 @@ Scope {
|
|||||||
root.sharedPasswordBuffer = newPassword;
|
root.sharedPasswordBuffer = newPassword;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
color: "black"
|
|
||||||
opacity: root.powerOffFadeTarget
|
|
||||||
visible: opacity > 0 || powerOffFadeTimer.running
|
|
||||||
|
|
||||||
Behavior on opacity {
|
|
||||||
enabled: !root.powerOffFadeInstant
|
|
||||||
NumberAnimation {
|
|
||||||
duration: SettingsData.fadeToDpmsGracePeriod * 1000
|
|
||||||
easing.type: Easing.OutCubic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
property real baselineX: -1
|
|
||||||
property real baselineY: -1
|
|
||||||
|
|
||||||
anchors.fill: parent
|
|
||||||
enabled: powerOffFadeTimer.running
|
|
||||||
hoverEnabled: enabled
|
|
||||||
onEnabledChanged: {
|
|
||||||
baselineX = -1;
|
|
||||||
baselineY = -1;
|
|
||||||
}
|
|
||||||
onPressed: root.cancelPowerOffFade()
|
|
||||||
onWheel: root.cancelPowerOffFade()
|
|
||||||
onPositionChanged: mouse => {
|
|
||||||
if (baselineX < 0) {
|
|
||||||
baselineX = mouse.x;
|
|
||||||
baselineY = mouse.y;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Math.abs(mouse.x - baselineX) < 5 && Math.abs(mouse.y - baselineY) < 5)
|
|
||||||
return;
|
|
||||||
root.cancelPowerOffFade();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: sessionLock
|
target: sessionLock
|
||||||
|
|
||||||
function onSecureChanged() {
|
function onLockedChanged() {
|
||||||
notifyLockedHint(sessionLock.secure);
|
notifyLockedHint(sessionLock.locked);
|
||||||
if (!sessionLock.secure)
|
if (sessionLock.locked) {
|
||||||
return;
|
|
||||||
IdleService.dismissFadeToLock();
|
|
||||||
lockRetryAttempts = 0;
|
|
||||||
pendingLock = false;
|
pendingLock = false;
|
||||||
if (lockPowerOffArmed && powerOffOnLock)
|
if (lockPowerOffArmed && SettingsData.lockScreenPowerOffMonitorsOnLock) {
|
||||||
beginPowerOff();
|
IdleService.monitorsOff = true;
|
||||||
|
CompositorService.powerOffMonitors();
|
||||||
|
lockWakeAllowed = false;
|
||||||
|
lockWakeDebounce.restart();
|
||||||
|
}
|
||||||
lockPowerOffArmed = false;
|
lockPowerOffArmed = false;
|
||||||
|
dpmsReapplyTimer.start();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onLockedChanged() {
|
|
||||||
if (sessionLock.locked)
|
|
||||||
return;
|
|
||||||
if (shouldLock && (IdleService.monitorsOff || lockPowerOffArmed || IdleService.lockPowerOffRequested))
|
|
||||||
return;
|
|
||||||
lockWakeAllowed = false;
|
lockWakeAllowed = false;
|
||||||
resetPowerOffFade();
|
if (IdleService.monitorsOff && SettingsData.lockScreenPowerOffMonitorsOnLock) {
|
||||||
if (IdleService.monitorsOff && powerOffOnLock) {
|
|
||||||
IdleService.monitorsOff = false;
|
IdleService.monitorsOff = false;
|
||||||
CompositorService.powerOnMonitors();
|
CompositorService.powerOnMonitors();
|
||||||
}
|
}
|
||||||
IdleService.lockPowerOffRequested = false;
|
|
||||||
if (shouldLock && !lockRetryPending)
|
|
||||||
handleLockLost();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,10 +254,6 @@ Scope {
|
|||||||
root.lock();
|
root.lock();
|
||||||
}
|
}
|
||||||
|
|
||||||
function lockAndOutputsOff() {
|
|
||||||
root.lockAndOutputsOff();
|
|
||||||
}
|
|
||||||
|
|
||||||
function unlock() {
|
function unlock() {
|
||||||
root.unlock();
|
root.unlock();
|
||||||
}
|
}
|
||||||
@@ -385,15 +267,13 @@ Scope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isLocked(): bool {
|
function isLocked(): bool {
|
||||||
return sessionLock.secure;
|
return sessionLock.locked;
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(): string {
|
function status(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
shouldLock: root.shouldLock,
|
shouldLock: root.shouldLock,
|
||||||
sessionLockLocked: sessionLock.locked,
|
sessionLockLocked: sessionLock.locked,
|
||||||
sessionLockSecure: sessionLock.secure,
|
|
||||||
lockRetryAttempts: root.lockRetryAttempts,
|
|
||||||
lockInitiatedLocally: root.lockInitiatedLocally,
|
lockInitiatedLocally: root.lockInitiatedLocally,
|
||||||
pendingLock: root.pendingLock,
|
pendingLock: root.pendingLock,
|
||||||
loginctlLocked: SessionService.locked,
|
loginctlLocked: SessionService.locked,
|
||||||
@@ -402,49 +282,6 @@ Scope {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: powerOffFadeTimer
|
|
||||||
interval: SettingsData.fadeToDpmsGracePeriod * 1000
|
|
||||||
repeat: false
|
|
||||||
onTriggered: root.applyMonitorsOff()
|
|
||||||
}
|
|
||||||
|
|
||||||
IdleMonitor {
|
|
||||||
timeout: 1
|
|
||||||
respectInhibitors: false
|
|
||||||
enabled: powerOffFadeTimer.running
|
|
||||||
onIsIdleChanged: {
|
|
||||||
if (isIdle)
|
|
||||||
return;
|
|
||||||
if (!powerOffFadeTimer.running)
|
|
||||||
return;
|
|
||||||
root.cancelPowerOffFade();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: lockRetryTimer
|
|
||||||
interval: 1000
|
|
||||||
repeat: false
|
|
||||||
onTriggered: root.lockRetryPending = false
|
|
||||||
}
|
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: resumeRelockTimer
|
|
||||||
interval: 1000
|
|
||||||
repeat: false
|
|
||||||
onTriggered: {
|
|
||||||
if (!root.shouldLock || sessionLock.locked)
|
|
||||||
return;
|
|
||||||
if (IdleService.monitorsOff || root.lockPowerOffArmed || IdleService.lockPowerOffRequested)
|
|
||||||
return;
|
|
||||||
root.log.warn("Session lock dead after resume - re-locking");
|
|
||||||
root.resetLockRetry();
|
|
||||||
root.lockRetryPending = true;
|
|
||||||
root.lockRetryPending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: dpmsReapplyTimer
|
id: dpmsReapplyTimer
|
||||||
interval: 100
|
interval: 100
|
||||||
@@ -457,9 +294,9 @@ Scope {
|
|||||||
interval: 200
|
interval: 200
|
||||||
repeat: false
|
repeat: false
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (!sessionLock.secure)
|
if (!sessionLock.locked)
|
||||||
return;
|
return;
|
||||||
if (!powerOffOnLock)
|
if (!SettingsData.lockScreenPowerOffMonitorsOnLock)
|
||||||
return;
|
return;
|
||||||
if (!IdleService.monitorsOff) {
|
if (!IdleService.monitorsOff) {
|
||||||
lockWakeAllowed = true;
|
lockWakeAllowed = true;
|
||||||
@@ -476,7 +313,7 @@ Scope {
|
|||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
enabled: sessionLock.secure
|
enabled: sessionLock.locked
|
||||||
hoverEnabled: enabled
|
hoverEnabled: enabled
|
||||||
onPressed: lockWakeDebounce.restart()
|
onPressed: lockWakeDebounce.restart()
|
||||||
onPositionChanged: lockWakeDebounce.restart()
|
onPositionChanged: lockWakeDebounce.restart()
|
||||||
@@ -485,10 +322,10 @@ Scope {
|
|||||||
|
|
||||||
FocusScope {
|
FocusScope {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
focus: sessionLock.secure
|
focus: sessionLock.locked
|
||||||
|
|
||||||
Keys.onPressed: event => {
|
Keys.onPressed: event => {
|
||||||
if (!sessionLock.secure)
|
if (!sessionLock.locked)
|
||||||
return;
|
return;
|
||||||
lockWakeDebounce.restart();
|
lockWakeDebounce.restart();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ Item {
|
|||||||
property int hyprlandLayoutCount: 0
|
property int hyprlandLayoutCount: 0
|
||||||
property bool lockerReadySent: false
|
property bool lockerReadySent: false
|
||||||
property bool lockerReadyArmed: false
|
property bool lockerReadyArmed: false
|
||||||
property var sessionLock: null
|
|
||||||
readonly property bool hasCustomWallpaper: SettingsData.lockScreenWallpaperPath !== ""
|
readonly property bool hasCustomWallpaper: SettingsData.lockScreenWallpaperPath !== ""
|
||||||
readonly property string lockFontFamily: SettingsData.lockScreenFontFamily
|
readonly property string lockFontFamily: SettingsData.lockScreenFontFamily
|
||||||
|
|
||||||
@@ -112,8 +111,6 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendLockerReadyOnce() {
|
function sendLockerReadyOnce() {
|
||||||
if (root.demoMode)
|
|
||||||
return;
|
|
||||||
if (lockerReadySent)
|
if (lockerReadySent)
|
||||||
return;
|
return;
|
||||||
if (root.unlocking)
|
if (root.unlocking)
|
||||||
@@ -136,27 +133,12 @@ Item {
|
|||||||
return;
|
return;
|
||||||
if (!root.visible || root.opacity <= 0)
|
if (!root.visible || root.opacity <= 0)
|
||||||
return;
|
return;
|
||||||
// Don't report ready until the compositor has confirmed the session is
|
|
||||||
// locked (ext-session-lock `locked` event). Qt's afterRendering fires
|
|
||||||
// before the lock surface is committed/presented, so releasing the sleep
|
|
||||||
// inhibitor on it lets the machine freeze with the desktop still on screen,
|
|
||||||
// which then flashes on resume. secure=true guarantees the desktop is hidden.
|
|
||||||
if (root.sessionLock && !root.sessionLock.secure)
|
|
||||||
return;
|
|
||||||
Qt.callLater(() => {
|
Qt.callLater(() => {
|
||||||
if (root.visible && root.opacity > 0 && !root.unlocking)
|
if (root.visible && root.opacity > 0 && !root.unlocking)
|
||||||
sendLockerReadyOnce();
|
sendLockerReadyOnce();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: root.sessionLock
|
|
||||||
enabled: target !== null
|
|
||||||
function onSecureChanged() {
|
|
||||||
root.maybeSend();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: root.Window.window
|
target: root.Window.window
|
||||||
enabled: target !== null
|
enabled: target !== null
|
||||||
@@ -796,7 +778,7 @@ Item {
|
|||||||
return "passkey";
|
return "passkey";
|
||||||
if (pam.fprint.tries >= SettingsData.maxFprintTries)
|
if (pam.fprint.tries >= SettingsData.maxFprintTries)
|
||||||
return "fingerprint_off";
|
return "fingerprint_off";
|
||||||
if (pam.fprint.active || pam.fprint.retrying)
|
if (pam.fprint.active)
|
||||||
return "fingerprint";
|
return "fingerprint";
|
||||||
if (pam.u2f.active)
|
if (pam.u2f.active)
|
||||||
return "passkey";
|
return "passkey";
|
||||||
@@ -1653,7 +1635,7 @@ Item {
|
|||||||
enabled: MprisController.activePlayer?.canGoNext ?? false
|
enabled: MprisController.activePlayer?.canGoNext ?? false
|
||||||
hoverEnabled: enabled
|
hoverEnabled: enabled
|
||||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
onClicked: MprisController.next()
|
onClicked: MprisController.activePlayer?.next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user