mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
Compare commits
78 Commits
74896fb87c
..
shell
| 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:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
@@ -55,15 +55,6 @@ jobs:
|
||||
fetch-depth: 0
|
||||
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)
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
@@ -92,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Dispatch Release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
|
||||
-f tag="${{ steps.derive.outputs.tag }}" \
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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() {
|
||||
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)")
|
||||
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 {
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -28,19 +27,8 @@ var (
|
||||
ssNoConfirm bool
|
||||
ssReset 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{
|
||||
Use: "screenshot",
|
||||
Short: "Capture screenshots",
|
||||
@@ -51,9 +39,8 @@ Modes:
|
||||
full - Capture the focused output
|
||||
all - Capture all outputs combined
|
||||
output - Capture a specific output by name
|
||||
window - Capture the focused window (Hyprland/Mango/niri)
|
||||
window - Capture the focused window (Hyprland/Mango)
|
||||
last - Capture the last selected region
|
||||
scroll - Select a region, then scroll to capture a stitched tall image
|
||||
|
||||
Output format (--format):
|
||||
png - PNG format (default)
|
||||
@@ -72,10 +59,7 @@ Examples:
|
||||
dms screenshot --no-file # Clipboard only
|
||||
dms screenshot --no-confirm # Region capture on mouse release
|
||||
dms screenshot --cursor=on # Include cursor
|
||||
dms screenshot -f jpg -q 85 # JPEG with quality 85
|
||||
dms screenshot --json # Print capture metadata as JSON
|
||||
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
|
||||
dms screenshot scroll --interval 250`,
|
||||
dms screenshot -f jpg -q 85 # JPEG with quality 85`,
|
||||
}
|
||||
|
||||
var ssRegionCmd = &cobra.Command{
|
||||
@@ -113,33 +97,10 @@ If no previous region exists, falls back to interactive selection.`,
|
||||
var ssWindowCmd = &cobra.Command{
|
||||
Use: "window",
|
||||
Short: "Capture the focused window",
|
||||
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
|
||||
Long: `Capture the currently focused window. Supported on Hyprland and Mango.`,
|
||||
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{
|
||||
Use: "list",
|
||||
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(&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(&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(ssScrollCmd)
|
||||
screenshotCmd.AddCommand(ssFullCmd)
|
||||
screenshotCmd.AddCommand(ssAllCmd)
|
||||
screenshotCmd.AddCommand(ssOutputCmd)
|
||||
@@ -246,39 +203,10 @@ func setPopoutScreenshotMode(begin bool) {
|
||||
_ = 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) {
|
||||
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.
|
||||
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 {
|
||||
setPopoutScreenshotMode(true)
|
||||
defer setPopoutScreenshotMode(false)
|
||||
@@ -287,13 +215,11 @@ func runScreenshot(config screenshot.Config) {
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
exitScreenshotError("", err)
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
if ssJSON {
|
||||
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
@@ -305,7 +231,8 @@ func runScreenshot(config screenshot.Config) {
|
||||
|
||||
if config.Stdout {
|
||||
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
|
||||
}
|
||||
@@ -325,37 +252,22 @@ func runScreenshot(config screenshot.Config) {
|
||||
|
||||
filePath = filepath.Join(outputDir, filename)
|
||||
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
|
||||
exitScreenshotError(" writing file", err)
|
||||
}
|
||||
if !ssJSON {
|
||||
fmt.Println(filePath)
|
||||
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(filePath)
|
||||
}
|
||||
|
||||
if config.Clipboard {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
|
||||
screenshot.SendNotification(screenshot.NotifyResult{
|
||||
@@ -466,12 +378,6 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
|
||||
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) {
|
||||
config := getScreenshotConfig(screenshot.ModeFullScreen)
|
||||
runScreenshot(config)
|
||||
|
||||
@@ -20,7 +20,7 @@ func init() {
|
||||
runCmd.Flags().MarkHidden("daemon-child")
|
||||
|
||||
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)
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
|
||||
@@ -20,7 +20,7 @@ func init() {
|
||||
runCmd.Flags().MarkHidden("daemon-child")
|
||||
|
||||
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)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
@@ -124,9 +124,7 @@ func GetLogger() *Logger {
|
||||
logger = &Logger{base}
|
||||
|
||||
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||
logMu.Lock()
|
||||
_ = setLogFile(logger, path)
|
||||
logMu.Unlock()
|
||||
_ = SetLogFile(path)
|
||||
}
|
||||
})
|
||||
return logger
|
||||
@@ -147,18 +145,15 @@ func SetLevel(level string) {
|
||||
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
||||
// the file stays plain while stderr keeps its colors.
|
||||
func SetLogFile(path string) error {
|
||||
l := GetLogger()
|
||||
logMu.Lock()
|
||||
defer logMu.Unlock()
|
||||
return setLogFile(l, path)
|
||||
}
|
||||
|
||||
func setLogFile(l *Logger, path string) error {
|
||||
if logFile != nil {
|
||||
logFile.Close()
|
||||
logFile = nil
|
||||
}
|
||||
|
||||
l := GetLogger()
|
||||
if path == "" {
|
||||
l.SetOutput(logStderr)
|
||||
applyColorProfile(l, logStderr)
|
||||
|
||||
+5
-338
@@ -70,11 +70,10 @@ var includedPamAuthFiles = []string{
|
||||
}
|
||||
|
||||
type AuthSettings struct {
|
||||
EnableFprint bool `json:"enableFprint"`
|
||||
EnableU2f bool `json:"enableU2f"`
|
||||
GreeterEnableFprint bool `json:"greeterEnableFprint"`
|
||||
GreeterEnableU2f bool `json:"greeterEnableU2f"`
|
||||
GreeterPamExternallyManaged bool `json:"greeterPamExternallyManaged"`
|
||||
EnableFprint bool `json:"enableFprint"`
|
||||
EnableU2f bool `json:"enableU2f"`
|
||||
GreeterEnableFprint bool `json:"greeterEnableFprint"`
|
||||
GreeterEnableU2f bool `json:"greeterEnableU2f"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -592,330 +583,6 @@ func buildManagedLockscreenPamContent(baseDirs []string, readFile func(string) (
|
||||
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"
|
||||
|
||||
func UserLockscreenPamDir() string {
|
||||
@@ -965,7 +632,7 @@ func buildManagedLockscreenU2FPamContent() string {
|
||||
|
||||
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -611,8 +611,8 @@ func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
|
||||
if err != nil {
|
||||
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") {
|
||||
t.Fatalf("expected NixOS informational log describing the user-state fallback, got %v", logs)
|
||||
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") {
|
||||
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs)
|
||||
}
|
||||
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)
|
||||
@@ -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) {
|
||||
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.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")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
candidates := []struct {
|
||||
socket string
|
||||
needsStat bool
|
||||
compositor Compositor
|
||||
}{
|
||||
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
|
||||
{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},
|
||||
}
|
||||
hyprlandSig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
niriSocket := os.Getenv("NIRI_SOCKET")
|
||||
swaySocket := os.Getenv("SWAYSOCK")
|
||||
scrollSocket := os.Getenv("SCROLLSOCK")
|
||||
miracleSocket := os.Getenv("MIRACLESOCK")
|
||||
mangoSocket := os.Getenv("MANGO_INSTANCE_SIGNATURE")
|
||||
|
||||
// A stale env var from a previous session must not mask the live compositor
|
||||
for _, c := range candidates {
|
||||
if c.socket == "" {
|
||||
continue
|
||||
switch {
|
||||
case mangoSocket != "":
|
||||
if _, err := os.Stat(mangoSocket); err == nil {
|
||||
detectedCompositor = CompositorMango
|
||||
return detectedCompositor
|
||||
}
|
||||
if c.needsStat {
|
||||
if _, err := os.Stat(c.socket); err != nil {
|
||||
continue
|
||||
}
|
||||
case niriSocket != "":
|
||||
if _, err := os.Stat(niriSocket); err == nil {
|
||||
detectedCompositor = CompositorNiri
|
||||
return detectedCompositor
|
||||
}
|
||||
detectedCompositor = c.compositor
|
||||
case scrollSocket != "":
|
||||
if _, err := os.Stat(scrollSocket); err == nil {
|
||||
detectedCompositor = CompositorScroll
|
||||
return detectedCompositor
|
||||
}
|
||||
case miracleSocket != "":
|
||||
if _, err := os.Stat(miracleSocket); err == nil {
|
||||
detectedCompositor = CompositorMiracle
|
||||
return detectedCompositor
|
||||
}
|
||||
case swaySocket != "":
|
||||
if _, err := os.Stat(swaySocket); err == nil {
|
||||
detectedCompositor = CompositorSway
|
||||
return detectedCompositor
|
||||
}
|
||||
case hyprlandSig != "":
|
||||
detectedCompositor = CompositorHyprland
|
||||
return detectedCompositor
|
||||
}
|
||||
|
||||
@@ -79,7 +90,7 @@ func GetActiveWindow() (*WindowGeometry, error) {
|
||||
case CompositorMango:
|
||||
return getMangoActiveWindow()
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
||||
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
|
||||
shiftHeld bool
|
||||
|
||||
phase selectorPhase
|
||||
scroll *scrollSession
|
||||
|
||||
running bool
|
||||
cancelled bool
|
||||
result Region
|
||||
@@ -170,32 +167,20 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
||||
|
||||
r.running = true
|
||||
for r.running {
|
||||
if err := r.dispatchOrTick(); err != nil {
|
||||
if err := r.ctx.Dispatch(); err != nil {
|
||||
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 {
|
||||
return nil, r.cancelled, nil
|
||||
}
|
||||
|
||||
yInverted := false
|
||||
var format uint32
|
||||
scale := 1.0
|
||||
if r.selection.surface != nil {
|
||||
yInverted = r.selection.surface.yInverted
|
||||
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{
|
||||
@@ -203,7 +188,6 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
||||
Region: r.result,
|
||||
YInverted: yInverted,
|
||||
Format: format,
|
||||
Scale: scale,
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
@@ -707,9 +691,7 @@ func (r *RegionSelector) initRenderBuffer(os *OutputSurface) {
|
||||
}
|
||||
slot.pool = pool
|
||||
|
||||
// niri latches surface opacity from the first buffer's format
|
||||
// (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))
|
||||
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), os.screenFormat)
|
||||
if err != nil {
|
||||
log.Error("create render slot wl_buffer failed", "err", err)
|
||||
pool.Destroy()
|
||||
@@ -751,9 +733,8 @@ func (r *RegionSelector) applyPreSelection(os *OutputSurface) {
|
||||
|
||||
x1 := float64(r.preSelect.X-os.output.x) * scaleX
|
||||
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 - scaleX
|
||||
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height)*scaleY - scaleY
|
||||
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width) * scaleX
|
||||
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height) * scaleY
|
||||
|
||||
r.selection.hasSelection = true
|
||||
r.selection.dragging = false
|
||||
@@ -783,13 +764,10 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
|
||||
return
|
||||
}
|
||||
|
||||
switch r.phase {
|
||||
case phaseScroll:
|
||||
r.drawScrollOverlay(os, slot.shm)
|
||||
default:
|
||||
slot.shm.CopyFrom(srcBuf)
|
||||
r.drawOverlay(os, slot.shm)
|
||||
}
|
||||
slot.shm.CopyFrom(srcBuf)
|
||||
|
||||
// Draw overlay (dimming + selection) into this slot
|
||||
r.drawOverlay(os, slot.shm)
|
||||
|
||||
if os.viewport != nil {
|
||||
_ = os.wlSurface.SetBufferScale(1)
|
||||
@@ -825,8 +803,6 @@ func (r *RegionSelector) cleanup() {
|
||||
r.cursorBuffer.Close()
|
||||
}
|
||||
|
||||
r.cleanupScroll()
|
||||
|
||||
for _, os := range r.surfaces {
|
||||
for _, slot := range os.slots {
|
||||
if slot == nil {
|
||||
|
||||
@@ -94,20 +94,6 @@ func (r *RegionSelector) setupPointerHandlers() {
|
||||
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 {
|
||||
case 0x110: // BTN_LEFT
|
||||
switch e.State {
|
||||
@@ -149,17 +135,6 @@ func (r *RegionSelector) setupKeyboardHandlers() {
|
||||
return
|
||||
}
|
||||
|
||||
if r.phase == phaseScroll {
|
||||
switch e.Key {
|
||||
case 1:
|
||||
r.cancelled = true
|
||||
r.running = false
|
||||
case 28, 96:
|
||||
r.finishScroll()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch e.Key {
|
||||
case 1:
|
||||
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 {
|
||||
return nil, 0, 0, 0, 0
|
||||
r.running = false
|
||||
return
|
||||
}
|
||||
|
||||
os := r.selection.surface
|
||||
srcBuf := r.getSourceBuffer(os)
|
||||
if srcBuf == nil {
|
||||
return nil, 0, 0, 0, 0
|
||||
r.running = false
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
bx1 := clamp(int(x1*scaleX), 0, srcBuf.Width)
|
||||
by1 := clamp(int(y1*scaleY), 0, srcBuf.Height)
|
||||
bx2 := clamp(int(x2*scaleX), 0, srcBuf.Width)
|
||||
by2 := clamp(int(y2*scaleY), 0, srcBuf.Height)
|
||||
bx1 := int(x1 * scaleX)
|
||||
by1 := int(y1 * scaleY)
|
||||
bx2 := int(x2 * scaleX)
|
||||
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
|
||||
if r.shiftHeld && w != h {
|
||||
@@ -224,23 +215,7 @@ func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, i
|
||||
h = 1
|
||||
}
|
||||
|
||||
return os, bx1, by1, w, h
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Create cropped buffer and copy pixels directly
|
||||
cropped, err := CreateShmBuffer(w, h, w*4)
|
||||
if err != nil {
|
||||
r.running = false
|
||||
|
||||
@@ -57,7 +57,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
||||
w, h := renderBuf.Width, renderBuf.Height
|
||||
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++ {
|
||||
off := y * stride
|
||||
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+1] = uint8(int(data[i+1]) * 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+1] = srcData[si+1]
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if r.selection.dragging {
|
||||
return
|
||||
|
||||
@@ -28,21 +28,6 @@ type CaptureResult struct {
|
||||
Region Region
|
||||
YInverted bool
|
||||
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 {
|
||||
@@ -92,7 +77,7 @@ func (s *Screenshoter) Run() (*CaptureResult, error) {
|
||||
switch s.config.Mode {
|
||||
case ModeLastRegion:
|
||||
return s.captureLastRegion()
|
||||
case ModeRegion, ModeScroll:
|
||||
case ModeRegion:
|
||||
return s.captureRegion()
|
||||
case ModeWindow:
|
||||
return s.captureWindow()
|
||||
@@ -145,10 +130,6 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
|
||||
}
|
||||
|
||||
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
|
||||
if DetectCompositor() == CompositorNiri {
|
||||
return s.captureNiriWindow()
|
||||
}
|
||||
|
||||
geom, err := GetActiveWindow()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -274,7 +255,6 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -450,7 +430,6 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
|
||||
Buffer: composite,
|
||||
Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)},
|
||||
Format: format,
|
||||
Scale: maxScale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -523,7 +502,6 @@ func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Scale = output.effectiveScale()
|
||||
|
||||
if result.YInverted {
|
||||
result.Buffer.FlipVertical()
|
||||
@@ -626,7 +604,6 @@ func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*Ca
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -635,7 +612,16 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
result, err := s.processFrame(frame, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Scale = scale
|
||||
return result, nil
|
||||
return s.processFrame(frame, region)
|
||||
}
|
||||
|
||||
func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) {
|
||||
@@ -688,7 +669,16 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
|
||||
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)
|
||||
localY := int(float64(region.Y-output.y) * scale)
|
||||
@@ -740,7 +730,6 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, 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
|
||||
ModeOutput
|
||||
ModeLastRegion
|
||||
ModeScroll
|
||||
)
|
||||
|
||||
type Format int
|
||||
@@ -63,7 +62,6 @@ type Config struct {
|
||||
SaveFile bool
|
||||
Notify bool
|
||||
Stdout bool
|
||||
IntervalMs int
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
|
||||
@@ -744,32 +744,6 @@ func (b *IWDBackend) DisconnectWiFi() error {
|
||||
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 {
|
||||
b.stateMutex.RLock()
|
||||
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)
|
||||
}
|
||||
|
||||
if !autoconnect {
|
||||
b.abortInFlightConnection(ssid)
|
||||
}
|
||||
|
||||
b.updateState()
|
||||
|
||||
if b.onStateChange != nil {
|
||||
|
||||
@@ -359,24 +359,6 @@ func (b *NetworkManagerBackend) DisconnectWiFi() error {
|
||||
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 {
|
||||
conn, err := b.findConnection(ssid)
|
||||
if err != nil {
|
||||
@@ -981,10 +963,6 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
|
||||
return fmt.Errorf("failed to update connection: %w", err)
|
||||
}
|
||||
|
||||
if !autoconnect {
|
||||
b.abortInFlightConnection(ssid)
|
||||
}
|
||||
|
||||
b.updateWiFiNetworks()
|
||||
|
||||
if b.onStateChange != nil {
|
||||
|
||||
@@ -50,42 +50,11 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine
|
||||
}
|
||||
|
||||
func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
||||
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
||||
}
|
||||
|
||||
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
|
||||
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
|
||||
// AUR packages are exempt — holding those cannot break the repo dependency graph.
|
||||
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
|
||||
argv := []string{"pacman", "-Syu", "--noconfirm", "--needed"}
|
||||
if len(opts.Ignored) > 0 {
|
||||
argv = append(argv, "--ignore", strings.Join(opts.Ignored, ","))
|
||||
}
|
||||
return privilegedArgv(opts, argv...)
|
||||
}
|
||||
|
||||
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), " ")
|
||||
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 {
|
||||
|
||||
@@ -119,7 +119,7 @@ func findTerminal(override string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
||||
func wrapInTerminal(term, title, shellCmd string) []string {
|
||||
const appID = "com.danklinux.dms"
|
||||
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'`,
|
||||
@@ -129,25 +129,24 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
||||
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||
full := export + banner + "; " + shellCmd + "; " + closer
|
||||
|
||||
var argv []string
|
||||
execFlag := "-e"
|
||||
switch term {
|
||||
case "kitty", "alacritty", "wezterm":
|
||||
argv = []string{term, "--class", appID, "-T", title}
|
||||
case "kitty":
|
||||
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":
|
||||
argv = []string{term, "--app-id=" + appID, "--title=" + title}
|
||||
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||
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":
|
||||
argv = []string{term, "-class", appID, "-T", title}
|
||||
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||
case "konsole":
|
||||
argv = []string{term, "-p", "tabtitle=" + title}
|
||||
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
|
||||
case "gnome-terminal":
|
||||
argv = []string{term, "--title=" + title}
|
||||
execFlag = "--"
|
||||
return []string{term, "--title=" + title, "--", "sh", "-c", full}
|
||||
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),
|
||||
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
||||
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
||||
TerminalArgs: stringSliceOpt(req.Params, "terminalArgs"),
|
||||
Ignored: stringSliceOpt(req.Params, "ignored"),
|
||||
}
|
||||
if err := m.Upgrade(opts); err != nil {
|
||||
|
||||
@@ -336,7 +336,7 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
}()
|
||||
|
||||
if opts.CustomCommand != "" {
|
||||
m.runCustomUpgrade(ctx, opts)
|
||||
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -345,9 +345,6 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
opts.Targets = append([]Package(nil), m.state.Packages...)
|
||||
m.mu.RUnlock()
|
||||
}
|
||||
if isPacmanFamily(m.selection.System) {
|
||||
opts.Ignored = dropPacmanRepoIgnores(opts.Ignored, opts.Targets)
|
||||
}
|
||||
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
|
||||
|
||||
backends := upgradeBackends(m.selection, opts)
|
||||
@@ -392,8 +389,8 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
m.finishSuccessfulUpgrade(true)
|
||||
}
|
||||
|
||||
func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
term := findTerminal(opts.Terminal)
|
||||
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
|
||||
term := findTerminal(terminalOverride)
|
||||
if term == "" {
|
||||
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||
return
|
||||
@@ -410,7 +407,7 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
m.markDirty()
|
||||
|
||||
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 {
|
||||
code := ErrCodeBackendFailed
|
||||
switch {
|
||||
@@ -428,7 +425,6 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
}
|
||||
|
||||
m.finishSuccessfulUpgrade(false)
|
||||
m.runRefresh(context.Background(), false)
|
||||
}
|
||||
|
||||
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
|
||||
|
||||
@@ -80,7 +80,6 @@ type UpgradeOptions struct {
|
||||
AttachStdio bool
|
||||
CustomCommand string
|
||||
Terminal string
|
||||
TerminalArgs []string
|
||||
Targets []Package
|
||||
Ignored []string
|
||||
}
|
||||
|
||||
@@ -50,9 +50,9 @@ func TestUpgradeCommandBuilders(t *testing.T) {
|
||||
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"}}),
|
||||
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed"},
|
||||
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed", "--ignore", "linux"},
|
||||
},
|
||||
{
|
||||
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) {
|
||||
argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}})
|
||||
if len(argv) < 2 || argv[len(argv)-2] != "-c" {
|
||||
|
||||
@@ -200,9 +200,7 @@ in
|
||||
];
|
||||
# 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.
|
||||
# Do not add pam_u2f or pam_fprintd here for security-key unlock, enable
|
||||
# programs.dank-material-shell.lockscreen.securityKey.enable, which declares the
|
||||
# dedicated dankshell-u2f service DMS drives on its own.
|
||||
# U2F and fingerprint are handled separately by DMS — do not add pam_u2f or pam_fprintd here.
|
||||
# security.pam.services.dankshell = {
|
||||
# # Example: add faillock
|
||||
# faillock.enable = true;
|
||||
|
||||
@@ -23,15 +23,6 @@ in
|
||||
description = "Systemd target to bind to.";
|
||||
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 {
|
||||
systemd.user.services.dms = lib.mkIf cfg.systemd.enable {
|
||||
description = "DankMaterialShell";
|
||||
@@ -55,18 +46,6 @@ in
|
||||
inherit value;
|
||||
}) 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.accounts-daemon.enable = lib.mkDefault true;
|
||||
services.geoclue2.enable = lib.mkDefault true;
|
||||
|
||||
@@ -19,7 +19,6 @@ pkgs.testers.runNixOSTest {
|
||||
programs.dank-material-shell = {
|
||||
enable = true;
|
||||
systemd.enable = true;
|
||||
lockscreen.securityKey.enable = true;
|
||||
plugins = {
|
||||
TestPlugin = {
|
||||
src = pkgs.emptyDirectory;
|
||||
@@ -40,7 +39,6 @@ pkgs.testers.runNixOSTest {
|
||||
machine.succeed("su -- danklinux -c 'dms --help >/dev/null'")
|
||||
machine.succeed("test -d /etc/xdg/quickshell/dms-plugins")
|
||||
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'"))
|
||||
t.assertIn("summary", payload)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
The Wolverine
|
||||
Marble Tabby
|
||||
|
||||
@@ -11,8 +11,6 @@ Singleton {
|
||||
|
||||
property var appUsageRanking: {}
|
||||
property bool _saving: false
|
||||
property bool _hasLoaded: false
|
||||
property bool _parseError: false
|
||||
|
||||
Component.onCompleted: {
|
||||
loadSettings();
|
||||
@@ -23,21 +21,15 @@ Singleton {
|
||||
}
|
||||
|
||||
function parseSettings(content) {
|
||||
_parseError = false;
|
||||
try {
|
||||
if (content && content.trim()) {
|
||||
var settings = JSON.parse(content);
|
||||
appUsageRanking = settings.appUsageRanking || {};
|
||||
}
|
||||
_hasLoaded = true;
|
||||
} catch (e) {
|
||||
_parseError = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
if (_parseError || !_hasLoaded)
|
||||
return;
|
||||
settingsFile.setText(JSON.stringify({
|
||||
"appUsageRanking": appUsageRanking
|
||||
}, null, 2));
|
||||
|
||||
@@ -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 close", label: "Keybinds Cheatsheet: Close" },
|
||||
{ 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 inhibit toggle", label: "Idle Inhibit: Toggle" },
|
||||
{ id: "spawn dms ipc call inhibit enable", label: "Idle Inhibit: Enable" },
|
||||
|
||||
@@ -240,8 +240,6 @@ Singleton {
|
||||
onBlurForegroundLayersChanged: saveSettings()
|
||||
property real blurLayerOutlineOpacity: 0.12
|
||||
onBlurLayerOutlineOpacityChanged: saveSettings()
|
||||
property bool blurBorderEnabled: true
|
||||
onBlurBorderEnabledChanged: saveSettings()
|
||||
property string blurBorderColor: "outline"
|
||||
onBlurBorderColorChanged: saveSettings()
|
||||
property string blurBorderCustomColor: "#ffffff"
|
||||
@@ -861,7 +859,6 @@ Singleton {
|
||||
property bool notificationOverlayEnabled: false
|
||||
property bool notificationPopupShadowEnabled: true
|
||||
property bool notificationPopupPrivacyMode: false
|
||||
property bool notificationForegroundLayers: true
|
||||
property int overviewRows: 2
|
||||
property int overviewColumns: 5
|
||||
property real overviewScale: 0.16
|
||||
@@ -898,12 +895,6 @@ Singleton {
|
||||
readonly property bool greeterU2fReady: Processes.greeterU2fReady
|
||||
readonly property string greeterU2fReason: Processes.greeterU2fReason
|
||||
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 int lockScreenNotificationMode: 0
|
||||
property bool lockScreenVideoEnabled: false
|
||||
|
||||
@@ -580,15 +580,11 @@ Singleton {
|
||||
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
|
||||
readonly property bool blurForegroundLayers: 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 readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
|
||||
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
||||
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
||||
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 layerOutlineOpacity: blurLayerOutlineOpacity
|
||||
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 bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== ""
|
||||
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 greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
|
||||
|
||||
@@ -177,7 +176,7 @@ Singleton {
|
||||
readonly property bool lockU2fReady: {
|
||||
if (forcedU2fAvailable !== null)
|
||||
return forcedU2fAvailable;
|
||||
return lockU2fCustomSourceDetected || lockU2fCustomConfigDetected || homeU2fKeysDetected;
|
||||
return lockU2fCustomConfigDetected || homeU2fKeysDetected;
|
||||
}
|
||||
|
||||
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"]
|
||||
|
||||
function detectAuthCapabilities() {
|
||||
// FileView cannot watch paths that do not exist yet, so reload the U2F PAM
|
||||
dankshellU2fPamWatcher.reload();
|
||||
u2fKeysWatcher.reload();
|
||||
|
||||
if (forcedFprintAvailable === null) {
|
||||
fingerprintProbeFinalized = false;
|
||||
Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => {
|
||||
@@ -727,22 +722,14 @@ Singleton {
|
||||
FileView {
|
||||
id: dankshellU2fPamWatcher
|
||||
path: "/etc/pam.d/dankshell-u2f"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: root.dankshellU2fPamText = text()
|
||||
onLoadFailed: root.dankshellU2fPamText = ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: customU2fPamWatcher
|
||||
path: root.settingsRoot?.lockU2fPamPath || ""
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: u2fKeysWatcher
|
||||
path: root.u2fKeysPath
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: root.u2fKeysText = text()
|
||||
onLoadFailed: root.u2fKeysText = ""
|
||||
|
||||
@@ -70,7 +70,6 @@ var SPEC = {
|
||||
blurEnabled: { def: false },
|
||||
blurForegroundLayers: { def: true },
|
||||
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
|
||||
blurBorderEnabled: { def: true },
|
||||
blurBorderColor: { def: "outline" },
|
||||
blurBorderCustomColor: { def: "#ffffff" },
|
||||
blurBorderOpacity: { def: 0.35, coerce: percentToUnit },
|
||||
@@ -234,7 +233,6 @@ var SPEC = {
|
||||
greeterLockDateFormat: { def: "", onChange: "markGreeterSyncPending" },
|
||||
greeterFontFamily: { def: "", onChange: "markGreeterSyncPending" },
|
||||
greeterWallpaperFillMode: { def: "", onChange: "markGreeterSyncPending" },
|
||||
greeterPamExternallyManaged: { def: false, onChange: "markGreeterSyncPending" },
|
||||
greeterSyncPending: { def: false },
|
||||
greeterSyncBaseline: { def: {} },
|
||||
mediaSize: { def: 1 },
|
||||
@@ -438,7 +436,6 @@ var SPEC = {
|
||||
notificationOverlayEnabled: { def: false },
|
||||
notificationPopupShadowEnabled: { def: true },
|
||||
notificationPopupPrivacyMode: { def: false },
|
||||
notificationForegroundLayers: { def: true },
|
||||
overviewRows: { def: 2, persist: false },
|
||||
overviewColumns: { def: 5, persist: false },
|
||||
overviewScale: { def: 0.16, persist: false },
|
||||
@@ -458,11 +455,6 @@ var SPEC = {
|
||||
maxFprintTries: { def: 15 },
|
||||
enableU2f: { def: false, onChange: "scheduleAuthApply" },
|
||||
u2fMode: { def: "or" },
|
||||
lockPamPath: { def: "" },
|
||||
lockPamInlineFprint: { def: false },
|
||||
lockPamInlineU2f: { def: false },
|
||||
lockPamExternallyManaged: { def: false },
|
||||
lockU2fPamPath: { def: "" },
|
||||
lockScreenInactiveColor: { def: "#000000" },
|
||||
lockScreenNotificationMode: { def: 0 },
|
||||
lockScreenVideoEnabled: { def: false },
|
||||
|
||||
+67
-54
@@ -1,5 +1,3 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
@@ -55,7 +53,6 @@ Item {
|
||||
delegate: Loader {
|
||||
id: fadeWindowLoader
|
||||
required property var modelData
|
||||
readonly property FadeToLockWindow loadedWindow: item as FadeToLockWindow
|
||||
active: SettingsData.fadeToLockEnabled
|
||||
asynchronous: false
|
||||
|
||||
@@ -67,29 +64,29 @@ Item {
|
||||
}
|
||||
|
||||
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 {
|
||||
target: IdleService
|
||||
enabled: fadeWindowLoader.loadedWindow !== null
|
||||
enabled: fadeWindowLoader.item !== null
|
||||
|
||||
function onFadeToLockRequested() {
|
||||
if (fadeWindowLoader.loadedWindow) {
|
||||
fadeWindowLoader.loadedWindow.startFade();
|
||||
if (fadeWindowLoader.item) {
|
||||
fadeWindowLoader.item.startFade();
|
||||
}
|
||||
}
|
||||
|
||||
function onCancelFadeToLock() {
|
||||
if (fadeWindowLoader.loadedWindow) {
|
||||
fadeWindowLoader.loadedWindow.cancelFade();
|
||||
if (fadeWindowLoader.item) {
|
||||
fadeWindowLoader.item.cancelFade();
|
||||
}
|
||||
}
|
||||
|
||||
function onDismissFadeToLock() {
|
||||
if (fadeWindowLoader.loadedWindow) {
|
||||
fadeWindowLoader.loadedWindow.dismiss();
|
||||
if (fadeWindowLoader.item) {
|
||||
fadeWindowLoader.item.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,7 +99,6 @@ Item {
|
||||
delegate: Loader {
|
||||
id: fadeDpmsWindowLoader
|
||||
required property var modelData
|
||||
readonly property FadeToDpmsWindow loadedWindow: item as FadeToDpmsWindow
|
||||
active: SettingsData.fadeToDpmsEnabled
|
||||
asynchronous: false
|
||||
|
||||
@@ -114,30 +110,30 @@ Item {
|
||||
}
|
||||
|
||||
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 {
|
||||
target: IdleService
|
||||
enabled: fadeDpmsWindowLoader.loadedWindow !== null
|
||||
enabled: fadeDpmsWindowLoader.item !== null
|
||||
|
||||
function onFadeToDpmsRequested() {
|
||||
if (fadeDpmsWindowLoader.loadedWindow) {
|
||||
fadeDpmsWindowLoader.loadedWindow.startFade();
|
||||
if (fadeDpmsWindowLoader.item) {
|
||||
fadeDpmsWindowLoader.item.startFade();
|
||||
}
|
||||
}
|
||||
|
||||
function onCancelFadeToDpms() {
|
||||
if (fadeDpmsWindowLoader.loadedWindow) {
|
||||
fadeDpmsWindowLoader.loadedWindow.cancelFade();
|
||||
if (fadeDpmsWindowLoader.item) {
|
||||
fadeDpmsWindowLoader.item.cancelFade();
|
||||
}
|
||||
}
|
||||
|
||||
function onRequestMonitorOn() {
|
||||
if (!fadeDpmsWindowLoader.loadedWindow)
|
||||
if (!fadeDpmsWindowLoader.item)
|
||||
return;
|
||||
fadeDpmsWindowLoader.loadedWindow.cancelFade();
|
||||
fadeDpmsWindowLoader.item.cancelFade();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,7 +213,7 @@ Item {
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
dockEnabled = true;
|
||||
dockRecreateDebounce.start();
|
||||
loginSoundTimer.start();
|
||||
osdStartupTimer.start();
|
||||
|
||||
@@ -355,7 +351,9 @@ Item {
|
||||
Variants {
|
||||
model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications")
|
||||
|
||||
delegate: NotificationPopupManager {}
|
||||
delegate: NotificationPopupManager {
|
||||
modelData: item
|
||||
}
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
@@ -389,7 +387,6 @@ Item {
|
||||
LazyLoader {
|
||||
id: wifiPasswordModalLoader
|
||||
active: false
|
||||
readonly property WifiPasswordModal loadedModal: item as WifiPasswordModal
|
||||
|
||||
Component.onCompleted: {
|
||||
PopoutService.wifiPasswordModalLoader = wifiPasswordModalLoader;
|
||||
@@ -424,7 +421,6 @@ Item {
|
||||
LazyLoader {
|
||||
id: polkitAuthModalLoader
|
||||
active: false
|
||||
readonly property PolkitAuthModal loadedModal: item as PolkitAuthModal
|
||||
|
||||
PolkitAuthModal {
|
||||
id: polkitAuthModal
|
||||
@@ -443,8 +439,8 @@ Item {
|
||||
if (PopoutService.systemUpdatePopout?.shouldBeVisible)
|
||||
return;
|
||||
polkitAuthModalLoader.active = true;
|
||||
if (polkitAuthModalLoader.loadedModal)
|
||||
polkitAuthModalLoader.loadedModal.show();
|
||||
if (polkitAuthModalLoader.item)
|
||||
polkitAuthModalLoader.item.show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,20 +459,20 @@ Item {
|
||||
target: NetworkService
|
||||
|
||||
function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) {
|
||||
const alreadyShown = wifiPasswordModalLoader.loadedModal && wifiPasswordModalLoader.loadedModal.shouldBeVisible;
|
||||
if (alreadyShown && token === root.lastCredentialsToken)
|
||||
const alreadyShown = wifiPasswordModalLoader.item && wifiPasswordModalLoader.item.shouldBeVisible;
|
||||
if (alreadyShown && token === lastCredentialsToken)
|
||||
return;
|
||||
|
||||
wifiPasswordModalLoader.active = true;
|
||||
if (!wifiPasswordModalLoader.loadedModal)
|
||||
if (!wifiPasswordModalLoader.item)
|
||||
return;
|
||||
|
||||
if (alreadyShown && root.lastCredentialsToken !== "" && root.lastCredentialsToken !== token)
|
||||
NetworkService.cancelCredentials(root.lastCredentialsToken);
|
||||
if (alreadyShown && lastCredentialsToken !== "" && lastCredentialsToken !== token)
|
||||
NetworkService.cancelCredentials(lastCredentialsToken);
|
||||
|
||||
root.lastCredentialsToken = token;
|
||||
root.lastCredentialsTime = Date.now();
|
||||
wifiPasswordModalLoader.loadedModal.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
|
||||
lastCredentialsToken = token;
|
||||
lastCredentialsTime = Date.now();
|
||||
wifiPasswordModalLoader.item.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,10 +769,10 @@ Item {
|
||||
}
|
||||
|
||||
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) {
|
||||
root.log.warn("Invalid app picker request data");
|
||||
log.warn("Invalid app picker request data");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -893,6 +889,7 @@ Item {
|
||||
|
||||
delegate: DankSlideout {
|
||||
id: notepadSlideout
|
||||
modelData: item
|
||||
title: I18n.tr("Notepad")
|
||||
slideoutWidth: 480
|
||||
expandable: true
|
||||
@@ -988,8 +985,8 @@ Item {
|
||||
onSwitchUserRequested: {
|
||||
switchUserModalLoader.active = true;
|
||||
Qt.callLater(() => {
|
||||
if (switchUserModalLoader.loadedModal)
|
||||
switchUserModalLoader.loadedModal.showFromPowerMenu();
|
||||
if (switchUserModalLoader.item)
|
||||
switchUserModalLoader.item.showFromPowerMenu();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1003,7 +1000,6 @@ Item {
|
||||
id: switchUserModalLoader
|
||||
|
||||
active: false
|
||||
readonly property SwitchUserModal loadedModal: item as SwitchUserModal
|
||||
|
||||
SwitchUserModal {
|
||||
id: switchUserModal
|
||||
@@ -1059,6 +1055,7 @@ Item {
|
||||
model: SettingsData.getFilteredScreens("toast")
|
||||
|
||||
delegate: Toast {
|
||||
modelData: item
|
||||
visible: ToastService.toastVisible
|
||||
}
|
||||
}
|
||||
@@ -1073,55 +1070,73 @@ Item {
|
||||
Variants {
|
||||
model: SettingsData.getFilteredScreens("osd")
|
||||
|
||||
delegate: VolumeOSD {}
|
||||
delegate: VolumeOSD {
|
||||
modelData: item
|
||||
}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: SettingsData.getFilteredScreens("osd")
|
||||
|
||||
delegate: MediaVolumeOSD {}
|
||||
delegate: MediaVolumeOSD {
|
||||
modelData: item
|
||||
}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: SettingsData.getFilteredScreens("osd")
|
||||
|
||||
delegate: MediaPlaybackOSD {}
|
||||
delegate: MediaPlaybackOSD {
|
||||
modelData: item
|
||||
}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: SettingsData.getFilteredScreens("osd")
|
||||
|
||||
delegate: MicVolumeOSD {}
|
||||
delegate: MicVolumeOSD {
|
||||
modelData: item
|
||||
}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: SettingsData.getFilteredScreens("osd")
|
||||
|
||||
delegate: BrightnessOSD {}
|
||||
delegate: BrightnessOSD {
|
||||
modelData: item
|
||||
}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: SettingsData.getFilteredScreens("osd")
|
||||
|
||||
delegate: IdleInhibitorOSD {}
|
||||
delegate: IdleInhibitorOSD {
|
||||
modelData: item
|
||||
}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
|
||||
|
||||
delegate: PowerProfileOSD {}
|
||||
delegate: PowerProfileOSD {
|
||||
modelData: item
|
||||
}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: SettingsData.getFilteredScreens("osd")
|
||||
|
||||
delegate: CapsLockOSD {}
|
||||
delegate: CapsLockOSD {
|
||||
modelData: item
|
||||
}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: SettingsData.getFilteredScreens("osd")
|
||||
|
||||
delegate: AudioOutputOSD {}
|
||||
delegate: AudioOutputOSD {
|
||||
modelData: item
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1138,7 +1153,6 @@ Item {
|
||||
Loader {
|
||||
id: greeterLoader
|
||||
active: false
|
||||
readonly property GreeterModal loadedModal: item as GreeterModal
|
||||
sourceComponent: GreeterModal {
|
||||
onGreeterCompleted: greeterLoader.active = false
|
||||
Component.onCompleted: show()
|
||||
@@ -1147,8 +1161,8 @@ Item {
|
||||
Connections {
|
||||
target: FirstLaunchService
|
||||
function onGreeterRequested() {
|
||||
if (greeterLoader.active && greeterLoader.loadedModal) {
|
||||
greeterLoader.loadedModal.show();
|
||||
if (greeterLoader.active && greeterLoader.item) {
|
||||
greeterLoader.item.show();
|
||||
return;
|
||||
}
|
||||
greeterLoader.active = true;
|
||||
@@ -1159,7 +1173,6 @@ Item {
|
||||
Loader {
|
||||
id: changelogLoader
|
||||
active: false
|
||||
readonly property ChangelogModal loadedModal: item as ChangelogModal
|
||||
sourceComponent: ChangelogModal {
|
||||
onChangelogDismissed: changelogLoader.active = false
|
||||
Component.onCompleted: show()
|
||||
@@ -1168,8 +1181,8 @@ Item {
|
||||
Connections {
|
||||
target: ChangelogService
|
||||
function onChangelogRequested() {
|
||||
if (changelogLoader.active && changelogLoader.loadedModal) {
|
||||
changelogLoader.loadedModal.show();
|
||||
if (changelogLoader.active && changelogLoader.item) {
|
||||
changelogLoader.item.show();
|
||||
return;
|
||||
}
|
||||
changelogLoader.active = true;
|
||||
|
||||
@@ -531,7 +531,9 @@ Item {
|
||||
}
|
||||
|
||||
function next(): void {
|
||||
MprisController.next();
|
||||
if (MprisController.activePlayer && MprisController.activePlayer.canGoNext) {
|
||||
MprisController.activePlayer.next();
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
|
||||
@@ -214,7 +214,7 @@ Item {
|
||||
Item {
|
||||
id: listClip
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: stickyHeader.visible ? 32 : 0
|
||||
anchors.topMargin: BlurService.enabled && stickyHeader.visible ? 32 : 0
|
||||
anchors.bottomMargin: bottomSectionHeader.visible ? bottomSectionHeader.height : 0
|
||||
clip: true
|
||||
|
||||
@@ -417,7 +417,7 @@ Item {
|
||||
anchors.top: parent.top
|
||||
height: 32
|
||||
z: 101
|
||||
color: "transparent"
|
||||
color: Theme.floatingSurface
|
||||
visible: !root._bottomSectionHeaderActive && stickyHeaderSection !== null
|
||||
|
||||
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Common
|
||||
@@ -32,8 +33,6 @@ Variants {
|
||||
|
||||
color: "transparent"
|
||||
|
||||
visible: root.contentReady && !root.surfaceBounce
|
||||
|
||||
updatesEnabled: root.renderActive || root._settleFrames > 0
|
||||
|
||||
mask: Region {
|
||||
@@ -56,96 +55,55 @@ Variants {
|
||||
}
|
||||
|
||||
property string source: SessionData.getMonitorWallpaper(modelData.name) || ""
|
||||
readonly property bool isColorSource: source.startsWith("#")
|
||||
readonly property string displaySource: {
|
||||
if (!source || isColorSource)
|
||||
return "";
|
||||
return source.startsWith("file://") ? source : encodeFileUrl(source);
|
||||
property bool isColorSource: source.startsWith("#")
|
||||
|
||||
Connections {
|
||||
target: SessionData
|
||||
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.
|
||||
property bool liveActive: false
|
||||
property bool frozenValid: false
|
||||
property string _frozenSource: ""
|
||||
property bool loadFailed: false
|
||||
property int _freezeWaitFrames: 0
|
||||
|
||||
readonly property var backingWindow: Window.window
|
||||
readonly property bool renderActive: !source || liveActive || _freezeWaitFrames > 0
|
||||
property int _settleFrames: 3
|
||||
|
||||
readonly property int maxTextureSize: 8192
|
||||
readonly property int textureWidth: Math.min(modelData.width, maxTextureSize)
|
||||
readonly property int textureHeight: Math.min(modelData.height, maxTextureSize)
|
||||
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 (!displaySource) {
|
||||
contentReady = true;
|
||||
return;
|
||||
}
|
||||
liveActive = true;
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
onDisplaySourceChanged: {
|
||||
invalidate();
|
||||
loadFailed = false;
|
||||
_freezeWaitFrames = 0;
|
||||
if (!displaySource) {
|
||||
liveActive = false;
|
||||
frozenValid = false;
|
||||
_frozenSource = "";
|
||||
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()
|
||||
property bool isInitialized: false
|
||||
property real transitionProgress: 0
|
||||
readonly property bool transitioning: transitionAnimation.running
|
||||
property bool effectActive: false
|
||||
property bool useNextForEffect: false
|
||||
readonly property var backingWindow: Window.window
|
||||
readonly property bool renderActive: !source || effectActive || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading
|
||||
property int _settleFrames: 3
|
||||
|
||||
function invalidate() {
|
||||
_settleFrames = 3;
|
||||
@@ -178,9 +136,9 @@ Variants {
|
||||
interval: 0
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root.surfaceBounce = true;
|
||||
blurWallpaperWindow.visible = false;
|
||||
Qt.callLater(() => {
|
||||
root.surfaceBounce = false;
|
||||
blurWallpaperWindow.visible = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -192,35 +150,29 @@ Variants {
|
||||
root._settleFrames--;
|
||||
root._wedgeBounced = false;
|
||||
wedgeWatchdog.stop();
|
||||
if (root._freezeWaitFrames > 0 && --root._freezeWaitFrames === 0)
|
||||
root.completeFreeze();
|
||||
}
|
||||
function onVisibleChanged() {
|
||||
root.invalidate();
|
||||
}
|
||||
function onWidthChanged() {
|
||||
root.regenerate();
|
||||
root.invalidate();
|
||||
}
|
||||
function onHeightChanged() {
|
||||
root.regenerate();
|
||||
}
|
||||
function onResourcesLost() {
|
||||
root.frozenValid = false;
|
||||
root.regenerate();
|
||||
root.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
root.regenerate();
|
||||
root.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onWallpaperFillModeChanged() {
|
||||
root.regenerate();
|
||||
root.invalidate();
|
||||
}
|
||||
function onEffectiveWallpaperBackgroundColorChanged() {
|
||||
root.invalidate();
|
||||
@@ -229,26 +181,18 @@ Variants {
|
||||
|
||||
Connections {
|
||||
target: SessionData
|
||||
function onIsLightModeChanged() {
|
||||
if (SessionData.perModeWallpaper) {
|
||||
var newSource = SessionData.getMonitorWallpaper(modelData.name) || "";
|
||||
if (newSource !== root.source) {
|
||||
root.source = newSource;
|
||||
}
|
||||
}
|
||||
}
|
||||
function onMonitorWallpaperFillModesChanged() {
|
||||
root.regenerate();
|
||||
root.invalidate();
|
||||
}
|
||||
function onPerMonitorWallpaperChanged() {
|
||||
root.regenerate();
|
||||
root.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
// Theme changes repaint DankBackdrop but nothing else wakes the render loop
|
||||
Connections {
|
||||
target: Theme
|
||||
enabled: root.isColorSource || root.loadFailed
|
||||
enabled: root.isColorSource || currentWallpaper.status === Image.Error
|
||||
function onPrimaryChanged() {
|
||||
root.invalidate();
|
||||
}
|
||||
@@ -266,23 +210,87 @@ Variants {
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: liveLoader.item
|
||||
function onBecameDisplayable() {
|
||||
root.handleDisplayable();
|
||||
function handleTransitionLoadError(failedSource) {
|
||||
log.warn("failed to load candidate wallpaper for", modelData.name + ":", failedSource);
|
||||
transitionDelayTimer.stop();
|
||||
transitionAnimation.stop();
|
||||
root.useNextForEffect = false;
|
||||
root.effectActive = false;
|
||||
root.transitionProgress = 0.0;
|
||||
nextWallpaper.source = "";
|
||||
}
|
||||
|
||||
onSourceChanged: {
|
||||
invalidate();
|
||||
if (!source || source.startsWith("#")) {
|
||||
setWallpaperImmediate("");
|
||||
return;
|
||||
}
|
||||
function onStableChanged() {
|
||||
if (liveLoader.item.stable)
|
||||
root.scheduleFreeze();
|
||||
|
||||
const formattedSource = source.startsWith("file://") ? source : encodeFileUrl(source);
|
||||
|
||||
if (!isInitialized || !currentWallpaper.source) {
|
||||
setWallpaperImmediate(formattedSource);
|
||||
isInitialized = true;
|
||||
return;
|
||||
}
|
||||
function onTransitioningChanged() {
|
||||
root.invalidate();
|
||||
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 {
|
||||
anchors.fill: parent
|
||||
active: !root.source || root.isColorSource || root.loadFailed
|
||||
active: !root.source || root.isColorSource || currentWallpaper.status === Image.Error
|
||||
asynchronous: true
|
||||
|
||||
sourceComponent: DankBackdrop {
|
||||
@@ -290,43 +298,123 @@ Variants {
|
||||
}
|
||||
}
|
||||
|
||||
ShaderEffectSource {
|
||||
id: frozenLayer
|
||||
readonly property int maxTextureSize: 8192
|
||||
property int textureWidth: Math.min(modelData.width, maxTextureSize)
|
||||
property int textureHeight: Math.min(modelData.height, maxTextureSize)
|
||||
|
||||
Image {
|
||||
id: currentWallpaper
|
||||
anchors.fill: parent
|
||||
sourceItem: liveContainer
|
||||
live: false
|
||||
visible: 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
|
||||
recursive: false
|
||||
smooth: true
|
||||
visible: root.frozenValid || root.liveActive
|
||||
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 {
|
||||
id: liveContainer
|
||||
id: blurredLayer
|
||||
anchors.fill: parent
|
||||
visible: root.liveActive
|
||||
|
||||
Loader {
|
||||
id: liveLoader
|
||||
MultiEffect {
|
||||
anchors.fill: parent
|
||||
active: root.liveActive
|
||||
asynchronous: false
|
||||
source: currentWallpaper
|
||||
visible: currentWallpaper.source !== ""
|
||||
blurEnabled: true
|
||||
blur: 0.8
|
||||
blurMax: 75
|
||||
opacity: 1 - root.transitionProgress
|
||||
autoPaddingEnabled: false
|
||||
}
|
||||
|
||||
// Cached images reach Ready synchronously during creation, before Connections retargets
|
||||
onLoaded: {
|
||||
if (item.displayableNow)
|
||||
root.handleDisplayable();
|
||||
if (item.stable)
|
||||
root.scheduleFreeze();
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
sourceComponent: BlurredWallpaperLive {
|
||||
wallpaperSource: root.displaySource
|
||||
initialSource: root._frozenSource
|
||||
screenName: modelData.name
|
||||
blurTextureSize: Qt.size(root.textureWidth, root.textureHeight)
|
||||
}
|
||||
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
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.nestedSurface
|
||||
border.color: Theme.outlineMedium
|
||||
border.width: Theme.layerOutlineWidth
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
Column {
|
||||
id: detailColumn
|
||||
@@ -134,11 +132,7 @@ PluginComponent {
|
||||
width: connButtonRow.implicitWidth + Theme.spacingM * 2
|
||||
|
||||
readonly property bool isConnected: TailscaleService.connected
|
||||
color: {
|
||||
if (!connButtonArea.containsMouse)
|
||||
return Theme.surfaceLight;
|
||||
return isConnected ? Theme.errorHover : Theme.primaryHoverLight;
|
||||
}
|
||||
color: isConnected ? (connButtonArea.containsMouse ? Theme.errorHover : Theme.surfaceLight) : (connButtonArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight)
|
||||
|
||||
Row {
|
||||
id: connButtonRow
|
||||
@@ -336,15 +330,13 @@ PluginComponent {
|
||||
required property var modelData
|
||||
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
|
||||
height: peerCardColumn.implicitHeight + Theme.spacingS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: peerMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||
border.color: isSelf ? Theme.primary : Theme.outlineLight
|
||||
border.width: isSelf ? 2 : 1
|
||||
color: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "") ? Theme.primaryHoverLight : Theme.surfaceContainerHighest
|
||||
|
||||
property bool isSelf: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "")
|
||||
property bool isExpanded: detailRoot.expandedHostname === modelData.hostname
|
||||
|
||||
Column {
|
||||
id: peerCardColumn
|
||||
@@ -362,14 +354,14 @@ PluginComponent {
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
color: modelData.online ? Theme.success : Theme.surfaceVariantText
|
||||
color: modelData.online ? "#4caf50" : Theme.surfaceVariantText
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: modelData.hostname || ""
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: isSelf ? Font.Medium : Font.Normal
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Bold
|
||||
color: Theme.surfaceText
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
@@ -378,7 +370,7 @@ PluginComponent {
|
||||
StyledText {
|
||||
visible: isSelf
|
||||
text: I18n.tr("This device", "Label for the user's own device in Tailscale")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.pixelSize: 10
|
||||
color: Theme.primary
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
@@ -417,7 +409,7 @@ PluginComponent {
|
||||
}
|
||||
return parts.join(" \u2022 ");
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.pixelSize: 10
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
@@ -437,7 +429,7 @@ PluginComponent {
|
||||
|
||||
StyledText {
|
||||
text: modelData.dnsName || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.pixelSize: 10
|
||||
color: Theme.surfaceVariantText
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
@@ -455,14 +447,14 @@ PluginComponent {
|
||||
StyledText {
|
||||
visible: (modelData.tags || []).length > 0
|
||||
text: I18n.tr("Tags: %1", "Tailscale device tags").arg((modelData.tags || []).join(", "))
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.pixelSize: 10
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
visible: (modelData.owner || "").length > 0
|
||||
text: I18n.tr("Owner: %1", "Tailscale device owner").arg(modelData.owner || "")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.pixelSize: 10
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ Item {
|
||||
readonly property real _barInsetPaddingAuto: _barIsVertical ? Theme.spacingXS : _edgeBaseMargin
|
||||
readonly property real _barInsetPadding: _barInsetPaddingRaw < 0 ? _barInsetPaddingAuto : _barInsetPaddingRaw
|
||||
// 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 _frameInsetExtra: Math.max(0, _frameInsetResolved - SettingsData.frameThickness)
|
||||
|
||||
@@ -47,28 +47,28 @@ Item {
|
||||
if (_barIsVertical)
|
||||
return _edgeBaseMargin;
|
||||
if (_usesFrameBarChrome)
|
||||
return hasAdjacentLeftBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : _frameInsetExtra;
|
||||
return hasAdjacentLeftBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
|
||||
return Math.max(0, _barInsetPadding);
|
||||
}
|
||||
readonly property real _rightMargin: {
|
||||
if (_barIsVertical)
|
||||
return _edgeBaseMargin;
|
||||
if (_usesFrameBarChrome)
|
||||
return hasAdjacentRightBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : _frameInsetExtra;
|
||||
return hasAdjacentRightBarLive ? (_edgeBaseMargin + SettingsData.frameBarSize + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
|
||||
return Math.max(0, _barInsetPadding);
|
||||
}
|
||||
readonly property real _topMargin: {
|
||||
if (!_barIsVertical)
|
||||
return 0;
|
||||
if (_usesFrameBarChrome)
|
||||
return hasAdjacentTopBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : _frameInsetExtra;
|
||||
return hasAdjacentTopBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
|
||||
return Math.max(0, _barInsetPadding);
|
||||
}
|
||||
readonly property real _bottomMargin: {
|
||||
if (!_barIsVertical)
|
||||
return 0;
|
||||
if (_usesFrameBarChrome)
|
||||
return hasAdjacentBottomBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : _frameInsetExtra;
|
||||
return hasAdjacentBottomBarLive ? (outlineThickness + SettingsData.frameThickness + _frameInsetExtra) : Math.max(0, _frameInsetResolved);
|
||||
return Math.max(0, _barInsetPadding);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ DankPopout {
|
||||
color: "transparent"
|
||||
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)
|
||||
|
||||
@@ -272,7 +272,7 @@ DankPopout {
|
||||
includeAUR: SettingsData.updaterAllowAUR,
|
||||
terminal: SessionData.terminalOverride
|
||||
};
|
||||
if (updaterPanel.upgradeRunsInTerminal) {
|
||||
if (updaterPanel.hasTerminalBackend) {
|
||||
systemUpdatePopout._reopenAfterUpgrade = true;
|
||||
SystemUpdateService.runUpdates(opts);
|
||||
systemUpdatePopout.close();
|
||||
@@ -336,10 +336,7 @@ DankPopout {
|
||||
|
||||
StyledText {
|
||||
id: statusText
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: ignoredSection.top
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
@@ -364,10 +361,7 @@ DankPopout {
|
||||
|
||||
DankListView {
|
||||
id: packagesList
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: ignoredSection.top
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingS
|
||||
visible: !SystemUpdateService.isUpgrading && SystemUpdateService.updateCount > 0 && !SystemUpdateService.hasError && !SystemUpdateService.isChecking
|
||||
clip: true
|
||||
@@ -473,128 +467,18 @@ DankPopout {
|
||||
iconName: "visibility_off"
|
||||
iconSize: 16
|
||||
iconColor: Theme.surfaceVariantText
|
||||
visible: rowHoverHandler.hovered && SystemUpdateService.canIgnorePackage(packageRow.modelData)
|
||||
visible: rowHoverHandler.hovered
|
||||
tooltipText: I18n.tr("Ignore this package")
|
||||
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 {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
visible: SystemUpdateService.isUpgrading && updaterPanel.upgradeRunsInTerminal
|
||||
visible: SystemUpdateService.isUpgrading && updaterPanel.hasTerminalBackend
|
||||
|
||||
DankIcon {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
@@ -625,7 +509,7 @@ DankPopout {
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
visible: SystemUpdateService.isUpgrading && !updaterPanel.upgradeRunsInTerminal
|
||||
visible: SystemUpdateService.isUpgrading && !updaterPanel.hasTerminalBackend
|
||||
contentWidth: width
|
||||
contentHeight: logText.implicitHeight
|
||||
clip: true
|
||||
|
||||
@@ -13,6 +13,33 @@ BasePill {
|
||||
readonly property bool _hoverPreview: MprisController.isFirefoxYoutubeHoverPreview(activePlayer)
|
||||
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: {
|
||||
if (!activePlayer?.identity)
|
||||
return false;
|
||||
@@ -102,7 +129,7 @@ BasePill {
|
||||
if (deltaY > 0) {
|
||||
MprisController.previousOrRewind();
|
||||
} else {
|
||||
MprisController.next();
|
||||
activePlayer.next();
|
||||
}
|
||||
} else {
|
||||
scrollAccumulatorY += deltaY;
|
||||
@@ -110,7 +137,7 @@ BasePill {
|
||||
if (scrollAccumulatorY > 0) {
|
||||
MprisController.previousOrRewind();
|
||||
} else {
|
||||
MprisController.next();
|
||||
activePlayer.next();
|
||||
}
|
||||
scrollAccumulatorY = 0;
|
||||
}
|
||||
@@ -239,7 +266,7 @@ BasePill {
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
MprisController.previousOrRewind();
|
||||
} 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")
|
||||
|
||||
property string displayText: {
|
||||
if (!activePlayer || !MprisController.stableTitle)
|
||||
if (!activePlayer || !root._stableTitle)
|
||||
return "";
|
||||
const title = MprisController.stableTitle;
|
||||
const subtitle = isWebMedia ? (MprisController.stableArtist || cachedIdentity) : MprisController.stableArtist;
|
||||
const title = isWebMedia ? root._stableTitle : (root._stableTitle || "Unknown Track");
|
||||
const subtitle = isWebMedia ? (root._stableArtist || cachedIdentity) : (root._stableArtist || "");
|
||||
return subtitle.length > 0 ? title + " • " + subtitle : title;
|
||||
}
|
||||
|
||||
@@ -317,45 +344,9 @@ BasePill {
|
||||
id: mediaText
|
||||
readonly property bool onScreen: Window.window?.visible ?? false
|
||||
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 int scrollDirection: 1
|
||||
property real scrollHoldMs: 2000
|
||||
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
|
||||
text: textContainer.displayText
|
||||
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
||||
@@ -365,38 +356,40 @@ BasePill {
|
||||
opacity: 1
|
||||
|
||||
onTextChanged: {
|
||||
resetScroll();
|
||||
scrollOffset = 0;
|
||||
textShift = 0;
|
||||
textChangeAnimation.restart();
|
||||
}
|
||||
|
||||
// Timer stepping, not NumberAnimation — a running animation commits frames every vsync (#2863).
|
||||
// When cava frames are already driving renders, scroll steps ride those ticks instead —
|
||||
// two unsynchronized tick sources nearly double the surface commit rate (#2863).
|
||||
Timer {
|
||||
id: scrollTimer
|
||||
SequentialAnimation {
|
||||
id: scrollAnimation
|
||||
running: mediaText.needsScrolling && textContainer.visible && mediaText.onScreen && root._isPlaying
|
||||
loops: Animation.Infinite
|
||||
onStopped: mediaText.scrollOffset = 0
|
||||
|
||||
interval: 60
|
||||
repeat: true
|
||||
running: mediaText.scrollActive
|
||||
onTriggered: {
|
||||
if (cavaTickWatch.running)
|
||||
return;
|
||||
mediaText.stepScroll(60);
|
||||
PauseAnimation {
|
||||
duration: 2000
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: cavaTickWatch
|
||||
interval: 150
|
||||
}
|
||||
NumberAnimation {
|
||||
target: mediaText
|
||||
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 {
|
||||
target: CavaService
|
||||
enabled: mediaText.scrollActive && SettingsData.audioVisualizerEnabled && CavaService.cavaAvailable
|
||||
function onValuesChanged() {
|
||||
cavaTickWatch.restart();
|
||||
mediaText.stepScroll(40);
|
||||
PauseAnimation {
|
||||
duration: 2000
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
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
|
||||
onClicked: {
|
||||
if (activePlayer) {
|
||||
MprisController.next();
|
||||
activePlayer.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1752,11 +1752,7 @@ BasePill {
|
||||
id: trayMenuContainer
|
||||
|
||||
readonly property real rawWidth: Math.min(500, Math.max(250, menuColumn.implicitWidth + Theme.spacingS * 2))
|
||||
readonly property real rawHeight: {
|
||||
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 rawHeight: Math.max(40, menuColumn.implicitHeight + Theme.spacingS * 2)
|
||||
|
||||
readonly property real alignedWidth: Theme.px(rawWidth, menuWindow.dpr)
|
||||
readonly property real alignedHeight: Theme.px(rawHeight, menuWindow.dpr)
|
||||
@@ -1856,243 +1852,236 @@ BasePill {
|
||||
}
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
id: menuFlickable
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingS
|
||||
contentWidth: width
|
||||
contentHeight: menuColumn.implicitHeight
|
||||
clip: true
|
||||
interactive: contentHeight > height
|
||||
Column {
|
||||
id: menuColumn
|
||||
|
||||
Column {
|
||||
id: menuColumn
|
||||
width: parent.width - Theme.spacingS * 2
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: Theme.spacingS
|
||||
spacing: 1
|
||||
|
||||
width: menuFlickable.width
|
||||
spacing: 1
|
||||
Rectangle {
|
||||
visible: entryStack.count === 0
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: visibilityToggleArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0)
|
||||
|
||||
Rectangle {
|
||||
visible: entryStack.count === 0
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: visibilityToggleArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0)
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
const itemId = menuRoot.trayItem?.id || "Unknown";
|
||||
if (root.isAutoOverflowTrayItem(menuRoot.trayItem))
|
||||
return itemId + " · " + I18n.tr("Keep in Bar");
|
||||
return itemId;
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
elide: Text.ElideMiddle
|
||||
width: parent.width - Theme.spacingS * 2 - (Theme.iconSizeSmall + Theme.spacingS)
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
const itemId = menuRoot.trayItem?.id || "Unknown";
|
||||
if (root.isAutoOverflowTrayItem(menuRoot.trayItem))
|
||||
return itemId + " · " + I18n.tr("Keep in Bar");
|
||||
return itemId;
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
elide: Text.ElideMiddle
|
||||
width: parent.width - Theme.spacingS * 2 - 24
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: {
|
||||
if (root.isAutoOverflowTrayItem(menuRoot.trayItem))
|
||||
return "push_pin";
|
||||
return root.isManualHiddenTrayItem(menuRoot.trayItem) ? "visibility" : "visibility_off";
|
||||
}
|
||||
size: 16
|
||||
color: Theme.widgetTextColor
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: visibilityToggleArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
const itemKey = root.getTrayItemKey(menuRoot.trayItem);
|
||||
if (!itemKey)
|
||||
return;
|
||||
if (root.isAutoOverflowTrayItem(menuRoot.trayItem)) {
|
||||
root.promoteTrayItemToBar(menuRoot.trayItem);
|
||||
} else if (root.isManualHiddenTrayItem(menuRoot.trayItem)) {
|
||||
SessionData.showTrayId(itemKey);
|
||||
} else {
|
||||
SessionData.hideTrayId(itemKey);
|
||||
}
|
||||
menuRoot.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: entryStack.count === 0
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outlineHeavy
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: entryStack.count > 0
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: backArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0)
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankIcon {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: {
|
||||
if (root.isAutoOverflowTrayItem(menuRoot.trayItem))
|
||||
return "push_pin";
|
||||
return root.isManualHiddenTrayItem(menuRoot.trayItem) ? "visibility" : "visibility_off";
|
||||
}
|
||||
size: Theme.iconSizeSmall
|
||||
name: "arrow_back"
|
||||
size: 16
|
||||
color: Theme.widgetTextColor
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Back")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.widgetTextColor
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: backArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: menuRoot.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: entryStack.count > 0
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outlineHeavy
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: entryStack.count ? (subOpener.children ? subOpener.children : (menuRoot.topEntry()?.children || [])) : rootOpener.children
|
||||
|
||||
Rectangle {
|
||||
property var menuEntry: modelData
|
||||
|
||||
width: menuColumn.width
|
||||
height: menuEntry?.isSeparator ? 1 : 28
|
||||
radius: menuEntry?.isSeparator ? 0 : Theme.cornerRadius
|
||||
color: {
|
||||
if (menuEntry?.isSeparator)
|
||||
return Theme.outlineHeavy;
|
||||
return itemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0);
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: visibilityToggleArea
|
||||
id: itemArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: !menuEntry?.isSeparator && (menuEntry?.enabled !== false)
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
onClicked: {
|
||||
const itemKey = root.getTrayItemKey(menuRoot.trayItem);
|
||||
if (!itemKey)
|
||||
if (!menuEntry || menuEntry.isSeparator)
|
||||
return;
|
||||
if (menuEntry.hasChildren) {
|
||||
menuRoot.showSubMenu(menuEntry);
|
||||
return;
|
||||
if (root.isAutoOverflowTrayItem(menuRoot.trayItem)) {
|
||||
root.promoteTrayItemToBar(menuRoot.trayItem);
|
||||
} else if (root.isManualHiddenTrayItem(menuRoot.trayItem)) {
|
||||
SessionData.showTrayId(itemKey);
|
||||
} else {
|
||||
SessionData.hideTrayId(itemKey);
|
||||
}
|
||||
menuRoot.close();
|
||||
|
||||
if (typeof menuEntry.activate === "function") {
|
||||
menuEntry.activate();
|
||||
} else if (typeof menuEntry.triggered === "function") {
|
||||
menuEntry.triggered();
|
||||
}
|
||||
pendingActionCloseTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: entryStack.count === 0
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outlineHeavy
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: entryStack.count > 0
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: backArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0)
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
visible: !menuEntry?.isSeparator
|
||||
|
||||
DankIcon {
|
||||
name: "arrow_back"
|
||||
size: Theme.iconSizeSmall
|
||||
color: Theme.widgetTextColor
|
||||
Rectangle {
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0
|
||||
radius: menuEntry?.buttonType === 2 ? 8 : 2
|
||||
border.width: 1
|
||||
border.color: Theme.outline
|
||||
color: "transparent"
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 6
|
||||
height: parent.height - 6
|
||||
radius: parent.radius - 3
|
||||
color: Theme.primary
|
||||
visible: menuEntry?.checkState === 2
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "check"
|
||||
size: 10
|
||||
color: Theme.primaryText
|
||||
visible: menuEntry?.buttonType === 1 && menuEntry?.checkState === 2
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: (menuEntry?.icon ?? "") !== ""
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: menuEntry?.icon || ""
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Back")
|
||||
text: menuEntry?.text || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.widgetTextColor
|
||||
color: (menuEntry?.enabled !== false) ? Theme.surfaceText : Theme.surfaceTextMedium
|
||||
elide: Text.ElideRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: backArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: menuRoot.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: entryStack.count > 0
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outlineHeavy
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: entryStack.count ? (subOpener.children ? subOpener.children : (menuRoot.topEntry()?.children || [])) : rootOpener.children
|
||||
|
||||
Rectangle {
|
||||
property var menuEntry: modelData
|
||||
|
||||
width: menuColumn.width
|
||||
height: menuEntry?.isSeparator ? 1 : 28
|
||||
radius: menuEntry?.isSeparator ? 0 : Theme.cornerRadius
|
||||
color: {
|
||||
if (menuEntry?.isSeparator)
|
||||
return Theme.outlineHeavy;
|
||||
return itemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0);
|
||||
width: Math.max(150, parent.width - 64)
|
||||
wrapMode: Text.NoWrap
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: itemArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: !menuEntry?.isSeparator && (menuEntry?.enabled !== false)
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
onClicked: {
|
||||
if (!menuEntry || menuEntry.isSeparator)
|
||||
return;
|
||||
if (menuEntry.hasChildren) {
|
||||
menuRoot.showSubMenu(menuEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof menuEntry.activate === "function") {
|
||||
menuEntry.activate();
|
||||
} else if (typeof menuEntry.triggered === "function") {
|
||||
menuEntry.triggered();
|
||||
}
|
||||
pendingActionCloseTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
Item {
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
visible: !menuEntry?.isSeparator
|
||||
|
||||
Rectangle {
|
||||
width: Theme.iconSizeSmall
|
||||
height: Theme.iconSizeSmall
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0
|
||||
radius: menuEntry?.buttonType === 2 ? width / 2 : 2
|
||||
border.width: 1
|
||||
border.color: Theme.outline
|
||||
color: "transparent"
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 6
|
||||
height: parent.height - 6
|
||||
radius: parent.radius - 3
|
||||
color: Theme.primary
|
||||
visible: menuEntry?.checkState === 2
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "check"
|
||||
size: Theme.iconSizeSmall - 6
|
||||
color: Theme.primaryText
|
||||
visible: menuEntry?.buttonType === 1 && menuEntry?.checkState === 2
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: Theme.iconSizeSmall
|
||||
height: Theme.iconSizeSmall
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: (menuEntry?.icon ?? "") !== ""
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: menuEntry?.icon || ""
|
||||
sourceSize.width: Theme.iconSizeSmall
|
||||
sourceSize.height: Theme.iconSizeSmall
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: menuEntry?.text || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: (menuEntry?.enabled !== false) ? Theme.surfaceText : Theme.surfaceTextMedium
|
||||
elide: Text.ElideRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.max(150, parent.width - 64)
|
||||
wrapMode: Text.NoWrap
|
||||
}
|
||||
|
||||
Item {
|
||||
width: Theme.iconSizeSmall
|
||||
height: Theme.iconSizeSmall
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "chevron_right"
|
||||
size: Theme.iconSizeSmall - 2
|
||||
color: Theme.widgetTextColor
|
||||
visible: menuEntry?.hasChildren ?? false
|
||||
}
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "chevron_right"
|
||||
size: 14
|
||||
color: Theme.widgetTextColor
|
||||
visible: menuEntry?.hasChildren ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ Item {
|
||||
property int dropdownType: 0
|
||||
property var activePlayer: null
|
||||
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 bool isRightEdge: false
|
||||
property var targetWindow: null
|
||||
@@ -66,22 +64,6 @@ Item {
|
||||
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: {
|
||||
switch (dropdownType) {
|
||||
case 1:
|
||||
@@ -162,7 +144,6 @@ Item {
|
||||
hoverEnabled: true
|
||||
onEntered: panelAreaEntered()
|
||||
onExited: panelAreaExited()
|
||||
onWheel: wheelEvent => root.volumeWheel(wheelEvent)
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -222,7 +203,6 @@ Item {
|
||||
|
||||
onEntered: panelAreaEntered()
|
||||
onExited: panelAreaExited()
|
||||
onWheel: wheelEvent => root.volumeWheel(wheelEvent)
|
||||
onPressed: mouse => updateVolume(mouse)
|
||||
onPositionChanged: mouse => {
|
||||
if (pressed)
|
||||
@@ -448,7 +428,7 @@ Item {
|
||||
id: playersPanel
|
||||
visible: dropdownType === 3
|
||||
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
|
||||
y: anchorPos.y - height / 2
|
||||
radius: Theme.cornerRadius * 2
|
||||
@@ -505,7 +485,7 @@ Item {
|
||||
anchors.margins: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Media Players (") + (root.selectablePlayers?.length || 0) + ")"
|
||||
text: I18n.tr("Media Players (") + (allPlayers?.length || 0) + ")"
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
@@ -526,7 +506,7 @@ Item {
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Repeater {
|
||||
model: root.selectablePlayers || []
|
||||
model: allPlayers || []
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
@@ -602,4 +582,5 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ Item {
|
||||
playersExpanded = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
readonly property bool isRightEdge: {
|
||||
if (barPosition === SettingsData.Position.Right)
|
||||
return true;
|
||||
@@ -340,6 +342,54 @@ Item {
|
||||
root.maybeFinishSwitch();
|
||||
}
|
||||
|
||||
component BgBlurLayer: ClippingRectangle {
|
||||
id: layer
|
||||
property alias art: layerImg.source
|
||||
readonly property bool ready: layerImg.status === Image.Ready && layerImg.source != ""
|
||||
property bool front: false
|
||||
signal loaded
|
||||
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: "transparent"
|
||||
antialiasing: true
|
||||
opacity: front ? 0.7 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: 350
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
|
||||
Image {
|
||||
id: layerImg
|
||||
anchors.centerIn: parent
|
||||
width: Math.max(parent.width, parent.height) * 1.1
|
||||
height: width
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
cache: true
|
||||
visible: false
|
||||
onStatusChanged: {
|
||||
if (status === Image.Ready && source != "")
|
||||
layer.loaded();
|
||||
}
|
||||
}
|
||||
|
||||
MultiEffect {
|
||||
anchors.centerIn: parent
|
||||
width: layerImg.width
|
||||
height: layerImg.height
|
||||
source: layerImg
|
||||
blurEnabled: true
|
||||
blurMax: 64
|
||||
blur: 0.8
|
||||
saturation: -0.2
|
||||
brightness: -0.25
|
||||
}
|
||||
}
|
||||
|
||||
BgBlurLayer {
|
||||
id: layerA
|
||||
front: bgContainer._showA
|
||||
@@ -360,54 +410,6 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
component BgBlurLayer: ClippingRectangle {
|
||||
id: layer
|
||||
property alias art: layerImg.source
|
||||
readonly property bool ready: layerImg.status === Image.Ready && layerImg.source != ""
|
||||
property bool front: false
|
||||
signal loaded
|
||||
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: "transparent"
|
||||
antialiasing: true
|
||||
opacity: front ? 0.7 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: 350
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
|
||||
Image {
|
||||
id: layerImg
|
||||
anchors.centerIn: parent
|
||||
width: Math.max(parent.width, parent.height) * 1.1
|
||||
height: width
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
cache: true
|
||||
visible: false
|
||||
onStatusChanged: {
|
||||
if (status === Image.Ready && source != "")
|
||||
layer.loaded();
|
||||
}
|
||||
}
|
||||
|
||||
MultiEffect {
|
||||
anchors.centerIn: parent
|
||||
width: layerImg.width
|
||||
height: layerImg.height
|
||||
source: layerImg
|
||||
blurEnabled: true
|
||||
blurMax: 64
|
||||
blur: 0.8
|
||||
saturation: -0.2
|
||||
brightness: -0.25
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingM
|
||||
@@ -467,7 +469,7 @@ Item {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
StyledText {
|
||||
text: MprisController.stableTitle || I18n.tr("Unknown Track")
|
||||
text: activePlayer?.trackTitle || I18n.tr("Unknown Track")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Bold
|
||||
color: Theme.surfaceText
|
||||
@@ -479,7 +481,7 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: MprisController.stableArtist || I18n.tr("Unknown Artist")
|
||||
text: activePlayer?.trackArtist || I18n.tr("Unknown Artist")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceTextMedium
|
||||
width: parent.width
|
||||
@@ -660,12 +662,11 @@ Item {
|
||||
weight: 500
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
id: playPauseArea
|
||||
disabled: !root.activePlayer || !root.activePlayer.canTogglePlaying
|
||||
stateColor: root.onAccent
|
||||
cornerRadius: parent.radius
|
||||
onClicked: root.activePlayer.togglePlaying()
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: activePlayer && activePlayer.togglePlaying()
|
||||
}
|
||||
|
||||
ElevationShadow {
|
||||
@@ -705,7 +706,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: MprisController.next()
|
||||
onClicked: activePlayer && activePlayer.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -797,17 +798,16 @@ Item {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (playersExpanded) {
|
||||
const players = (root.allPlayers || []).filter(p => p && !MprisController.isIdle(p));
|
||||
if (players.length > 1) {
|
||||
if (allPlayers && allPlayers.length > 1) {
|
||||
let currentIndex = -1;
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
if (players[i] === root.activePlayer) {
|
||||
for (let i = 0; i < allPlayers.length; i++) {
|
||||
if (allPlayers[i] === activePlayer) {
|
||||
currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const nextIndex = (currentIndex + 1) % players.length;
|
||||
MprisController.setActivePlayer(players[nextIndex]);
|
||||
const nextIndex = (currentIndex + 1) % allPlayers.length;
|
||||
MprisController.setActivePlayer(allPlayers[nextIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -884,15 +884,19 @@ Item {
|
||||
onClicked: {
|
||||
toggleMute();
|
||||
}
|
||||
property real wheelAccum: 0
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ Card {
|
||||
topPadding: Theme.spacingL
|
||||
|
||||
StyledText {
|
||||
text: MprisController.stableTitle || I18n.tr("Unknown")
|
||||
text: activePlayer?.trackTitle || I18n.tr("Unknown")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
@@ -99,7 +99,7 @@ Card {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: MprisController.stableArtist || I18n.tr("Unknown Artist")
|
||||
text: activePlayer?.trackArtist || I18n.tr("Unknown Artist")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
width: parent.width
|
||||
@@ -190,7 +190,7 @@ Card {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: MprisController.next()
|
||||
onClicked: activePlayer?.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,7 +936,7 @@ Item {
|
||||
id: dailyLoader
|
||||
anchors.fill: parent
|
||||
sourceComponent: dailyComponent
|
||||
active: root.visible && root.available && width > 0 && height > 0
|
||||
active: root.visible && root.available
|
||||
visible: !root.showHourly
|
||||
asynchronous: true
|
||||
opacity: 0
|
||||
@@ -953,7 +953,7 @@ Item {
|
||||
id: hourlyLoader
|
||||
anchors.fill: parent
|
||||
sourceComponent: hourlyComponent
|
||||
active: root.visible && root.available && width > 0 && height > 0
|
||||
active: root.visible && root.available
|
||||
visible: root.showHourly
|
||||
asynchronous: true
|
||||
opacity: 0
|
||||
@@ -1038,7 +1038,6 @@ Item {
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
property real hWheelAccum: 0
|
||||
onWheel: wheel => {
|
||||
if (wheel.modifiers & Qt.ShiftModifier) {
|
||||
if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) {
|
||||
@@ -1050,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;
|
||||
}
|
||||
}
|
||||
@@ -1106,7 +1096,6 @@ Item {
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
property real hWheelAccum: 0
|
||||
onWheel: wheel => {
|
||||
if (wheel.modifiers & Qt.ShiftModifier) {
|
||||
if (wheel.angleDelta.y % 120 == 0 && wheel.angleDelta.x == 0) {
|
||||
@@ -1118,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,19 +340,7 @@ Variants {
|
||||
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: {
|
||||
if (!startupRevealDone)
|
||||
return false;
|
||||
|
||||
if (_modalRetractActive)
|
||||
return false;
|
||||
|
||||
@@ -640,7 +628,6 @@ Variants {
|
||||
id: dockContainer
|
||||
anchors.fill: parent
|
||||
clip: false
|
||||
opacity: dock.startupRevealDone ? 1 : 0
|
||||
|
||||
transform: Translate {
|
||||
id: dockSlide
|
||||
|
||||
@@ -73,14 +73,14 @@ Item {
|
||||
readonly property bool greeterPamHasFprint: greeterPamStackHasFprint && (!fprintdProbeComplete || fprintdHasDevice)
|
||||
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
|
||||
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 string externalAuthStatusMessage: {
|
||||
if (!externalAuthInProgress)
|
||||
return "";
|
||||
if (greeterPamHasFprint && greeterPamHasU2f)
|
||||
if (greeterPamStackHasFprint && greeterPamHasU2f)
|
||||
return I18n.tr("Awaiting fingerprint or security key authentication");
|
||||
if (greeterPamHasFprint)
|
||||
if (greeterPamStackHasFprint)
|
||||
return I18n.tr("Awaiting fingerprint authentication");
|
||||
return I18n.tr("Awaiting security key authentication");
|
||||
}
|
||||
|
||||
@@ -13,12 +13,6 @@ Scope {
|
||||
property string sharedPasswordBuffer: ""
|
||||
property bool shouldLock: false
|
||||
|
||||
onSharedPasswordBufferChanged: {
|
||||
if (!powerOffFadeTimer.running)
|
||||
return;
|
||||
cancelPowerOffFade();
|
||||
}
|
||||
|
||||
onShouldLockChanged: {
|
||||
IdleService.isShellLocked = shouldLock;
|
||||
if (shouldLock && lockPowerOffArmed) {
|
||||
@@ -33,8 +27,12 @@ Scope {
|
||||
onTriggered: {
|
||||
if (sessionLock.locked && lockPowerOffArmed) {
|
||||
pendingLock = false;
|
||||
IdleService.monitorsOff = true;
|
||||
CompositorService.powerOffMonitors();
|
||||
lockWakeAllowed = false;
|
||||
lockWakeDebounce.restart();
|
||||
lockPowerOffArmed = false;
|
||||
beginPowerOff();
|
||||
dpmsReapplyTimer.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,38 +42,6 @@ Scope {
|
||||
property bool lockPowerOffArmed: false
|
||||
property bool lockWakeAllowed: 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;
|
||||
CompositorService.powerOffMonitors();
|
||||
lockWakeAllowed = false;
|
||||
lockWakeDebounce.restart();
|
||||
dpmsReapplyTimer.start();
|
||||
}
|
||||
|
||||
function resetPowerOffFade() {
|
||||
powerOffFadeTimer.stop();
|
||||
powerOffFadeInstant = true;
|
||||
powerOffFadeTarget = 0;
|
||||
}
|
||||
|
||||
function cancelPowerOffFade() {
|
||||
resetPowerOffFade();
|
||||
IdleService.lockPowerOffRequested = false;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
IdleService.lockComponent = this;
|
||||
@@ -99,7 +65,6 @@ Scope {
|
||||
}
|
||||
|
||||
function spawnCustomLocker() {
|
||||
IdleService.lockPowerOffRequested = false;
|
||||
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionLock]);
|
||||
// The custom locker manages its own surface; DMS never engages
|
||||
// WlSessionLock here, so isShellLocked stays false and the fade
|
||||
@@ -125,7 +90,7 @@ Scope {
|
||||
return;
|
||||
|
||||
lockInitiatedLocally = true;
|
||||
lockPowerOffArmed = powerOffOnLock;
|
||||
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
|
||||
|
||||
if (!SessionService.active && SessionService.loginctlAvailable && SettingsData.loginctlLockIntegration) {
|
||||
pendingLock = true;
|
||||
@@ -137,16 +102,6 @@ Scope {
|
||||
notifyLoginctl(true);
|
||||
}
|
||||
|
||||
function lockAndOutputsOff() {
|
||||
IdleService.lockPowerOffRequested = true;
|
||||
if (sessionLock.locked) {
|
||||
beginPowerOff();
|
||||
return;
|
||||
}
|
||||
lockPowerOffArmed = true;
|
||||
lock();
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
if (!shouldLock)
|
||||
return;
|
||||
@@ -160,8 +115,6 @@ Scope {
|
||||
pendingLock = false;
|
||||
shouldLock = false;
|
||||
customLockerSpawned = false;
|
||||
resetPowerOffFade();
|
||||
IdleService.lockPowerOffRequested = false;
|
||||
}
|
||||
|
||||
function activate() {
|
||||
@@ -182,7 +135,7 @@ Scope {
|
||||
return;
|
||||
}
|
||||
lockInitiatedLocally = false;
|
||||
lockPowerOffArmed = powerOffOnLock;
|
||||
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
|
||||
shouldLock = true;
|
||||
}
|
||||
|
||||
@@ -202,7 +155,7 @@ Scope {
|
||||
if (SessionService.active && pendingLock) {
|
||||
pendingLock = false;
|
||||
lockInitiatedLocally = true;
|
||||
lockPowerOffArmed = powerOffOnLock;
|
||||
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
|
||||
shouldLock = true;
|
||||
return;
|
||||
}
|
||||
@@ -210,7 +163,7 @@ Scope {
|
||||
if (handleLoginctlCustomLock())
|
||||
return;
|
||||
lockInitiatedLocally = false;
|
||||
lockPowerOffArmed = powerOffOnLock;
|
||||
lockPowerOffArmed = SettingsData.lockScreenPowerOffMonitorsOnLock;
|
||||
shouldLock = true;
|
||||
}
|
||||
}
|
||||
@@ -222,11 +175,6 @@ Scope {
|
||||
function onLockRequested() {
|
||||
lock();
|
||||
}
|
||||
|
||||
function onMonitorsOffChanged() {
|
||||
if (!IdleService.monitorsOff)
|
||||
root.resetPowerOffFade();
|
||||
}
|
||||
}
|
||||
|
||||
Pam {
|
||||
@@ -266,46 +214,6 @@ Scope {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,19 +224,22 @@ Scope {
|
||||
notifyLockedHint(sessionLock.locked);
|
||||
if (sessionLock.locked) {
|
||||
pendingLock = false;
|
||||
if (lockPowerOffArmed && powerOffOnLock)
|
||||
beginPowerOff();
|
||||
if (lockPowerOffArmed && SettingsData.lockScreenPowerOffMonitorsOnLock) {
|
||||
IdleService.monitorsOff = true;
|
||||
CompositorService.powerOffMonitors();
|
||||
lockWakeAllowed = false;
|
||||
lockWakeDebounce.restart();
|
||||
}
|
||||
lockPowerOffArmed = false;
|
||||
dpmsReapplyTimer.start();
|
||||
return;
|
||||
}
|
||||
|
||||
lockWakeAllowed = false;
|
||||
resetPowerOffFade();
|
||||
if (IdleService.monitorsOff && powerOffOnLock) {
|
||||
if (IdleService.monitorsOff && SettingsData.lockScreenPowerOffMonitorsOnLock) {
|
||||
IdleService.monitorsOff = false;
|
||||
CompositorService.powerOnMonitors();
|
||||
}
|
||||
IdleService.lockPowerOffRequested = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,10 +254,6 @@ Scope {
|
||||
root.lock();
|
||||
}
|
||||
|
||||
function lockAndOutputsOff() {
|
||||
root.lockAndOutputsOff();
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
root.unlock();
|
||||
}
|
||||
@@ -375,26 +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: dpmsReapplyTimer
|
||||
interval: 100
|
||||
@@ -409,7 +296,7 @@ Scope {
|
||||
onTriggered: {
|
||||
if (!sessionLock.locked)
|
||||
return;
|
||||
if (!powerOffOnLock)
|
||||
if (!SettingsData.lockScreenPowerOffMonitorsOnLock)
|
||||
return;
|
||||
if (!IdleService.monitorsOff) {
|
||||
lockWakeAllowed = true;
|
||||
|
||||
@@ -1635,7 +1635,7 @@ Item {
|
||||
enabled: MprisController.activePlayer?.canGoNext ?? false
|
||||
hoverEnabled: enabled
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: MprisController.next()
|
||||
onClicked: MprisController.activePlayer?.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ Scope {
|
||||
}
|
||||
|
||||
function proceedAfterPrimaryAuth(): void {
|
||||
if (!root.u2fSuppressedByPrimaryPam && SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) {
|
||||
if (SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) {
|
||||
u2f.startForSecondFactor();
|
||||
} else {
|
||||
completeUnlock();
|
||||
@@ -90,18 +90,6 @@ Scope {
|
||||
fprint.checkAvail();
|
||||
}
|
||||
|
||||
readonly property bool customPamActive: SettingsData.lockPamPath !== "" && customPamWatcher.loaded
|
||||
readonly property bool fprintSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineFprint)
|
||||
readonly property bool u2fSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineU2f)
|
||||
readonly property bool customU2fPamActive: SettingsData.lockU2fPamPath !== "" && customU2fPamWatcher.loaded
|
||||
|
||||
FileView {
|
||||
id: customPamWatcher
|
||||
|
||||
path: SettingsData.lockPamPath !== "" ? SettingsData.lockPamPath : ""
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: dankshellConfigWatcher
|
||||
|
||||
@@ -110,17 +98,16 @@ Scope {
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: u2fConfigWatcher
|
||||
id: nixosMarker
|
||||
|
||||
path: "/etc/pam.d/dankshell-u2f"
|
||||
watchChanges: true
|
||||
path: "/etc/NIXOS"
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: customU2fPamWatcher
|
||||
id: u2fConfigWatcher
|
||||
|
||||
path: SettingsData.lockU2fPamPath !== "" ? SettingsData.lockU2fPamPath : ""
|
||||
path: "/etc/pam.d/dankshell-u2f"
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
@@ -147,36 +134,33 @@ Scope {
|
||||
}
|
||||
|
||||
function ensureUserPamConfig(): void {
|
||||
if (SettingsData.lockPamExternallyManaged || resolveUserPam.running)
|
||||
if (root.runningFromNixStore || resolveUserPam.running)
|
||||
return;
|
||||
resolveUserPam.running = true;
|
||||
}
|
||||
|
||||
Component.onCompleted: ensureUserPamConfig()
|
||||
|
||||
// Detects Nix-installed DMS on non-NixOS systems
|
||||
readonly property bool runningFromNixStore: Quickshell.shellDir.startsWith("/nix/store/")
|
||||
|
||||
PamContext {
|
||||
id: passwd
|
||||
|
||||
config: {
|
||||
if (root.customPamActive)
|
||||
return SettingsData.lockPamPath.slice(SettingsData.lockPamPath.lastIndexOf("/") + 1);
|
||||
if (SettingsData.lockPamExternallyManaged)
|
||||
return "login";
|
||||
if (dankshellConfigWatcher.loaded)
|
||||
return "dankshell";
|
||||
if (nixosMarker.loaded || root.runningFromNixStore)
|
||||
return "login";
|
||||
if (userPamWatcher.loaded)
|
||||
return "dankshell";
|
||||
return "login";
|
||||
}
|
||||
configDirectory: {
|
||||
if (root.customPamActive) {
|
||||
const idx = SettingsData.lockPamPath.lastIndexOf("/");
|
||||
return idx > 0 ? SettingsData.lockPamPath.slice(0, idx) : "/";
|
||||
}
|
||||
if (SettingsData.lockPamExternallyManaged)
|
||||
return "/etc/pam.d";
|
||||
if (dankshellConfigWatcher.loaded)
|
||||
return "/etc/pam.d";
|
||||
if (nixosMarker.loaded || root.runningFromNixStore)
|
||||
return "/etc/pam.d";
|
||||
if (userPamWatcher.loaded)
|
||||
return root.userPamDir;
|
||||
return Quickshell.shellDir + "/assets/pam";
|
||||
@@ -264,7 +248,7 @@ Scope {
|
||||
property int errorTries
|
||||
|
||||
function checkAvail(): void {
|
||||
if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByPrimaryPam) {
|
||||
if (!available || !SettingsData.enableFprint || !root.lockSecured) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
@@ -324,7 +308,7 @@ Scope {
|
||||
property bool available: SettingsData.lockU2fReady
|
||||
|
||||
function checkAvail(): void {
|
||||
if (!available || !SettingsData.enableU2f || !root.lockSecured || root.u2fSuppressedByPrimaryPam) {
|
||||
if (!available || !SettingsData.enableU2f || !root.lockSecured) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
@@ -334,7 +318,7 @@ Scope {
|
||||
}
|
||||
|
||||
function startForSecondFactor(): void {
|
||||
if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam) {
|
||||
if (!available || !SettingsData.enableU2f) {
|
||||
root.completeUnlock();
|
||||
return;
|
||||
}
|
||||
@@ -347,7 +331,7 @@ Scope {
|
||||
}
|
||||
|
||||
function startForAlternativeAuth(): void {
|
||||
if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active)
|
||||
if (!available || !SettingsData.enableU2f || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active)
|
||||
return;
|
||||
abort();
|
||||
root.u2fPending = true;
|
||||
@@ -357,18 +341,8 @@ Scope {
|
||||
start();
|
||||
}
|
||||
|
||||
config: {
|
||||
if (root.customU2fPamActive)
|
||||
return SettingsData.lockU2fPamPath.slice(SettingsData.lockU2fPamPath.lastIndexOf("/") + 1);
|
||||
return u2fConfigWatcher.loaded ? "dankshell-u2f" : "u2f";
|
||||
}
|
||||
configDirectory: {
|
||||
if (root.customU2fPamActive) {
|
||||
const idx = SettingsData.lockU2fPamPath.lastIndexOf("/");
|
||||
return idx > 0 ? SettingsData.lockU2fPamPath.slice(0, idx) : "/";
|
||||
}
|
||||
return u2fConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam";
|
||||
}
|
||||
config: u2fConfigWatcher.loaded ? "dankshell-u2f" : "u2f"
|
||||
configDirectory: u2fConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam"
|
||||
|
||||
onMessageChanged: {
|
||||
if (message.toLowerCase().includes("touch"))
|
||||
@@ -487,11 +461,8 @@ Scope {
|
||||
root.attemptInfoMessages = [];
|
||||
root.lockoutAnnouncedThisAttempt = false;
|
||||
root.resetAuthFlows();
|
||||
if (!SettingsData.lockPamExternallyManaged && !dankshellConfigWatcher.loaded && !userPamWatcher.loaded)
|
||||
if (!dankshellConfigWatcher.loaded && !nixosMarker.loaded && !userPamWatcher.loaded)
|
||||
ensureUserPamConfig();
|
||||
// FileView cannot watch a path that does not exist yet; re-read so a
|
||||
// dedicated service created after startup is used on the next lock.
|
||||
u2fConfigWatcher.reload();
|
||||
fprint.checkAvail();
|
||||
u2f.checkAvail();
|
||||
}
|
||||
@@ -515,32 +486,6 @@ Scope {
|
||||
u2f.checkAvail();
|
||||
}
|
||||
|
||||
function onLockPamPathChanged(): void {
|
||||
fprint.checkAvail();
|
||||
u2f.checkAvail();
|
||||
}
|
||||
|
||||
function onLockPamInlineFprintChanged(): void {
|
||||
fprint.checkAvail();
|
||||
}
|
||||
|
||||
function onLockPamInlineU2fChanged(): void {
|
||||
u2f.checkAvail();
|
||||
}
|
||||
|
||||
function onLockPamExternallyManagedChanged(): void {
|
||||
root.resetAuthFlows();
|
||||
if (!SettingsData.lockPamExternallyManaged)
|
||||
root.ensureUserPamConfig();
|
||||
fprint.checkAvail();
|
||||
u2f.checkAvail();
|
||||
}
|
||||
|
||||
function onLockU2fPamPathChanged(): void {
|
||||
u2f.abort();
|
||||
u2f.checkAvail();
|
||||
}
|
||||
|
||||
function onU2fModeChanged(): void {
|
||||
if (root.lockSecured) {
|
||||
u2f.abort();
|
||||
|
||||
@@ -49,7 +49,7 @@ Rectangle {
|
||||
color: {
|
||||
if (isSelected && keyboardNavigationActive)
|
||||
return Theme.primaryPressed;
|
||||
return Theme.notificationFloatingSurfaceHigh;
|
||||
return Theme.floatingSurfaceHigh;
|
||||
}
|
||||
border.color: {
|
||||
if (isSelected && keyboardNavigationActive)
|
||||
|
||||
@@ -153,7 +153,7 @@ Rectangle {
|
||||
if (keyboardNavigationActive && expanded && selectedNotificationIndex >= 0) {
|
||||
return Theme.primaryHoverLight;
|
||||
}
|
||||
return Theme.notificationFloatingSurfaceHigh;
|
||||
return Theme.floatingSurfaceHigh;
|
||||
}
|
||||
border.color: {
|
||||
if (isGroupSelected && keyboardNavigationActive) {
|
||||
@@ -583,7 +583,7 @@ Rectangle {
|
||||
return expandedBaseHeight;
|
||||
}
|
||||
radius: Theme.cornerRadius
|
||||
color: isSelected ? Theme.primaryPressed : Theme.notificationNestedSurface
|
||||
color: isSelected ? Theme.primaryPressed : Theme.nestedSurface
|
||||
border.color: isSelected ? Theme.withAlpha(Theme.primary, 0.4) : Theme.outlineMedium
|
||||
border.width: 1
|
||||
|
||||
|
||||
@@ -676,7 +676,7 @@ PanelWindow {
|
||||
sourceWidth: Math.max(0, content.width - (content.cardInset * 2))
|
||||
sourceHeight: Math.max(0, content.height - (content.cardInset * 2))
|
||||
targetRadius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius
|
||||
targetColor: win.connectedFrameMode ? Theme.notificationFloatingSurface : Theme.readableSurface
|
||||
targetColor: win.connectedFrameMode ? Theme.floatingSurface : Theme.readableSurface
|
||||
borderColor: win.notificationData && win.notificationData.urgency === NotificationUrgency.Critical ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.outline, 0.08)
|
||||
borderWidth: win.notificationData && win.notificationData.urgency === NotificationUrgency.Critical ? 2 : 0
|
||||
}
|
||||
|
||||
@@ -359,17 +359,6 @@ Item {
|
||||
anchors.fill: parent
|
||||
active: root.widgetEnabled && root.activeComponent !== null
|
||||
sourceComponent: root.activeComponent
|
||||
opacity: 0
|
||||
|
||||
NumberAnimation {
|
||||
id: revealFade
|
||||
target: contentLoader
|
||||
property: "opacity"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: Theme.mediumDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
|
||||
function reloadComponent() {
|
||||
active = false;
|
||||
@@ -395,8 +384,6 @@ Item {
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
revealFade.restart();
|
||||
|
||||
if (item.pluginService !== undefined) {
|
||||
item.pluginService = root.isInstance ? instanceScopedPluginService : root.pluginService;
|
||||
}
|
||||
|
||||
@@ -18,40 +18,57 @@ Item {
|
||||
readonly property bool greeterU2fToggleAvailable: SettingsData.greeterU2fCanEnable || SettingsData.greeterEnableU2f
|
||||
|
||||
function greeterFingerprintDescription() {
|
||||
if (SettingsData.greeterPamExternallyManaged)
|
||||
return I18n.tr("Managed by the primary PAM source.", "factor managed by PAM source status");
|
||||
if (SettingsData.greeterFingerprintSource === "pam")
|
||||
return I18n.tr("PAM already provides fingerprint auth. Enable this to show it at login.", "greeter fingerprint login setting");
|
||||
const source = SettingsData.greeterFingerprintSource;
|
||||
const reason = SettingsData.greeterFingerprintReason;
|
||||
|
||||
switch (SettingsData.greeterFingerprintReason) {
|
||||
if (source === "pam") {
|
||||
switch (reason) {
|
||||
case "configured_externally":
|
||||
return SettingsData.greeterEnableFprint ? I18n.tr("Enabled. PAM already provides fingerprint auth.") : I18n.tr("PAM already provides fingerprint auth. Enable this to show it at login.");
|
||||
case "missing_enrollment":
|
||||
return SettingsData.greeterEnableFprint ? I18n.tr("Enabled. PAM provides fingerprint auth, but no prints are enrolled yet.") : I18n.tr("PAM provides fingerprint auth, but no prints are enrolled yet.");
|
||||
case "missing_reader":
|
||||
return I18n.tr("PAM provides fingerprint auth, but no reader was detected.");
|
||||
default:
|
||||
return I18n.tr("PAM provides fingerprint auth, but availability could not be confirmed.");
|
||||
}
|
||||
}
|
||||
|
||||
switch (reason) {
|
||||
case "ready":
|
||||
return I18n.tr("Authentication changes apply automatically.", "greeter auth setting description");
|
||||
return SettingsData.greeterEnableFprint ? I18n.tr("Authentication changes apply automatically. Fingerprint-only login may not unlock Keyring.") : I18n.tr("Only affects DMS-managed PAM. If greetd already includes pam_fprintd, fingerprint stays enabled.");
|
||||
case "missing_enrollment":
|
||||
return I18n.tr("Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and run Sync later.", "greeter fingerprint login setting");
|
||||
if (SettingsData.greeterEnableFprint)
|
||||
return I18n.tr("Enabled, but no prints are enrolled yet. Enroll fingerprints and run Sync.");
|
||||
return I18n.tr("Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and run Sync later.");
|
||||
case "missing_reader":
|
||||
return I18n.tr("No fingerprint reader detected.", "fingerprint setting status");
|
||||
return SettingsData.greeterEnableFprint ? I18n.tr("Enabled, but no fingerprint reader was detected.") : I18n.tr("No fingerprint reader detected.");
|
||||
case "missing_pam_support":
|
||||
return I18n.tr("Not available — install fprintd and pam_fprintd, or configure greetd PAM.", "greeter fingerprint login setting");
|
||||
return I18n.tr("Not available — install fprintd and pam_fprintd, or configure greetd PAM.");
|
||||
default:
|
||||
return I18n.tr("Fingerprint availability could not be confirmed.", "fingerprint setting status");
|
||||
return SettingsData.greeterEnableFprint ? I18n.tr("Enabled, but fingerprint availability could not be confirmed.") : I18n.tr("Fingerprint availability could not be confirmed.");
|
||||
}
|
||||
}
|
||||
|
||||
function greeterU2fDescription() {
|
||||
if (SettingsData.greeterPamExternallyManaged)
|
||||
return I18n.tr("Managed by the primary PAM source.", "factor managed by PAM source status");
|
||||
if (SettingsData.greeterU2fSource === "pam")
|
||||
return I18n.tr("PAM already provides security-key auth. Enable this to show it at login.", "greeter security key login setting");
|
||||
const source = SettingsData.greeterU2fSource;
|
||||
const reason = SettingsData.greeterU2fReason;
|
||||
|
||||
switch (SettingsData.greeterU2fReason) {
|
||||
if (source === "pam") {
|
||||
return SettingsData.greeterEnableU2f ? I18n.tr("Enabled. PAM already provides security-key auth.") : I18n.tr("PAM already provides security-key auth. Enable this to show it at login.");
|
||||
}
|
||||
|
||||
switch (reason) {
|
||||
case "ready":
|
||||
return I18n.tr("Authentication changes apply automatically.", "greeter auth setting description");
|
||||
return SettingsData.greeterEnableU2f ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Available.");
|
||||
case "missing_key_registration":
|
||||
return I18n.tr("Security-key support was detected, but no registered key was found yet. You can enable this now and register one later.", "security key setting status");
|
||||
if (SettingsData.greeterEnableU2f)
|
||||
return I18n.tr("Enabled, but no registered security key was found yet. Register a key and run Sync.");
|
||||
return I18n.tr("Security-key support was detected, but no registered key was found yet. You can enable this now and register one later.");
|
||||
case "missing_pam_support":
|
||||
return I18n.tr("Not available — install or configure pam_u2f, or configure greetd PAM.", "greeter security key login setting");
|
||||
return I18n.tr("Not available — install or configure pam_u2f, or configure greetd PAM.");
|
||||
default:
|
||||
return I18n.tr("Security-key availability could not be confirmed.", "security key setting status");
|
||||
return SettingsData.greeterEnableU2f ? I18n.tr("Enabled, but security-key availability could not be confirmed.") : I18n.tr("Security-key availability could not be confirmed.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +322,7 @@ Item {
|
||||
onExited: exitCode => {
|
||||
root.greeterSyncRunning = false;
|
||||
if (exitCode === 0) {
|
||||
var launched = root.greeterTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete authentication there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete authentication there; it will close automatically when done.");
|
||||
var launched = root.greeterTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete sync authentication there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete sync there; it will close automatically when done.");
|
||||
root.greeterStatusText = root.greeterStatusText ? root.greeterStatusText + "\n\n" + launched : launched;
|
||||
SettingsData.clearGreeterSyncPending();
|
||||
return;
|
||||
@@ -396,7 +413,7 @@ Item {
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "info"
|
||||
title: I18n.tr("Status")
|
||||
title: I18n.tr("Greeter Status")
|
||||
settingKey: "greeterStatus"
|
||||
|
||||
StyledText {
|
||||
@@ -470,7 +487,7 @@ Item {
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "fingerprint"
|
||||
title: I18n.tr("Authentication")
|
||||
title: I18n.tr("Login Authentication")
|
||||
settingKey: "greeterAuth"
|
||||
|
||||
StyledText {
|
||||
@@ -482,15 +499,6 @@ Item {
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "greeterPamExternallyManaged"
|
||||
tags: ["greeter", "pam", "managed", "external", "greetd", "auth"]
|
||||
text: I18n.tr("Use system PAM authentication", "system PAM policy toggle")
|
||||
description: I18n.tr("DMS removes its managed block from /etc/pam.d/greetd and stops writing to it", "greeter system PAM toggle description")
|
||||
checked: SettingsData.greeterPamExternallyManaged
|
||||
onToggled: checked => SettingsData.set("greeterPamExternallyManaged", checked)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "greeterEnableFprint"
|
||||
tags: ["greeter", "fingerprint", "fprintd", "login", "auth"]
|
||||
@@ -498,7 +506,7 @@ Item {
|
||||
description: root.greeterFingerprintDescription()
|
||||
descriptionColor: (SettingsData.greeterFingerprintReason === "ready" || SettingsData.greeterFingerprintReason === "configured_externally") ? Theme.surfaceVariantText : Theme.warning
|
||||
checked: SettingsData.greeterEnableFprint
|
||||
enabled: root.greeterFprintToggleAvailable && !SettingsData.greeterPamExternallyManaged
|
||||
enabled: root.greeterFprintToggleAvailable
|
||||
onToggled: checked => SettingsData.set("greeterEnableFprint", checked)
|
||||
}
|
||||
|
||||
@@ -509,7 +517,7 @@ Item {
|
||||
description: root.greeterU2fDescription()
|
||||
descriptionColor: (SettingsData.greeterU2fReason === "ready" || SettingsData.greeterU2fReason === "configured_externally") ? Theme.surfaceVariantText : Theme.warning
|
||||
checked: SettingsData.greeterEnableU2f
|
||||
enabled: root.greeterU2fToggleAvailable && !SettingsData.greeterPamExternallyManaged
|
||||
enabled: root.greeterU2fToggleAvailable
|
||||
onToggled: checked => SettingsData.set("greeterEnableU2f", checked)
|
||||
}
|
||||
}
|
||||
@@ -517,7 +525,7 @@ Item {
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "palette"
|
||||
title: I18n.tr("Appearance")
|
||||
title: I18n.tr("Greeter Appearance")
|
||||
settingKey: "greeterAppearance"
|
||||
|
||||
StyledText {
|
||||
@@ -558,7 +566,7 @@ Item {
|
||||
currentValue: {
|
||||
var current = (SettingsData.greeterLockDateFormat !== undefined && SettingsData.greeterLockDateFormat !== "") ? SettingsData.greeterLockDateFormat : SettingsData.lockDateFormat || "";
|
||||
var match = root._lockDateFormatPresets.find(p => p.format === current);
|
||||
return match ? match.label : (current ? I18n.tr("Custom") + ": " + current : root._lockDateFormatPresets[0].label);
|
||||
return match ? match.label : (current ? I18n.tr("Custom: ") + current : root._lockDateFormatPresets[0].label);
|
||||
}
|
||||
onValueChanged: value => {
|
||||
var preset = root._lockDateFormatPresets.find(p => p.label === value);
|
||||
@@ -601,7 +609,7 @@ Item {
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "history"
|
||||
title: I18n.tr("Behavior")
|
||||
title: I18n.tr("Greeter Behavior")
|
||||
settingKey: "greeterBehavior"
|
||||
|
||||
StyledText {
|
||||
@@ -710,13 +718,9 @@ Item {
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.AllButtons
|
||||
cursorShape: !root.greeterSyncRunning && !root.greeterInstallActionRunning ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.LeftButton && !root.greeterSyncRunning && !root.greeterInstallActionRunning)
|
||||
root.runGreeterSync();
|
||||
}
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: !root.greeterSyncRunning && !root.greeterInstallActionRunning
|
||||
onClicked: root.runGreeterSync()
|
||||
}
|
||||
|
||||
Row {
|
||||
|
||||
@@ -747,31 +747,6 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "layers"
|
||||
title: I18n.tr("Modal Background")
|
||||
settingKey: "modalBackground"
|
||||
tags: ["modal", "darken", "background", "overlay", "launcher"]
|
||||
|
||||
SettingsControlledByFrame {
|
||||
visible: SettingsData.frameEnabled
|
||||
parentModal: root.parentModal
|
||||
settingLabel: I18n.tr("Darken Modal Background")
|
||||
reason: I18n.tr("Disabled by Frame Mode")
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "modalDarkenBackground"
|
||||
tags: ["modal", "darken", "background", "overlay", "launcher"]
|
||||
text: I18n.tr("Darken Modal Background")
|
||||
description: I18n.tr("Show darkened overlay behind modal dialogs")
|
||||
visible: !SettingsData.frameEnabled
|
||||
checked: SettingsData.modalDarkenBackground
|
||||
onToggled: checked => SettingsData.set("modalDarkenBackground", checked)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "open_in_new"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Modals.FileBrowser
|
||||
import qs.Services
|
||||
@@ -11,113 +11,36 @@ Item {
|
||||
|
||||
readonly property bool lockFprintToggleAvailable: SettingsData.lockFingerprintCanEnable || SettingsData.enableFprint
|
||||
readonly property bool lockU2fToggleAvailable: SettingsData.lockU2fCanEnable || SettingsData.enableU2f
|
||||
readonly property bool primaryPamHasFprint: SettingsData.lockPamPath !== "" && SettingsData.lockPamInlineFprint
|
||||
readonly property bool primaryPamHasU2f: SettingsData.lockPamPath !== "" && SettingsData.lockPamInlineU2f
|
||||
readonly property bool lockFprintControlledByPrimary: SettingsData.lockPamExternallyManaged || primaryPamHasFprint
|
||||
readonly property bool lockU2fControlledByPrimary: SettingsData.lockPamExternallyManaged || primaryPamHasU2f
|
||||
|
||||
property var authServices: []
|
||||
property bool authValidateRunning: false
|
||||
property bool authValidateOk: false
|
||||
property bool authValidateWarn: false
|
||||
property string authValidateMessage: ""
|
||||
property string authPendingApplyPath: ""
|
||||
property bool authShowCustom: false
|
||||
property bool u2fValidateRunning: false
|
||||
property bool u2fValidateOk: false
|
||||
property bool u2fValidateWarn: false
|
||||
property string u2fValidateMessage: ""
|
||||
property string u2fPendingApplyPath: ""
|
||||
property bool u2fShowCustom: false
|
||||
|
||||
readonly property string authAutoLabel: I18n.tr("Auto", "automatic PAM authentication source option")
|
||||
readonly property string authCustomLabel: I18n.tr("Custom...", "custom PAM authentication source option")
|
||||
|
||||
function authServiceLabel(service) {
|
||||
const label = service.name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join("-");
|
||||
return service.dir === "/etc/pam.d" ? label : label + " (" + service.dir + ")";
|
||||
}
|
||||
|
||||
readonly property var authOptions: [authAutoLabel, ...authServices.map(s => authServiceLabel(s)), authCustomLabel]
|
||||
|
||||
readonly property string authCurrentValue: {
|
||||
if (SettingsData.lockPamPath === "")
|
||||
return authAutoLabel;
|
||||
const svc = authServices.find(s => s.path === SettingsData.lockPamPath);
|
||||
return svc ? authServiceLabel(svc) : authCustomLabel;
|
||||
}
|
||||
|
||||
readonly property string u2fAuthCurrentValue: SettingsData.lockU2fPamPath === "" ? authAutoLabel : authCustomLabel
|
||||
|
||||
function refreshAuthServices() {
|
||||
authListServicesProcess.running = true;
|
||||
}
|
||||
|
||||
function applyAutoAuthSource() {
|
||||
SettingsData.set("lockPamPath", "");
|
||||
SettingsData.set("lockPamInlineFprint", false);
|
||||
SettingsData.set("lockPamInlineU2f", false);
|
||||
root.authValidateOk = false;
|
||||
root.authValidateWarn = false;
|
||||
root.authValidateMessage = "";
|
||||
}
|
||||
|
||||
function validateAndApplyAuthSource(path) {
|
||||
if (!path)
|
||||
return;
|
||||
root.authPendingApplyPath = path;
|
||||
root.authValidateMessage = "";
|
||||
root.authValidateOk = false;
|
||||
root.authValidateWarn = false;
|
||||
root.authValidateRunning = true;
|
||||
authValidateProcess.command = ["dms", "auth", "validate", "--path", path, "--json"];
|
||||
authValidateProcess.running = true;
|
||||
}
|
||||
|
||||
function applyAutoU2fSource() {
|
||||
SettingsData.set("lockU2fPamPath", "");
|
||||
root.u2fValidateOk = false;
|
||||
root.u2fValidateWarn = false;
|
||||
root.u2fValidateMessage = "";
|
||||
}
|
||||
|
||||
function validateAndApplyU2fSource(path) {
|
||||
if (!path)
|
||||
return;
|
||||
root.u2fPendingApplyPath = path;
|
||||
root.u2fValidateMessage = "";
|
||||
root.u2fValidateOk = false;
|
||||
root.u2fValidateWarn = false;
|
||||
root.u2fValidateRunning = true;
|
||||
u2fValidateProcess.command = ["dms", "auth", "validate", "--purpose", "u2f", "--path", path, "--json"];
|
||||
u2fValidateProcess.running = true;
|
||||
}
|
||||
|
||||
function lockFingerprintDescription() {
|
||||
switch (SettingsData.lockFingerprintReason) {
|
||||
case "ready":
|
||||
return I18n.tr("Use fingerprint authentication for the lock screen.", "lock screen fingerprint setting");
|
||||
return SettingsData.enableFprint ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Use fingerprint authentication for the lock screen.");
|
||||
case "missing_enrollment":
|
||||
return I18n.tr("Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and enroll later.", "lock screen fingerprint setting");
|
||||
if (SettingsData.enableFprint)
|
||||
return I18n.tr("Enabled, but no prints are enrolled yet. Authentication changes apply automatically once you enroll fingerprints.");
|
||||
return I18n.tr("Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and enroll later.");
|
||||
case "missing_reader":
|
||||
return I18n.tr("No fingerprint reader detected.", "fingerprint setting status");
|
||||
return SettingsData.enableFprint ? I18n.tr("Enabled, but no fingerprint reader was detected.") : I18n.tr("No fingerprint reader detected.");
|
||||
case "missing_pam_support":
|
||||
return I18n.tr("Not available — install fprintd and pam_fprintd.", "lock screen fingerprint setting");
|
||||
return I18n.tr("Not available — install fprintd and pam_fprintd.");
|
||||
default:
|
||||
return I18n.tr("Fingerprint availability could not be confirmed.", "fingerprint setting status");
|
||||
return SettingsData.enableFprint ? I18n.tr("Enabled, but fingerprint availability could not be confirmed.") : I18n.tr("Fingerprint availability could not be confirmed.");
|
||||
}
|
||||
}
|
||||
|
||||
function lockU2fDescription() {
|
||||
switch (SettingsData.lockU2fReason) {
|
||||
case "ready":
|
||||
return I18n.tr("Use a security key for lock screen authentication.", "lock screen U2F security key setting");
|
||||
return SettingsData.enableU2f ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Use a security key for lock screen authentication.", "lock screen U2F security key setting");
|
||||
case "missing_key_registration":
|
||||
return I18n.tr("Security-key support was detected, but no registered key was found yet. You can enable this now and register one later.", "security key setting status");
|
||||
if (SettingsData.enableU2f)
|
||||
return I18n.tr("Enabled, but no registered security key was found yet. Authentication changes apply automatically once your key is registered or your U2F config is updated.");
|
||||
return I18n.tr("Security-key support was detected, but no registered key was found yet. You can enable this now and register one later.");
|
||||
case "missing_pam_support":
|
||||
return I18n.tr("Not available — install or configure pam_u2f.", "lock screen security key setting");
|
||||
return I18n.tr("Not available — install or configure pam_u2f.");
|
||||
default:
|
||||
return I18n.tr("Security-key availability could not be confirmed.", "security key setting status");
|
||||
return SettingsData.enableU2f ? I18n.tr("Enabled, but security-key availability could not be confirmed.") : I18n.tr("Security-key availability could not be confirmed.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,15 +48,10 @@ Item {
|
||||
SettingsData.refreshAuthAvailability();
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
refreshAuthDetection();
|
||||
refreshAuthServices();
|
||||
}
|
||||
Component.onCompleted: refreshAuthDetection()
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
if (visible)
|
||||
refreshAuthDetection();
|
||||
refreshAuthServices();
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserModal {
|
||||
@@ -146,110 +64,6 @@ Item {
|
||||
onFileSelected: path => SettingsData.set("lockScreenVideoPath", path)
|
||||
}
|
||||
|
||||
Process {
|
||||
id: authListServicesProcess
|
||||
command: ["dms", "auth", "list-services", "--json"]
|
||||
running: false
|
||||
|
||||
property string collected: ""
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: authListServicesProcess.collected = text || ""
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
if (exitCode !== 0) {
|
||||
root.authServices = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(authListServicesProcess.collected);
|
||||
root.authServices = (data && Array.isArray(data.services)) ? data.services.filter(s => s.hasAuth) : [];
|
||||
} catch (e) {
|
||||
root.authServices = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: authValidateProcess
|
||||
running: false
|
||||
|
||||
property string collected: ""
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: authValidateProcess.collected = text || ""
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
root.authValidateRunning = false;
|
||||
root.authValidateOk = false;
|
||||
root.authValidateWarn = false;
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(authValidateProcess.collected);
|
||||
} catch (e) {}
|
||||
|
||||
if (!data) {
|
||||
root.authValidateMessage = I18n.tr("Config validation failed");
|
||||
return;
|
||||
}
|
||||
if (!data.valid) {
|
||||
const errs = Array.isArray(data.errors) ? data.errors : [];
|
||||
root.authValidateMessage = [I18n.tr("Config validation failed"), ...errs].join("\n");
|
||||
return;
|
||||
}
|
||||
|
||||
SettingsData.set("lockPamPath", root.authPendingApplyPath);
|
||||
SettingsData.set("lockPamInlineFprint", data.inlineFingerprint === true);
|
||||
SettingsData.set("lockPamInlineU2f", data.inlineU2f === true);
|
||||
const warns = Array.isArray(data.warnings) ? data.warnings : [];
|
||||
root.authValidateOk = true;
|
||||
root.authValidateWarn = warns.length > 0;
|
||||
root.authValidateMessage = [I18n.tr("Authentication changes applied."), ...warns].join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: u2fValidateProcess
|
||||
running: false
|
||||
|
||||
property string collected: ""
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: u2fValidateProcess.collected = text || ""
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
root.u2fValidateRunning = false;
|
||||
root.u2fValidateOk = false;
|
||||
root.u2fValidateWarn = false;
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(u2fValidateProcess.collected);
|
||||
} catch (e) {}
|
||||
|
||||
if (!data) {
|
||||
root.u2fValidateMessage = I18n.tr("Config validation failed");
|
||||
return;
|
||||
}
|
||||
if (!data.valid) {
|
||||
const errs = Array.isArray(data.errors) ? data.errors : [];
|
||||
root.u2fValidateMessage = [I18n.tr("Config validation failed"), ...errs].join("\n");
|
||||
return;
|
||||
}
|
||||
|
||||
SettingsData.set("lockU2fPamPath", root.u2fPendingApplyPath);
|
||||
const warns = Array.isArray(data.warnings) ? data.warnings : [];
|
||||
root.u2fValidateOk = true;
|
||||
root.u2fValidateWarn = warns.length > 0;
|
||||
root.u2fValidateMessage = [I18n.tr("Authentication changes applied."), ...warns].join("\n");
|
||||
root.refreshAuthDetection();
|
||||
}
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
@@ -266,7 +80,7 @@ Item {
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "lock"
|
||||
title: I18n.tr("Layout")
|
||||
title: I18n.tr("Lock Screen layout")
|
||||
settingKey: "lockLayout"
|
||||
|
||||
SettingsToggleRow {
|
||||
@@ -345,7 +159,7 @@ Item {
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "palette"
|
||||
title: I18n.tr("Appearance")
|
||||
title: I18n.tr("Lock Screen Appearance")
|
||||
settingKey: "lockAppearance"
|
||||
|
||||
StyledText {
|
||||
@@ -393,195 +207,10 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "key"
|
||||
title: I18n.tr("Authentication")
|
||||
settingKey: "lockAuthSource"
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Authentication changes apply automatically.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
settingKey: "lockPamPath"
|
||||
tags: ["lock", "screen", "pam", "authentication", "source", "service"]
|
||||
text: I18n.tr("Authentication Source", "lock screen PAM source setting")
|
||||
description: SettingsData.lockPamPath !== "" ? SettingsData.lockPamPath : I18n.tr("Which PAM service the lock screen uses to authenticate", "lock screen PAM source setting")
|
||||
options: root.authOptions
|
||||
currentValue: root.authCurrentValue
|
||||
onValueChanged: value => {
|
||||
if (value === root.authAutoLabel) {
|
||||
root.authShowCustom = false;
|
||||
root.applyAutoAuthSource();
|
||||
return;
|
||||
}
|
||||
if (value === root.authCustomLabel) {
|
||||
root.authShowCustom = true;
|
||||
return;
|
||||
}
|
||||
root.authShowCustom = false;
|
||||
const svc = root.authServices.find(s => root.authServiceLabel(s) === value);
|
||||
if (svc)
|
||||
root.validateAndApplyAuthSource(svc.path);
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
visible: root.authShowCustom || root.authCurrentValue === root.authCustomLabel
|
||||
|
||||
DankTextField {
|
||||
id: customPamField
|
||||
width: parent.width - validatePamButton.width - Theme.spacingS
|
||||
placeholderText: "/etc/pam.d/my-service"
|
||||
text: SettingsData.lockPamPath
|
||||
backgroundColor: Theme.surfaceContainerHighest
|
||||
}
|
||||
|
||||
DankButton {
|
||||
id: validatePamButton
|
||||
text: I18n.tr("Apply Changes", "validate and apply custom PAM authentication source")
|
||||
enabled: !root.authValidateRunning && customPamField.text.trim() !== ""
|
||||
onClicked: root.validateAndApplyAuthSource(customPamField.text.trim())
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: Math.min(160, authStatusText.implicitHeight + Theme.spacingM * 2)
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHighest
|
||||
visible: root.authValidateMessage !== ""
|
||||
|
||||
StyledText {
|
||||
id: authStatusText
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
text: root.authValidateMessage
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.family: "monospace"
|
||||
color: !root.authValidateOk ? Theme.error : (root.authValidateWarn ? Theme.warning : Theme.surfaceVariantText)
|
||||
wrapMode: Text.Wrap
|
||||
verticalAlignment: Text.AlignTop
|
||||
}
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "lockPamExternallyManaged"
|
||||
tags: ["lock", "screen", "pam", "managed", "external", "authentication", "policy"]
|
||||
text: I18n.tr("Use system PAM authentication", "system PAM policy toggle")
|
||||
checked: SettingsData.lockPamExternallyManaged
|
||||
onToggled: checked => SettingsData.set("lockPamExternallyManaged", checked)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "enableFprint"
|
||||
tags: ["lock", "screen", "fingerprint", "authentication", "biometric", "fprint"]
|
||||
text: I18n.tr("Enable fingerprint authentication")
|
||||
description: root.lockFprintControlledByPrimary ? I18n.tr("Managed by the primary PAM source.", "factor managed by PAM source status") : root.lockFingerprintDescription()
|
||||
descriptionColor: root.lockFprintControlledByPrimary || SettingsData.lockFingerprintReason === "ready" ? Theme.surfaceVariantText : Theme.warning
|
||||
checked: SettingsData.enableFprint || root.primaryPamHasFprint
|
||||
enabled: root.lockFprintToggleAvailable && !root.lockFprintControlledByPrimary
|
||||
onToggled: checked => SettingsData.set("enableFprint", checked)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "enableU2f"
|
||||
tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "fido", "authentication", "hardware"]
|
||||
text: I18n.tr("Enable security key authentication", "Enable FIDO2/U2F hardware security key for lock screen")
|
||||
description: root.lockU2fControlledByPrimary ? I18n.tr("Managed by the primary PAM source.", "factor managed by PAM source status") : root.lockU2fDescription()
|
||||
descriptionColor: root.lockU2fControlledByPrimary || SettingsData.lockU2fReason === "ready" ? Theme.surfaceVariantText : Theme.warning
|
||||
checked: SettingsData.enableU2f || root.primaryPamHasU2f
|
||||
enabled: root.lockU2fToggleAvailable && !root.lockU2fControlledByPrimary
|
||||
onToggled: checked => SettingsData.set("enableU2f", checked)
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
settingKey: "u2fMode"
|
||||
tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "mode", "factor", "second"]
|
||||
text: I18n.tr("Security key mode", "lock screen U2F security key mode setting")
|
||||
description: I18n.tr("'Alternative' lets the key unlock on its own. 'Second factor' requires password or fingerprint first, then the key.", "lock screen U2F security key mode setting")
|
||||
visible: SettingsData.enableU2f && !root.lockU2fControlledByPrimary
|
||||
options: [I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method"), I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint")]
|
||||
currentValue: SettingsData.u2fMode === "and" ? I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint") : I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method")
|
||||
onValueChanged: value => {
|
||||
if (value === I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint"))
|
||||
SettingsData.set("u2fMode", "and");
|
||||
else
|
||||
SettingsData.set("u2fMode", "or");
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
settingKey: "lockU2fPamPath"
|
||||
tags: ["lock", "screen", "pam", "u2f", "security", "key", "source", "service"]
|
||||
text: I18n.tr("Security Key PAM Source", "lock screen dedicated U2F PAM source setting")
|
||||
description: SettingsData.lockU2fPamPath !== "" ? SettingsData.lockU2fPamPath : I18n.tr("Auto uses an installed or bundled key-only service.", "lock screen dedicated U2F PAM source setting")
|
||||
visible: !root.lockU2fControlledByPrimary
|
||||
options: [root.authAutoLabel, root.authCustomLabel]
|
||||
currentValue: root.u2fAuthCurrentValue
|
||||
onValueChanged: value => {
|
||||
if (value === root.authAutoLabel) {
|
||||
root.u2fShowCustom = false;
|
||||
root.applyAutoU2fSource();
|
||||
return;
|
||||
}
|
||||
root.u2fShowCustom = true;
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
visible: !root.lockU2fControlledByPrimary && (root.u2fShowCustom || root.u2fAuthCurrentValue === root.authCustomLabel)
|
||||
|
||||
DankTextField {
|
||||
id: customU2fPamField
|
||||
width: parent.width - validateU2fPamButton.width - Theme.spacingS
|
||||
placeholderText: "/etc/pam.d/dankshell-u2f"
|
||||
text: SettingsData.lockU2fPamPath
|
||||
backgroundColor: Theme.surfaceContainerHighest
|
||||
}
|
||||
|
||||
DankButton {
|
||||
id: validateU2fPamButton
|
||||
text: I18n.tr("Apply Changes", "validate and apply custom U2F PAM authentication source")
|
||||
enabled: !root.u2fValidateRunning && customU2fPamField.text.trim() !== ""
|
||||
onClicked: root.validateAndApplyU2fSource(customU2fPamField.text.trim())
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: Math.min(160, u2fStatusText.implicitHeight + Theme.spacingM * 2)
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHighest
|
||||
visible: !root.lockU2fControlledByPrimary && root.u2fValidateMessage !== ""
|
||||
|
||||
StyledText {
|
||||
id: u2fStatusText
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
text: root.u2fValidateMessage
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.family: "monospace"
|
||||
color: !root.u2fValidateOk ? Theme.error : (root.u2fValidateWarn ? Theme.warning : Theme.surfaceVariantText)
|
||||
wrapMode: Text.Wrap
|
||||
verticalAlignment: Text.AlignTop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "lock"
|
||||
title: I18n.tr("Behavior")
|
||||
title: I18n.tr("Lock Screen behaviour")
|
||||
settingKey: "lockBehavior"
|
||||
|
||||
StyledText {
|
||||
@@ -634,6 +263,53 @@ Item {
|
||||
checked: SettingsData.lockAtStartup
|
||||
onToggled: checked => SettingsData.set("lockAtStartup", checked)
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Lock screen authentication changes apply automatically and may open a terminal when sudo authentication is required.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
wrapMode: Text.Wrap
|
||||
topPadding: Theme.spacingS
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "enableFprint"
|
||||
tags: ["lock", "screen", "fingerprint", "authentication", "biometric", "fprint"]
|
||||
text: I18n.tr("Enable fingerprint authentication")
|
||||
description: root.lockFingerprintDescription()
|
||||
descriptionColor: SettingsData.lockFingerprintReason === "ready" ? Theme.surfaceVariantText : Theme.warning
|
||||
checked: SettingsData.enableFprint
|
||||
enabled: root.lockFprintToggleAvailable
|
||||
onToggled: checked => SettingsData.set("enableFprint", checked)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "enableU2f"
|
||||
tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "fido", "authentication", "hardware"]
|
||||
text: I18n.tr("Enable security key authentication", "Enable FIDO2/U2F hardware security key for lock screen")
|
||||
description: root.lockU2fDescription()
|
||||
descriptionColor: SettingsData.lockU2fReason === "ready" ? Theme.surfaceVariantText : Theme.warning
|
||||
checked: SettingsData.enableU2f
|
||||
enabled: root.lockU2fToggleAvailable
|
||||
onToggled: checked => SettingsData.set("enableU2f", checked)
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
settingKey: "u2fMode"
|
||||
tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "mode", "factor", "second"]
|
||||
text: I18n.tr("Security key mode", "lock screen U2F security key mode setting")
|
||||
description: I18n.tr("'Alternative' lets the key unlock on its own. 'Second factor' requires password or fingerprint first, then the key.", "lock screen U2F security key mode setting")
|
||||
visible: SettingsData.enableU2f
|
||||
options: [I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method"), I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint")]
|
||||
currentValue: SettingsData.u2fMode === "and" ? I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint") : I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method")
|
||||
onValueChanged: value => {
|
||||
if (value === I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint"))
|
||||
SettingsData.set("u2fMode", "and");
|
||||
else
|
||||
SettingsData.set("u2fMode", "or");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
@@ -720,7 +396,7 @@ Item {
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "monitor"
|
||||
title: I18n.tr("Display Assignment")
|
||||
title: I18n.tr("Lock Screen Display")
|
||||
settingKey: "lockDisplay"
|
||||
|
||||
StyledText {
|
||||
|
||||
@@ -300,15 +300,6 @@ Item {
|
||||
onToggled: checked => SettingsData.set("notificationCompactMode", checked)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "notificationForegroundLayers"
|
||||
tags: ["notification", "foreground", "layers", "surface", "blur", "glass", "contrast", "cards"]
|
||||
text: I18n.tr("Foreground Layers")
|
||||
description: I18n.tr("Show foreground surfaces on notification cards")
|
||||
checked: SettingsData.notificationForegroundLayers ?? true
|
||||
onToggled: checked => SettingsData.set("notificationForegroundLayers", checked)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "notificationShowTimeoutBar"
|
||||
tags: ["notification", "timeout", "progress", "bar", "timer", "countdown"]
|
||||
|
||||
@@ -1734,6 +1734,16 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsToggleRow {
|
||||
tab: "theme"
|
||||
tags: ["foreground", "layers", "contrast", "surface", "blur", "glass", "frosted"]
|
||||
settingKey: "blurForegroundLayers"
|
||||
text: I18n.tr("Foreground Layers")
|
||||
description: I18n.tr("Show foreground surfaces on panels for stronger contrast")
|
||||
checked: SettingsData.blurForegroundLayers ?? true
|
||||
onToggled: checked => SettingsData.set("blurForegroundLayers", checked)
|
||||
}
|
||||
|
||||
SettingsSliderRow {
|
||||
tab: "theme"
|
||||
tags: ["surface", "popup", "transparency", "opacity", "modal"]
|
||||
@@ -1749,47 +1759,12 @@ Item {
|
||||
onSliderValueChanged: newValue => SettingsData.set("popupTransparency", newValue / 100)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
tab: "theme"
|
||||
tags: ["foreground", "layers", "contrast", "surface", "blur", "glass", "frosted"]
|
||||
settingKey: "blurForegroundLayers"
|
||||
text: I18n.tr("Foreground Layers")
|
||||
description: I18n.tr("Show foreground surfaces on panels for stronger contrast")
|
||||
checked: SettingsData.blurForegroundLayers ?? true
|
||||
onToggled: checked => SettingsData.set("blurForegroundLayers", checked)
|
||||
}
|
||||
|
||||
SettingsSliderRow {
|
||||
tab: "theme"
|
||||
tags: ["foreground", "layers", "outline", "border", "cards", "widgets", "notifications", "control center"]
|
||||
settingKey: "blurLayerOutlineOpacity"
|
||||
text: I18n.tr("Layer Outline Opacity")
|
||||
description: I18n.tr("Controls outlines around foreground cards, pills, and notification cards")
|
||||
value: Math.round((SettingsData.blurLayerOutlineOpacity ?? 0.12) * 100)
|
||||
minimum: 0
|
||||
maximum: 40
|
||||
unit: "%"
|
||||
defaultValue: 12
|
||||
onSliderValueChanged: newValue => SettingsData.set("blurLayerOutlineOpacity", newValue / 100)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
tab: "theme"
|
||||
tags: ["surface", "popup", "modal", "border", "outline", "edge"]
|
||||
settingKey: "blurBorderEnabled"
|
||||
text: I18n.tr("Surface Border Outline")
|
||||
description: I18n.tr("Outline around shell surfaces")
|
||||
checked: SettingsData.blurBorderEnabled ?? true
|
||||
onToggled: checked => SettingsData.set("blurBorderEnabled", checked)
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
tab: "theme"
|
||||
tags: ["surface", "popup", "modal", "border", "outline", "edge"]
|
||||
settingKey: "blurBorderColor"
|
||||
text: I18n.tr("Surface Border Color")
|
||||
description: I18n.tr("Border color around popouts, modals, and other shell surfaces")
|
||||
visible: SettingsData.blurBorderEnabled ?? true
|
||||
options: [I18n.tr("Outline", "surface border color"), I18n.tr("Primary", "surface border color"), I18n.tr("Secondary", "surface border color"), I18n.tr("Text Color", "surface border color"), I18n.tr("Custom", "surface border color")]
|
||||
optionColorMap: ({
|
||||
[I18n.tr("Outline", "surface border color")]: Theme.outline,
|
||||
@@ -1834,7 +1809,6 @@ Item {
|
||||
settingKey: "blurBorderOpacity"
|
||||
text: I18n.tr("Surface Border Opacity")
|
||||
description: I18n.tr("Controls the outline of popouts, modals, and other shell surfaces")
|
||||
visible: SettingsData.blurBorderEnabled ?? true
|
||||
value: Math.round((SettingsData.blurBorderOpacity ?? 0.35) * 100)
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
@@ -1843,6 +1817,20 @@ Item {
|
||||
onSliderValueChanged: newValue => SettingsData.set("blurBorderOpacity", newValue / 100)
|
||||
}
|
||||
|
||||
SettingsSliderRow {
|
||||
tab: "theme"
|
||||
tags: ["foreground", "layers", "outline", "border", "cards", "widgets", "notifications", "control center"]
|
||||
settingKey: "blurLayerOutlineOpacity"
|
||||
text: I18n.tr("Layer Outline Opacity")
|
||||
description: I18n.tr("Controls outlines around foreground cards, pills, and notification cards")
|
||||
value: Math.round((SettingsData.blurLayerOutlineOpacity ?? 0.12) * 100)
|
||||
minimum: 0
|
||||
maximum: 40
|
||||
unit: "%"
|
||||
defaultValue: 12
|
||||
onSliderValueChanged: newValue => SettingsData.set("blurLayerOutlineOpacity", newValue / 100)
|
||||
}
|
||||
|
||||
SettingsSliderRow {
|
||||
tab: "theme"
|
||||
tags: ["corner", "radius", "rounded", "square"]
|
||||
@@ -2116,6 +2104,32 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
tab: "theme"
|
||||
tags: ["modal", "darken", "background", "overlay"]
|
||||
title: I18n.tr("Modal Background")
|
||||
settingKey: "modalBackground"
|
||||
iconName: "layers"
|
||||
|
||||
SettingsControlledByFrame {
|
||||
visible: themeColorsTab.frameModeActive
|
||||
parentModal: themeColorsTab.parentModal
|
||||
settingLabel: I18n.tr("Darken Modal Background")
|
||||
reason: I18n.tr("Disabled by Frame Mode")
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
tab: "theme"
|
||||
tags: ["modal", "darken", "background", "overlay"]
|
||||
settingKey: "modalDarkenBackground"
|
||||
text: I18n.tr("Darken Modal Background")
|
||||
description: I18n.tr("Show darkened overlay behind modal dialogs")
|
||||
visible: !themeColorsTab.frameModeActive
|
||||
checked: SettingsData.modalDarkenBackground
|
||||
onToggled: checked => SettingsData.set("modalDarkenBackground", checked)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
tab: "theme"
|
||||
tags: ["applications", "portal", "dark", "terminal"]
|
||||
|
||||
@@ -557,19 +557,20 @@ Item {
|
||||
return sectionId === "left" ? leftSection : sectionId === "center" ? centerSection : sectionId === "right" ? rightSection : null;
|
||||
}
|
||||
|
||||
// Id-based reorder; rebuilds from authoritative objects so every prop (incl. hideWhenIdle) survives.
|
||||
// Consumes one entry per occurrence so duplicate ids (e.g. two spacers) don't collapse, and
|
||||
// appends unconsumed entries so nothing is silently dropped from the config array.
|
||||
// Id-based reorder; rebuilds from authoritative objects so every prop (incl. hideWhenIdle) survives
|
||||
function reorderSection(sectionId, orderedIds) {
|
||||
var remaining = getWidgetsForSection(sectionId).slice();
|
||||
var current = getWidgetsForSection(sectionId);
|
||||
var byId = {};
|
||||
current.forEach(w => {
|
||||
var id = (typeof w === "string" ? w : w.id);
|
||||
byId[id] = w;
|
||||
});
|
||||
var reordered = [];
|
||||
orderedIds.forEach(id => {
|
||||
var idx = remaining.findIndex(w => (typeof w === "string" ? w : w.id) === id);
|
||||
if (idx < 0)
|
||||
return;
|
||||
reordered.push(remaining.splice(idx, 1)[0]);
|
||||
if (byId[id] !== undefined)
|
||||
reordered.push(byId[id]);
|
||||
});
|
||||
setWidgetsForSection(sectionId, reordered.concat(remaining));
|
||||
setWidgetsForSection(sectionId, reordered);
|
||||
}
|
||||
|
||||
// Move a widget across sections (or within); committed as one atomic bar-config save
|
||||
@@ -978,16 +979,8 @@ Item {
|
||||
var isString = typeof widget === "string";
|
||||
var widgetId = isString ? widget : widget.id;
|
||||
var widgetDef = baseWidgetDefinitions.find(w => w.id === widgetId);
|
||||
if (!widgetDef) {
|
||||
// Skipping entries would desync row indices from the config array (issue #2844)
|
||||
widgetDef = {
|
||||
"id": widgetId,
|
||||
"text": widgetId || I18n.tr("Unknown"),
|
||||
"description": "",
|
||||
"icon": "extension",
|
||||
"warning": I18n.tr("Unavailable")
|
||||
};
|
||||
}
|
||||
if (!widgetDef)
|
||||
return;
|
||||
|
||||
var item = Object.assign({}, widgetDef);
|
||||
item.enabled = isString ? true : widget.enabled;
|
||||
|
||||
@@ -37,8 +37,6 @@ Variants {
|
||||
|
||||
color: "transparent"
|
||||
|
||||
visible: root.contentReady && !root.surfaceBounce
|
||||
|
||||
updatesEnabled: root.renderActive || root._settleFrames > 0
|
||||
|
||||
mask: Region {
|
||||
@@ -66,9 +64,6 @@ Variants {
|
||||
property string actualTransitionType: transitionType
|
||||
property bool isInitialized: false
|
||||
|
||||
property bool contentReady: false
|
||||
property bool surfaceBounce: false
|
||||
|
||||
property string scrollMode: SettingsData.wallpaperFillMode
|
||||
property bool scrollingEnabled: scrollMode === "Scrolling"
|
||||
property int currentWorkspaceIndex: 0
|
||||
@@ -268,9 +263,9 @@ Variants {
|
||||
interval: 0
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root.surfaceBounce = true;
|
||||
wallpaperWindow.visible = false;
|
||||
Qt.callLater(() => {
|
||||
root.surfaceBounce = false;
|
||||
wallpaperWindow.visible = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -511,9 +506,6 @@ Variants {
|
||||
|
||||
Component.onCompleted: {
|
||||
isInitialized = true;
|
||||
if (!source || isColorSource) {
|
||||
contentReady = true;
|
||||
}
|
||||
|
||||
if (scrollingEnabled) {
|
||||
updateWorkspaceData();
|
||||
@@ -582,10 +574,8 @@ Variants {
|
||||
root.effectActive = false;
|
||||
root.screenScale = CompositorService.getScreenScale(modelData);
|
||||
// No status change coming to clear the flag
|
||||
if (!newSource || currentWallpaper.source.toString() === newSource) {
|
||||
if (!newSource || currentWallpaper.source.toString() === newSource)
|
||||
root.changePending = false;
|
||||
root.contentReady = true;
|
||||
}
|
||||
currentWallpaper.source = newSource;
|
||||
nextWallpaper.source = "";
|
||||
|
||||
@@ -756,7 +746,6 @@ Variants {
|
||||
}
|
||||
if (status === Image.Ready || status === Image.Error) {
|
||||
root.changePending = false;
|
||||
root.contentReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import qs.Common
|
||||
@@ -79,6 +80,7 @@ Item {
|
||||
readonly property int displayWorkspaceCount: displayedWorkspaceIds.length
|
||||
|
||||
readonly property int effectiveColumns: SettingsData.overviewColumns
|
||||
readonly property int effectiveRows: Math.max(SettingsData.overviewRows, Math.ceil(displayWorkspaceCount / effectiveColumns))
|
||||
|
||||
function getWorkspaceMonitorName(workspaceId) {
|
||||
if (!allWorkspaces || !workspaceId)
|
||||
@@ -105,53 +107,21 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
function monitorIpcForWorkspace(workspaceId) {
|
||||
function getWorkspaceViewportBounds(workspaceId) {
|
||||
const workspace = allWorkspaces?.find(ws => ws?.id === workspaceId);
|
||||
return workspace?.monitor?.lastIpcObject ?? monitor?.lastIpcObject ?? null;
|
||||
}
|
||||
const mon = workspace?.monitor?.lastIpcObject || monitor?.lastIpcObject || {};
|
||||
const reserved = mon.reserved || [0, 0, 0, 0];
|
||||
|
||||
function monitorLogicalSize(ipc) {
|
||||
if (!ipc || !ipc.width || !ipc.height)
|
||||
return {
|
||||
"width": monitorPhysicalWidth,
|
||||
"height": monitorPhysicalHeight
|
||||
};
|
||||
|
||||
const monScale = ipc.scale > 0 ? ipc.scale : 1;
|
||||
const rotated = ((ipc.transform ?? 0) % 2) === 1;
|
||||
return {
|
||||
"width": (rotated ? ipc.height : ipc.width) / monScale,
|
||||
"height": (rotated ? ipc.width : ipc.height) / monScale
|
||||
};
|
||||
}
|
||||
|
||||
function cellForWorkspace(workspaceId) {
|
||||
const cell = gridLayout.cells.find(c => c.id === workspaceId);
|
||||
if (cell)
|
||||
return cell;
|
||||
return {
|
||||
"id": workspaceId,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": workspaceImplicitWidth,
|
||||
"height": workspaceImplicitHeight
|
||||
};
|
||||
}
|
||||
|
||||
function getWorkspaceViewportBounds(workspaceId, cellWidth, cellHeight) {
|
||||
const ipc = monitorIpcForWorkspace(workspaceId) ?? {};
|
||||
const logical = monitorLogicalSize(ipc);
|
||||
const reserved = ipc.reserved || [0, 0, 0, 0];
|
||||
|
||||
const x = (ipc.x ?? 0) + (reserved[0] ?? 0);
|
||||
const y = (ipc.y ?? 0) + (reserved[1] ?? 0);
|
||||
const width = Math.max(logical.width - (reserved[0] ?? 0) - (reserved[2] ?? 0), 1);
|
||||
const height = Math.max(logical.height - (reserved[1] ?? 0) - (reserved[3] ?? 0), 1);
|
||||
const x = (mon.x ?? 0) + (reserved[0] ?? 0);
|
||||
const y = (mon.y ?? 0) + (reserved[1] ?? 0);
|
||||
const width = Math.max((mon.width ?? monitorPhysicalWidth) - (reserved[0] ?? 0) - (reserved[2] ?? 0), 1);
|
||||
const height = Math.max((mon.height ?? monitorPhysicalHeight) - (reserved[1] ?? 0) - (reserved[3] ?? 0), 1);
|
||||
const scale = Math.min(root.workspaceImplicitWidth / width, root.workspaceImplicitHeight / height);
|
||||
|
||||
return {
|
||||
"x": x,
|
||||
"y": y,
|
||||
"scale": Math.min(cellWidth / width, cellHeight / height)
|
||||
"scale": scale
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,56 +143,6 @@ Item {
|
||||
property int draggingFromWorkspace: -1
|
||||
property int draggingTargetWorkspace: -1
|
||||
|
||||
readonly property var gridLayout: {
|
||||
const ids = displayedWorkspaceIds;
|
||||
const columns = effectiveColumns;
|
||||
if (!ids || ids.length === 0 || columns < 1)
|
||||
return {
|
||||
"cells": [],
|
||||
"width": 0,
|
||||
"height": 0
|
||||
};
|
||||
|
||||
const rows = [];
|
||||
for (let i = 0; i < ids.length; i += columns) {
|
||||
rows.push(ids.slice(i, i + columns).map(id => {
|
||||
const logical = monitorLogicalSize(monitorIpcForWorkspace(id));
|
||||
return {
|
||||
"id": id,
|
||||
"width": logical.width * scale,
|
||||
"height": logical.height * scale
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
const rowWidth = row => row.reduce((acc, cell) => acc + cell.width, 0) + workspaceSpacing * (row.length - 1);
|
||||
const totalWidth = rows.reduce((acc, row) => Math.max(acc, rowWidth(row)), 0);
|
||||
|
||||
const cells = [];
|
||||
let cursorY = 0;
|
||||
for (const row of rows) {
|
||||
const rowHeight = row.reduce((acc, cell) => Math.max(acc, cell.height), 0);
|
||||
let cursorX = (totalWidth - rowWidth(row)) / 2;
|
||||
for (const cell of row) {
|
||||
cells.push({
|
||||
"id": cell.id,
|
||||
"x": cursorX,
|
||||
"y": cursorY + (rowHeight - cell.height) / 2,
|
||||
"width": cell.width,
|
||||
"height": cell.height
|
||||
});
|
||||
cursorX += cell.width + workspaceSpacing;
|
||||
}
|
||||
cursorY += rowHeight + workspaceSpacing;
|
||||
}
|
||||
|
||||
return {
|
||||
"cells": cells,
|
||||
"width": totalWidth,
|
||||
"height": cursorY - workspaceSpacing
|
||||
};
|
||||
}
|
||||
|
||||
implicitWidth: overviewBackground.implicitWidth + Theme.spacingL * 2
|
||||
implicitHeight: overviewBackground.implicitHeight + Theme.spacingL * 2
|
||||
|
||||
@@ -246,8 +166,8 @@ Item {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingL
|
||||
|
||||
implicitWidth: workspaceGrid.implicitWidth + padding * 2
|
||||
implicitHeight: workspaceGrid.implicitHeight + padding * 2
|
||||
implicitWidth: workspaceColumnLayout.implicitWidth + padding * 2
|
||||
implicitHeight: workspaceColumnLayout.implicitHeight + padding * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainer
|
||||
|
||||
@@ -262,78 +182,84 @@ Item {
|
||||
shadowEnabled: Theme.elevationEnabled
|
||||
}
|
||||
|
||||
Item {
|
||||
id: workspaceGrid
|
||||
ColumnLayout {
|
||||
id: workspaceColumnLayout
|
||||
|
||||
z: root.workspaceZ
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: root.gridLayout.width
|
||||
implicitHeight: root.gridLayout.height
|
||||
spacing: workspaceSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.gridLayout.cells.length
|
||||
model: root.effectiveRows
|
||||
delegate: RowLayout {
|
||||
id: row
|
||||
property int rowIndex: index
|
||||
spacing: workspaceSpacing
|
||||
|
||||
Rectangle {
|
||||
id: workspace
|
||||
required property int index
|
||||
readonly property var cell: root.gridLayout.cells[index] ?? null
|
||||
property int workspaceValue: cell?.id ?? -1
|
||||
property bool workspaceExists: (root.allWorkspaceIds && workspaceValue > 0) ? root.allWorkspaceIds.includes(workspaceValue) : false
|
||||
property var workspaceObj: (workspaceExists && Hyprland.workspaces?.values) ? Hyprland.workspaces.values.find(ws => ws?.id === workspaceValue) : null
|
||||
property bool isActive: workspaceObj?.active ?? false
|
||||
property bool isOnThisMonitor: (workspaceObj && root.monitor) ? (workspaceObj.monitor?.name === root.monitor.name) : true
|
||||
property bool hasWindows: (workspaceValue > 0) ? root.workspaceHasWindows(workspaceValue) : false
|
||||
property color defaultWorkspaceColor: workspaceExists ? Theme.surfaceContainer : Theme.withAlpha(Theme.surfaceContainer, 0.3)
|
||||
property color hoveredWorkspaceColor: Qt.lighter(defaultWorkspaceColor, 1.1)
|
||||
property color hoveredBorderColor: Theme.surfaceVariant
|
||||
property bool hoveredWhileDragging: false
|
||||
property bool shouldShowActiveIndicator: isActive && isOnThisMonitor && hasWindows
|
||||
Repeater {
|
||||
model: root.effectiveColumns
|
||||
Rectangle {
|
||||
id: workspace
|
||||
property int colIndex: index
|
||||
property int workspaceIndex: rowIndex * root.effectiveColumns + colIndex
|
||||
property int workspaceValue: (root.displayedWorkspaceIds && workspaceIndex < root.displayedWorkspaceIds.length) ? root.displayedWorkspaceIds[workspaceIndex] : -1
|
||||
property bool workspaceExists: (root.allWorkspaceIds && workspaceValue > 0) ? root.allWorkspaceIds.includes(workspaceValue) : false
|
||||
property var workspaceObj: (workspaceExists && Hyprland.workspaces?.values) ? Hyprland.workspaces.values.find(ws => ws?.id === workspaceValue) : null
|
||||
property bool isActive: workspaceObj?.active ?? false
|
||||
property bool isOnThisMonitor: (workspaceObj && root.monitor) ? (workspaceObj.monitor?.name === root.monitor.name) : true
|
||||
property bool hasWindows: (workspaceValue > 0) ? root.workspaceHasWindows(workspaceValue) : false
|
||||
property string workspaceMonitorName: (workspaceValue > 0) ? root.getWorkspaceMonitorName(workspaceValue) : ""
|
||||
property color defaultWorkspaceColor: workspaceExists ? Theme.surfaceContainer : Theme.withAlpha(Theme.surfaceContainer, 0.3)
|
||||
property color hoveredWorkspaceColor: Qt.lighter(defaultWorkspaceColor, 1.1)
|
||||
property color hoveredBorderColor: Theme.surfaceVariant
|
||||
property bool hoveredWhileDragging: false
|
||||
property bool shouldShowActiveIndicator: isActive && isOnThisMonitor && hasWindows
|
||||
|
||||
visible: workspaceValue !== -1
|
||||
visible: workspaceValue !== -1
|
||||
|
||||
x: cell?.x ?? 0
|
||||
y: cell?.y ?? 0
|
||||
width: cell?.width ?? 0
|
||||
height: cell?.height ?? 0
|
||||
color: hoveredWhileDragging ? hoveredWorkspaceColor : defaultWorkspaceColor
|
||||
radius: Theme.cornerRadius
|
||||
border.width: 2
|
||||
border.color: hoveredWhileDragging ? hoveredBorderColor : (shouldShowActiveIndicator ? root.activeBorderColor : Theme.withAlpha(root.activeBorderColor, 0))
|
||||
implicitWidth: root.workspaceImplicitWidth
|
||||
implicitHeight: root.workspaceImplicitHeight
|
||||
color: hoveredWhileDragging ? hoveredWorkspaceColor : defaultWorkspaceColor
|
||||
radius: Theme.cornerRadius
|
||||
border.width: 2
|
||||
border.color: hoveredWhileDragging ? hoveredBorderColor : (shouldShowActiveIndicator ? root.activeBorderColor : Theme.withAlpha(root.activeBorderColor, 0))
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: workspace.workspaceValue
|
||||
font.pixelSize: Theme.fontSizeXLarge * 6
|
||||
font.weight: Font.DemiBold
|
||||
color: Theme.withAlpha(Theme.surfaceText, workspace.workspaceExists ? 0.2 : 0.1)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: workspaceArea
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton
|
||||
onClicked: {
|
||||
if (root.draggingTargetWorkspace === -1) {
|
||||
root.overviewOpen = false;
|
||||
HyprlandService.focusWorkspace(workspace.workspaceValue);
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: workspaceValue
|
||||
font.pixelSize: Theme.fontSizeXLarge * 6
|
||||
font.weight: Font.DemiBold
|
||||
color: Theme.withAlpha(Theme.surfaceText, workspaceExists ? 0.2 : 0.1)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropArea {
|
||||
anchors.fill: parent
|
||||
onEntered: {
|
||||
root.draggingTargetWorkspace = workspace.workspaceValue;
|
||||
if (root.draggingFromWorkspace == root.draggingTargetWorkspace)
|
||||
return;
|
||||
workspace.hoveredWhileDragging = true;
|
||||
}
|
||||
onExited: {
|
||||
workspace.hoveredWhileDragging = false;
|
||||
if (root.draggingTargetWorkspace == workspace.workspaceValue)
|
||||
root.draggingTargetWorkspace = -1;
|
||||
MouseArea {
|
||||
id: workspaceArea
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton
|
||||
onClicked: {
|
||||
if (root.draggingTargetWorkspace === -1) {
|
||||
root.overviewOpen = false;
|
||||
HyprlandService.focusWorkspace(workspaceValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropArea {
|
||||
anchors.fill: parent
|
||||
onEntered: {
|
||||
root.draggingTargetWorkspace = workspaceValue;
|
||||
if (root.draggingFromWorkspace == root.draggingTargetWorkspace)
|
||||
return;
|
||||
hoveredWhileDragging = true;
|
||||
}
|
||||
onExited: {
|
||||
hoveredWhileDragging = false;
|
||||
if (root.draggingTargetWorkspace == workspaceValue)
|
||||
root.draggingTargetWorkspace = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,8 +269,8 @@ Item {
|
||||
Item {
|
||||
id: windowSpace
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: workspaceGrid.implicitWidth
|
||||
implicitHeight: workspaceGrid.implicitHeight
|
||||
implicitWidth: workspaceColumnLayout.implicitWidth
|
||||
implicitHeight: workspaceColumnLayout.implicitHeight
|
||||
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
@@ -380,21 +306,41 @@ Item {
|
||||
|
||||
overviewOpen: root.overviewOpen
|
||||
readonly property int windowWorkspaceId: modelData?.workspace?.id ?? -1
|
||||
readonly property var workspaceCell: root.cellForWorkspace(windowWorkspaceId)
|
||||
readonly property var workspaceBounds: root.getWorkspaceViewportBounds(windowWorkspaceId, workspaceCell.width, workspaceCell.height)
|
||||
|
||||
function getWorkspaceIndex() {
|
||||
if (!root.displayedWorkspaceIds || root.displayedWorkspaceIds.length === 0)
|
||||
return 0;
|
||||
if (!windowWorkspaceId || windowWorkspaceId < 0)
|
||||
return 0;
|
||||
try {
|
||||
for (let i = 0; i < root.displayedWorkspaceIds.length; i++) {
|
||||
if (root.displayedWorkspaceIds[i] === windowWorkspaceId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property int workspaceIndex: getWorkspaceIndex()
|
||||
readonly property int workspaceColIndex: workspaceIndex % root.effectiveColumns
|
||||
readonly property int workspaceRowIndex: Math.floor(workspaceIndex / root.effectiveColumns)
|
||||
readonly property var workspaceBounds: root.getWorkspaceViewportBounds(windowWorkspaceId)
|
||||
|
||||
toplevel: modelData
|
||||
scale: root.scale
|
||||
monitorDpr: root.dpr
|
||||
availableWorkspaceWidth: workspaceCell.width
|
||||
availableWorkspaceHeight: workspaceCell.height
|
||||
availableWorkspaceWidth: root.workspaceImplicitWidth
|
||||
availableWorkspaceHeight: root.workspaceImplicitHeight
|
||||
contentOriginX: workspaceBounds.x
|
||||
contentOriginY: workspaceBounds.y
|
||||
contentScale: workspaceBounds.scale
|
||||
widgetMonitorId: root.monitor.id
|
||||
|
||||
xOffset: workspaceCell.x
|
||||
yOffset: workspaceCell.y
|
||||
xOffset: (root.workspaceImplicitWidth + workspaceSpacing) * workspaceColIndex
|
||||
yOffset: (root.workspaceImplicitHeight + workspaceSpacing) * workspaceRowIndex
|
||||
|
||||
z: atInitPosition ? root.windowZ : root.windowDraggingZ
|
||||
property bool atInitPosition: (initX == x && initY == y)
|
||||
@@ -463,44 +409,54 @@ Item {
|
||||
Item {
|
||||
id: monitorLabelSpace
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: workspaceGrid.implicitWidth
|
||||
implicitHeight: workspaceGrid.implicitHeight
|
||||
implicitWidth: workspaceColumnLayout.implicitWidth
|
||||
implicitHeight: workspaceColumnLayout.implicitHeight
|
||||
z: root.monitorLabelZ
|
||||
|
||||
Repeater {
|
||||
model: root.gridLayout.cells.length
|
||||
model: root.effectiveRows
|
||||
delegate: Item {
|
||||
id: labelItem
|
||||
required property int index
|
||||
readonly property var cell: root.gridLayout.cells[index] ?? null
|
||||
property int workspaceValue: cell?.id ?? -1
|
||||
property bool workspaceExists: (root.allWorkspaceIds && workspaceValue > 0) ? root.allWorkspaceIds.includes(workspaceValue) : false
|
||||
property string workspaceMonitorName: (workspaceValue > 0) ? root.getWorkspaceMonitorName(workspaceValue) : ""
|
||||
id: labelRow
|
||||
property int rowIndex: index
|
||||
y: (root.workspaceImplicitHeight + workspaceSpacing) * rowIndex
|
||||
width: parent.width
|
||||
height: root.workspaceImplicitHeight
|
||||
|
||||
x: cell?.x ?? 0
|
||||
y: cell?.y ?? 0
|
||||
width: cell?.width ?? 0
|
||||
height: cell?.height ?? 0
|
||||
Repeater {
|
||||
model: root.effectiveColumns
|
||||
delegate: Item {
|
||||
id: labelItem
|
||||
property int colIndex: index
|
||||
property int workspaceIndex: labelRow.rowIndex * root.effectiveColumns + colIndex
|
||||
property int workspaceValue: (root.displayedWorkspaceIds && workspaceIndex < root.displayedWorkspaceIds.length) ? root.displayedWorkspaceIds[workspaceIndex] : -1
|
||||
property bool workspaceExists: (root.allWorkspaceIds && workspaceValue > 0) ? root.allWorkspaceIds.includes(workspaceValue) : false
|
||||
property string workspaceMonitorName: (workspaceValue > 0) ? root.getWorkspaceMonitorName(workspaceValue) : ""
|
||||
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.spacingS
|
||||
width: monitorNameText.contentWidth + Theme.spacingS * 2
|
||||
height: monitorNameText.contentHeight + Theme.spacingXS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surface
|
||||
visible: labelItem.workspaceExists && labelItem.workspaceMonitorName !== ""
|
||||
x: (root.workspaceImplicitWidth + workspaceSpacing) * colIndex
|
||||
width: root.workspaceImplicitWidth
|
||||
height: root.workspaceImplicitHeight
|
||||
|
||||
StyledText {
|
||||
id: monitorNameText
|
||||
anchors.centerIn: parent
|
||||
text: labelItem.workspaceMonitorName
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.spacingS
|
||||
width: monitorNameText.contentWidth + Theme.spacingS * 2
|
||||
height: monitorNameText.contentHeight + Theme.spacingXS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surface
|
||||
visible: labelItem.workspaceExists && labelItem.workspaceMonitorName !== ""
|
||||
|
||||
StyledText {
|
||||
id: monitorNameText
|
||||
anchors.centerIn: parent
|
||||
text: labelItem.workspaceMonitorName
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ Singleton {
|
||||
property bool suppressSound: true
|
||||
property bool previousPluggedState: false
|
||||
|
||||
readonly property var scale: 100 / SettingsData.batteryChargeLimit
|
||||
|
||||
Timer {
|
||||
id: startupTimer
|
||||
interval: 500
|
||||
@@ -76,7 +78,7 @@ Singleton {
|
||||
return 0;
|
||||
if (batteryCapacity === 0) {
|
||||
if (usePreferred && preferredDeviceKnown) {
|
||||
const val = Math.min(100, Math.round(preferredDevice.percentage * 100));
|
||||
const val = Math.min(100, Math.round(preferredDevice.percentage * 100 * scale));
|
||||
_lastBatteryLevel = val;
|
||||
return val;
|
||||
}
|
||||
@@ -86,7 +88,7 @@ Singleton {
|
||||
if (validBatteries.length === 0)
|
||||
return _lastBatteryLevel;
|
||||
const avgPercentage = validBatteries.reduce((sum, b) => sum + b.percentage, 0) / validBatteries.length;
|
||||
const val = Math.min(100, Math.round(avgPercentage * 100));
|
||||
const val = Math.min(100, Math.round(avgPercentage * 100 * scale));
|
||||
_lastBatteryLevel = val;
|
||||
return val;
|
||||
}
|
||||
@@ -94,7 +96,7 @@ Singleton {
|
||||
const cap = batteryCapacity;
|
||||
if (cap === 0)
|
||||
return _lastBatteryLevel;
|
||||
const val = Math.min(100, Math.round((energy * 100) / cap));
|
||||
const val = Math.min(100, Math.round((energy * 100) / cap * scale));
|
||||
_lastBatteryLevel = val;
|
||||
return val;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ Singleton {
|
||||
|
||||
// These settings predate non-blurred surface borders, so keep their keys for compatibility.
|
||||
readonly property color borderColor: {
|
||||
if (!(SettingsData.blurBorderEnabled ?? true))
|
||||
return "transparent";
|
||||
const opacity = SettingsData.blurBorderOpacity ?? 0.35;
|
||||
switch (SettingsData.blurBorderColor ?? "outline") {
|
||||
case "primary":
|
||||
@@ -28,12 +26,12 @@ Singleton {
|
||||
case "surfaceText":
|
||||
return Theme.withAlpha(Theme.surfaceText, opacity);
|
||||
case "custom":
|
||||
return Theme.withAlpha(Qt.color(SettingsData.blurBorderCustomColor ?? "#ffffff"), opacity);
|
||||
return Theme.withAlpha(SettingsData.blurBorderCustomColor ?? "#ffffff", opacity);
|
||||
default:
|
||||
return Theme.withAlpha(Theme.outline, opacity);
|
||||
}
|
||||
}
|
||||
readonly property int borderWidth: (SettingsData.blurBorderEnabled ?? true) ? 1 : 0
|
||||
readonly property int borderWidth: 1
|
||||
|
||||
function hoverColor(baseColor, hoverAlpha) {
|
||||
if (!enabled)
|
||||
|
||||
@@ -13,7 +13,7 @@ Singleton {
|
||||
readonly property var log: Log.scoped("ChangelogService")
|
||||
|
||||
readonly property string currentVersion: "1.5"
|
||||
readonly property bool changelogEnabled: true
|
||||
readonly property bool changelogEnabled: false
|
||||
|
||||
readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/DankMaterialShell"
|
||||
readonly property string changelogMarkerPath: configDir + "/.changelog-" + currentVersion
|
||||
|
||||
@@ -28,6 +28,7 @@ Singleton {
|
||||
readonly property string hyprlandSignature: Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
readonly property string niriSocket: Quickshell.env("NIRI_SOCKET")
|
||||
readonly property string swaySocket: Quickshell.env("SWAYSOCK")
|
||||
readonly property string scrollSocket: Quickshell.env("SWAYSOCK")
|
||||
readonly property string miracleSocket: Quickshell.env("MIRACLESOCK")
|
||||
readonly property string labwcPid: Quickshell.env("LABWC_PID")
|
||||
readonly property string mangoSignature: Quickshell.env("MANGO_INSTANCE_SIGNATURE")
|
||||
@@ -942,133 +943,122 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// Primary detection asks the kernel which process owns the $WAYLAND_DISPLAY
|
||||
// socket — the compositor quickshell is actually connected to. Env vars like
|
||||
// HYPRLAND_INSTANCE_SIGNATURE / MANGO_INSTANCE_SIGNATURE can leak into the
|
||||
// systemd user environment from previous sessions and lie. Unset
|
||||
// WAYLAND_DISPLAY falls back to "wayland-0", mirroring wl_display_connect.
|
||||
// /proc/net/unix: field 6 is state (01 = listening), 7 inode, 8 bound path.
|
||||
function detectCompositor() {
|
||||
const script = 'sock="${WAYLAND_DISPLAY:-wayland-0}"; case "$sock" in /*) ;; *) sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/$sock" ;; esac; inode=$(awk -v p="$sock" \'$8 == p && $6 == 1 {print $7; exit}\' /proc/net/unix); [ -n "$inode" ] || exit 1; fd=$(find /proc/[0-9]*/fd/ -mindepth 1 -maxdepth 1 -lname "socket:\\[$inode\\]" 2>/dev/null | head -n1); [ -n "$fd" ] || exit 1; pid="${fd#/proc/}"; cat "/proc/${pid%%/*}/comm"';
|
||||
Proc.runCommand("waylandSocketOwner", ["sh", "-c", script], (output, exitCode) => {
|
||||
const comm = (exitCode === 0 && output) ? output.trim().toLowerCase() : "";
|
||||
const name = _compositorNameFromComm(comm);
|
||||
if (name) {
|
||||
_applyCompositor(name);
|
||||
log.info("Detected", name, "from Wayland socket owner:", comm);
|
||||
return;
|
||||
}
|
||||
if (comm)
|
||||
log.info("Unrecognized Wayland socket owner:", comm, "- falling back to env detection");
|
||||
_detectFromEnv(0);
|
||||
}, 0, 3000);
|
||||
}
|
||||
|
||||
function _compositorNameFromComm(comm) {
|
||||
switch (comm) {
|
||||
case "niri":
|
||||
return "niri";
|
||||
case "hyprland":
|
||||
return "hyprland";
|
||||
case "sway":
|
||||
return "sway";
|
||||
case "scroll":
|
||||
return "scroll";
|
||||
case "mango":
|
||||
return "mango";
|
||||
case "miracle-wm":
|
||||
return "miracle";
|
||||
case "labwc":
|
||||
return "labwc";
|
||||
default:
|
||||
return "";
|
||||
if (mangoSignature && mangoSignature.length > 0) {
|
||||
isHyprland = false;
|
||||
isNiri = false;
|
||||
isMango = true;
|
||||
isSway = false;
|
||||
isScroll = false;
|
||||
isMiracle = false;
|
||||
isLabwc = false;
|
||||
compositor = "mango";
|
||||
log.info("Detected MangoWM via MANGO_INSTANCE_SIGNATURE");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function _applyCompositor(name) {
|
||||
isHyprland = name === "hyprland";
|
||||
isNiri = name === "niri";
|
||||
isMango = name === "mango";
|
||||
isSway = name === "sway";
|
||||
isScroll = name === "scroll";
|
||||
isMiracle = name === "miracle";
|
||||
isLabwc = name === "labwc";
|
||||
compositor = name;
|
||||
compositorDetected = true;
|
||||
if (isNiri)
|
||||
NiriService.generateNiriBlurrule();
|
||||
}
|
||||
if (hyprlandSignature && hyprlandSignature.length > 0 && !niriSocket && !swaySocket && !scrollSocket && !miracleSocket && !labwcPid) {
|
||||
isHyprland = true;
|
||||
isNiri = false;
|
||||
isMango = false;
|
||||
isSway = false;
|
||||
isScroll = false;
|
||||
isMiracle = false;
|
||||
isLabwc = false;
|
||||
compositor = "hyprland";
|
||||
log.info("Detected Hyprland");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback when the socket owner can't be resolved (no ss, unrecognized
|
||||
// comm). Same priority order as before, but every candidate must prove
|
||||
// liveness; a dead socket/PID falls through to the next candidate instead
|
||||
// of winning on a stale env var.
|
||||
function _envDetectionCandidates() {
|
||||
const runtimeDir = Quickshell.env("XDG_RUNTIME_DIR") || "";
|
||||
return [
|
||||
{
|
||||
name: "mango",
|
||||
present: !!mangoSignature,
|
||||
test: ["test", "-S", mangoSignature],
|
||||
detail: "MANGO_INSTANCE_SIGNATURE " + mangoSignature
|
||||
},
|
||||
{
|
||||
name: "niri",
|
||||
present: !!niriSocket,
|
||||
test: ["test", "-S", niriSocket],
|
||||
detail: "NIRI_SOCKET " + niriSocket
|
||||
},
|
||||
{
|
||||
name: "miracle",
|
||||
present: !!miracleSocket,
|
||||
test: ["test", "-S", miracleSocket],
|
||||
detail: "MIRACLESOCK " + miracleSocket
|
||||
},
|
||||
{
|
||||
name: "sway",
|
||||
present: !!swaySocket,
|
||||
test: ["test", "-S", swaySocket],
|
||||
resolve: () => {
|
||||
const desktop = String(Quickshell.env("XDG_CURRENT_DESKTOP") || "").toLowerCase();
|
||||
return desktop.includes("sway") ? "sway" : "scroll";
|
||||
},
|
||||
detail: "SWAYSOCK " + swaySocket
|
||||
},
|
||||
{
|
||||
name: "labwc",
|
||||
present: !!labwcPid,
|
||||
test: ["sh", "-c", "[ \"$(cat /proc/\"$LABWC_PID\"/comm 2>/dev/null)\" = labwc ]"],
|
||||
detail: "LABWC_PID " + labwcPid
|
||||
},
|
||||
{
|
||||
name: "hyprland",
|
||||
present: !!hyprlandSignature,
|
||||
test: ["test", "-S", runtimeDir + "/hypr/" + hyprlandSignature + "/.socket.sock"],
|
||||
detail: "HYPRLAND_INSTANCE_SIGNATURE " + hyprlandSignature
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function _detectFromEnv(index) {
|
||||
const candidates = _envDetectionCandidates();
|
||||
for (let i = index; i < candidates.length; i++) {
|
||||
const c = candidates[i];
|
||||
if (!c.present)
|
||||
continue;
|
||||
const next = i + 1;
|
||||
Proc.runCommand(c.name + "SocketCheck", c.test, (output, exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
log.warn(c.detail, "is set but not alive, skipping");
|
||||
_detectFromEnv(next);
|
||||
return;
|
||||
if (niriSocket && niriSocket.length > 0) {
|
||||
Proc.runCommand("niriSocketCheck", ["test", "-S", niriSocket], (output, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
isNiri = true;
|
||||
isHyprland = false;
|
||||
isMango = false;
|
||||
isSway = false;
|
||||
isScroll = false;
|
||||
isMiracle = false;
|
||||
isLabwc = false;
|
||||
compositor = "niri";
|
||||
log.info("Detected Niri with socket:", niriSocket);
|
||||
NiriService.generateNiriBlurrule();
|
||||
}
|
||||
const name = c.resolve ? c.resolve() : c.name;
|
||||
_applyCompositor(name);
|
||||
log.info("Detected", name, "via", c.detail);
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
_applyCompositor("unknown");
|
||||
|
||||
if (swaySocket && swaySocket.length > 0 && !scrollSocket && scrollSocket.length == 0 && !miracleSocket) {
|
||||
Proc.runCommand("swaySocketCheck", ["test", "-S", swaySocket], (output, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
isNiri = false;
|
||||
isHyprland = false;
|
||||
isSway = true;
|
||||
isScroll = false;
|
||||
isMiracle = false;
|
||||
isLabwc = false;
|
||||
compositor = "sway";
|
||||
log.info("Detected Sway with socket:", swaySocket);
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (miracleSocket && miracleSocket.length > 0) {
|
||||
Proc.runCommand("miracleSocketCheck", ["test", "-S", miracleSocket], (output, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
isNiri = false;
|
||||
isHyprland = false;
|
||||
isMango = false;
|
||||
isSway = false;
|
||||
isScroll = false;
|
||||
isMiracle = true;
|
||||
isLabwc = false;
|
||||
compositor = "miracle";
|
||||
log.info("Detected Miracle WM with socket:", miracleSocket);
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollSocket && scrollSocket.length > 0 && !miracleSocket) {
|
||||
Proc.runCommand("scrollSocketCheck", ["test", "-S", scrollSocket], (output, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
isNiri = false;
|
||||
isHyprland = false;
|
||||
isMango = false;
|
||||
isSway = false;
|
||||
isScroll = true;
|
||||
isMiracle = false;
|
||||
isLabwc = false;
|
||||
compositor = "scroll";
|
||||
log.info("Detected Scroll with socket:", scrollSocket);
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (labwcPid && labwcPid.length > 0) {
|
||||
isHyprland = false;
|
||||
isNiri = false;
|
||||
isMango = false;
|
||||
isSway = false;
|
||||
isScroll = false;
|
||||
isMiracle = false;
|
||||
isLabwc = true;
|
||||
compositor = "labwc";
|
||||
log.info("Detected LabWC with PID:", labwcPid);
|
||||
return;
|
||||
}
|
||||
|
||||
isHyprland = false;
|
||||
isNiri = false;
|
||||
isMango = false;
|
||||
isSway = false;
|
||||
isScroll = false;
|
||||
isMiracle = false;
|
||||
isLabwc = false;
|
||||
compositor = "unknown";
|
||||
log.warn("No compositor detected");
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ Singleton {
|
||||
property var lockComponent: null
|
||||
property bool monitorsOff: false
|
||||
property bool isShellLocked: false
|
||||
property bool lockPowerOffRequested: false
|
||||
|
||||
function reapplyDpmsIfNeeded() {
|
||||
if (monitorsOff)
|
||||
@@ -147,7 +146,7 @@ Singleton {
|
||||
id: lockWakeMonitor
|
||||
timeout: 1
|
||||
respectInhibitors: false
|
||||
enabled: root.enabled && root.isShellLocked && root.monitorsOff && (SettingsData.lockScreenPowerOffMonitorsOnLock || root.lockPowerOffRequested)
|
||||
enabled: root.enabled && root.isShellLocked && root.monitorsOff && SettingsData.lockScreenPowerOffMonitorsOnLock
|
||||
onIsIdleChanged: {
|
||||
if (!isIdle && root.monitorsOff)
|
||||
root.requestMonitorOn();
|
||||
|
||||
@@ -19,8 +19,7 @@ Singleton {
|
||||
const desktopEntry = ("desktopEntry" in p && p.desktopEntry) ? String(p.desktopEntry).toLowerCase() : "";
|
||||
return !excluded.some(ex => {
|
||||
const exLower = String(ex).toLowerCase().trim();
|
||||
if (!exLower)
|
||||
return false;
|
||||
if (!exLower) return false;
|
||||
|
||||
// 1. Substring match
|
||||
if (identity.includes(exLower) || desktopEntry.includes(exLower))
|
||||
@@ -44,20 +43,17 @@ Singleton {
|
||||
}
|
||||
property MprisPlayer activePlayer: null
|
||||
property real activePlayerStableLength: 0
|
||||
// Chromium can report blank metadata between tracks
|
||||
property string stableTitle: ""
|
||||
property string stableArtist: ""
|
||||
|
||||
Connections {
|
||||
target: root.activePlayer
|
||||
function onTrackTitleChanged() {
|
||||
root.activePlayerStableLength = (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) ? root.activePlayer.length : 0;
|
||||
root._syncStableMeta();
|
||||
root._checkIdle();
|
||||
if (root.isIdle(root.activePlayer))
|
||||
root._resolveActivePlayer();
|
||||
}
|
||||
function onTrackArtistChanged() {
|
||||
root._syncStableMeta();
|
||||
root._checkIdle();
|
||||
if (root.isIdle(root.activePlayer))
|
||||
root._resolveActivePlayer();
|
||||
}
|
||||
function onLengthChanged() {
|
||||
if (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) {
|
||||
@@ -65,53 +61,13 @@ Singleton {
|
||||
}
|
||||
}
|
||||
function onPlaybackStateChanged() {
|
||||
root._syncStableMeta();
|
||||
root._checkIdle();
|
||||
if (root.isIdle(root.activePlayer))
|
||||
root._resolveActivePlayer();
|
||||
}
|
||||
}
|
||||
|
||||
onActivePlayerChanged: {
|
||||
activePlayerStableLength = (activePlayer && activePlayer.lengthSupported && activePlayer.length > 1) ? activePlayer.length : 0;
|
||||
stableTitle = activePlayer?.trackTitle || "";
|
||||
stableArtist = activePlayer?.trackArtist || "";
|
||||
_checkIdle();
|
||||
}
|
||||
|
||||
function _syncStableMeta(): void {
|
||||
const p = activePlayer;
|
||||
if (!p) {
|
||||
stableTitle = "";
|
||||
stableArtist = "";
|
||||
return;
|
||||
}
|
||||
if (isFirefoxYoutubeHoverPreview(p))
|
||||
return;
|
||||
if (p.trackTitle)
|
||||
stableTitle = p.trackTitle;
|
||||
if (p.trackArtist)
|
||||
stableArtist = p.trackArtist;
|
||||
}
|
||||
|
||||
// Chromium reports stopped media w/blank metadata, resolve by checking idle status
|
||||
Timer {
|
||||
id: _idleGraceTimer
|
||||
interval: 1223
|
||||
onTriggered: {
|
||||
if (!root.isIdle(root.activePlayer))
|
||||
return;
|
||||
root.stableTitle = "";
|
||||
root.stableArtist = "";
|
||||
root._resolveActivePlayer();
|
||||
}
|
||||
}
|
||||
|
||||
function _checkIdle(): void {
|
||||
if (!isIdle(activePlayer)) {
|
||||
_idleGraceTimer.stop();
|
||||
return;
|
||||
}
|
||||
if (!_idleGraceTimer.running)
|
||||
_idleGraceTimer.start();
|
||||
}
|
||||
|
||||
onAvailablePlayersChanged: _resolveActivePlayer()
|
||||
@@ -130,28 +86,21 @@ Singleton {
|
||||
}
|
||||
|
||||
function isIdle(player: MprisPlayer): bool {
|
||||
return player && player.playbackState === MprisPlaybackState.Stopped && !player.trackTitle && !player.trackArtist;
|
||||
return player
|
||||
&& player.playbackState === MprisPlaybackState.Stopped
|
||||
&& !player.trackTitle
|
||||
&& !player.trackArtist;
|
||||
}
|
||||
|
||||
function _resolveActivePlayer(): void {
|
||||
// A playing player always wins; otherwise keep the selection stable w/idle
|
||||
const playing = availablePlayers.find(p => p.isPlaying);
|
||||
if (playing) {
|
||||
if (activePlayer !== playing) {
|
||||
activePlayer = playing;
|
||||
_persistIdentity(playing.identity);
|
||||
}
|
||||
activePlayer = playing;
|
||||
_persistIdentity(playing.identity);
|
||||
return;
|
||||
}
|
||||
if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0 && (!isIdle(activePlayer) || _idleGraceTimer.running))
|
||||
if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0 && !isIdle(activePlayer))
|
||||
return;
|
||||
if (activePlayer && availablePlayers.indexOf(activePlayer) < 0) {
|
||||
const successor = availablePlayers.find(p => p.identity === activePlayer.identity);
|
||||
if (successor) {
|
||||
activePlayer = successor;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const savedId = SessionData.lastPlayerIdentity;
|
||||
if (savedId) {
|
||||
const match = availablePlayers.find(p => p.identity === savedId);
|
||||
@@ -201,10 +150,4 @@ Singleton {
|
||||
else if (activePlayer.canGoPrevious)
|
||||
activePlayer.previous();
|
||||
}
|
||||
|
||||
function next(): void {
|
||||
const player = activePlayer;
|
||||
if (player?.canGoNext)
|
||||
player.next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
@@ -33,18 +34,6 @@ Singleton {
|
||||
|
||||
readonly property int updateCount: availableUpdates.length
|
||||
readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0
|
||||
readonly property bool useCustomCommand: SettingsData.updaterUseCustomCommand && (SettingsData.updaterCustomCommand || "").trim().length > 0
|
||||
|
||||
// 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/Flatpak packages stay ignorable — holding those cannot break the repo dependency graph.
|
||||
readonly property bool systemHoldsAllowed: !["pacman", "paru", "yay"].includes(pkgManager)
|
||||
|
||||
function canIgnorePackage(pkg) {
|
||||
if (!pkg)
|
||||
return false;
|
||||
return systemHoldsAllowed || pkg.repo !== "system";
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: DMSService
|
||||
@@ -119,11 +108,9 @@ Singleton {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
backends = data.backends || [];
|
||||
const systemBackend = backends.find(b => b.repo === "system" || b.repo === "ostree");
|
||||
pkgManager = systemBackend ? systemBackend.id : (backends.length > 0 ? backends[0].id : "");
|
||||
_rawUpdates = data.packages || [];
|
||||
availableUpdates = _filterUpdates(_rawUpdates);
|
||||
backends = data.backends || [];
|
||||
distribution = data.distro || "";
|
||||
distributionPretty = data.distroPretty || "";
|
||||
distributionSupported = (backends.length > 0);
|
||||
@@ -158,6 +145,13 @@ Singleton {
|
||||
errorCode = "";
|
||||
errorHint = "";
|
||||
}
|
||||
|
||||
if (backends.length > 0) {
|
||||
const sys = backends.find(b => b.repo === "system" || b.repo === "ostree");
|
||||
pkgManager = sys ? sys.id : backends[0].id;
|
||||
} else {
|
||||
pkgManager = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _filterUpdates(pkgs) {
|
||||
@@ -165,8 +159,6 @@ Singleton {
|
||||
return (pkgs || []).filter(p => {
|
||||
if (!SettingsData.updaterAllowAUR && p.repo === "aur")
|
||||
return false;
|
||||
if (!canIgnorePackage(p))
|
||||
return true;
|
||||
return ignored.indexOf(p.name) === -1;
|
||||
});
|
||||
}
|
||||
@@ -185,27 +177,17 @@ Singleton {
|
||||
SettingsData.set("updaterIgnoredPackages", list);
|
||||
}
|
||||
|
||||
function unignorePackage(name) {
|
||||
if (!name)
|
||||
return;
|
||||
const list = (SettingsData.updaterIgnoredPackages || []).filter(p => p !== name);
|
||||
SettingsData.set("updaterIgnoredPackages", list);
|
||||
}
|
||||
|
||||
function checkForUpdates() {
|
||||
DMSService.sysupdateRefresh(false, null);
|
||||
}
|
||||
|
||||
function runUpdates(opts) {
|
||||
const params = opts || {};
|
||||
params.ignored = SettingsData.updaterIgnoredPackages || [];
|
||||
if (useCustomCommand) {
|
||||
params.customCommand = SettingsData.updaterCustomCommand.trim();
|
||||
const termArgs = (SettingsData.updaterTerminalAdditionalParams || "").trim();
|
||||
if (termArgs.length > 0) {
|
||||
params.terminalArgs = termArgs.split(/\s+/);
|
||||
}
|
||||
if (SettingsData.updaterUseCustomCommand && SettingsData.updaterCustomCommand.length > 0) {
|
||||
_runCustomTerminalCommand();
|
||||
return;
|
||||
}
|
||||
params.ignored = SettingsData.updaterIgnoredPackages || [];
|
||||
DMSService.sysupdateUpgrade(params, null);
|
||||
}
|
||||
|
||||
@@ -217,6 +199,30 @@ Singleton {
|
||||
DMSService.sysupdateSetInterval(seconds, null);
|
||||
}
|
||||
|
||||
function _runCustomTerminalCommand() {
|
||||
const terminal = SessionData.resolveTerminal();
|
||||
if (!terminal || terminal.length === 0) {
|
||||
ToastService.showError(I18n.tr("No terminal configured"), I18n.tr("Pick a terminal in Settings → Launcher (or set $TERMINAL)."));
|
||||
return;
|
||||
}
|
||||
const updateCommand = `${SettingsData.updaterCustomCommand} && echo -n "Updates complete! " ; echo "Press Enter to close..." && read`;
|
||||
const termClass = SettingsData.updaterTerminalAdditionalParams || "";
|
||||
var argv = [terminal];
|
||||
if (termClass.length > 0) {
|
||||
argv = argv.concat(termClass.split(" "));
|
||||
}
|
||||
argv.push("-e");
|
||||
argv.push("sh");
|
||||
argv.push("-c");
|
||||
argv.push(updateCommand);
|
||||
customRunner.command = argv;
|
||||
customRunner.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: customRunner
|
||||
}
|
||||
|
||||
property bool _startupCheckDone: false
|
||||
|
||||
function _maybeStartupCheck() {
|
||||
@@ -256,4 +262,5 @@ Singleton {
|
||||
}
|
||||
DMSService.sysupdateRelease(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -29,17 +29,19 @@ Singleton {
|
||||
function getArtworkUrl(player) {
|
||||
if (!player) return "";
|
||||
|
||||
// 1. If native trackArtUrl is present and valid
|
||||
let artUrl = player.trackArtUrl || "";
|
||||
if (artUrl !== "") {
|
||||
return artUrl;
|
||||
}
|
||||
|
||||
// 2. Fallback to raw metadata mpris:artUrl if present
|
||||
if (player.metadata && player.metadata["mpris:artUrl"]) {
|
||||
artUrl = player.metadata["mpris:artUrl"].toString();
|
||||
if (artUrl !== "") return artUrl;
|
||||
}
|
||||
|
||||
// YouTube publishes no artUrl; derive the thumbnail from the video id.
|
||||
// 3. Fallback for YouTube from xesam:url
|
||||
if (player.metadata && player.metadata["xesam:url"]) {
|
||||
const url = player.metadata["xesam:url"].toString();
|
||||
if (url.includes("youtube.com") || url.includes("youtu.be")) {
|
||||
@@ -54,13 +56,13 @@ Singleton {
|
||||
return "";
|
||||
}
|
||||
|
||||
function _commit(u, artKey, srcUrl) {
|
||||
function _commit(u) {
|
||||
resolvedArtUrl = u;
|
||||
_committedArtKey = u !== "" ? artKey : "";
|
||||
_committedSrcUrl = u !== "" ? srcUrl : "";
|
||||
_committedArtKey = u !== "" ? _pendingArtKey : "";
|
||||
_committedSrcUrl = u !== "" ? _lastArtUrl : "";
|
||||
}
|
||||
|
||||
function loadArtwork(url, artKey, requestSerial) {
|
||||
function loadArtwork(url) {
|
||||
if (!url || url === "") {
|
||||
// Keep stale art; only blank once the empty url debounce settles.
|
||||
_lastArtUrl = "";
|
||||
@@ -69,11 +71,9 @@ Singleton {
|
||||
return;
|
||||
}
|
||||
_clearDebounce.stop();
|
||||
// Same url must re-issue under a new serial; the bump cancelled the in-flight load.
|
||||
if (url === _lastArtUrl && requestSerial === _lastIssuedSerial)
|
||||
if (url === _lastArtUrl)
|
||||
return;
|
||||
_lastArtUrl = url;
|
||||
_lastIssuedSerial = requestSerial;
|
||||
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
loading = true;
|
||||
@@ -83,17 +83,18 @@ Singleton {
|
||||
const filePath = cacheDir + "/remote_" + hash;
|
||||
const localFileUrl = "file://" + filePath;
|
||||
|
||||
// 1. First, check if the file already exists locally
|
||||
Proc.runCommand(null, ["test", "-f", filePath], (output, exitCode) => {
|
||||
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
|
||||
if (_lastArtUrl !== targetUrl)
|
||||
return;
|
||||
|
||||
if (exitCode === 0) {
|
||||
_commit(localFileUrl, artKey, targetUrl);
|
||||
_commit(localFileUrl);
|
||||
loading = false;
|
||||
} else {
|
||||
const dlCmd = "mkdir -p \"$(dirname \"$1\")\" && curl -f -s -L -o \"$1\" \"$2\" && mv \"$1\" \"$3\" || { rm -f \"$1\"; exit 1; }";
|
||||
|
||||
// YouTube: try the 16:9 maxres thumbnail before falling back.
|
||||
// 2. Check if this is a YouTube URL to do high quality 16:9 fallback
|
||||
if (targetUrl.includes("img.youtube.com/vi/")) {
|
||||
const videoId = targetUrl.split("/vi/")[1].split("/")[0];
|
||||
const maxresUrl = "https://img.youtube.com/vi/" + videoId + "/maxresdefault.jpg";
|
||||
@@ -101,29 +102,30 @@ Singleton {
|
||||
const tmpPath = filePath + ".tmp";
|
||||
|
||||
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, maxresUrl, filePath], (maxOutput, maxExitCode) => {
|
||||
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
|
||||
if (_lastArtUrl !== targetUrl)
|
||||
return;
|
||||
|
||||
if (maxExitCode === 0) {
|
||||
_commit(localFileUrl, artKey, targetUrl);
|
||||
_commit(localFileUrl);
|
||||
loading = false;
|
||||
} else {
|
||||
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, mqUrl, filePath], (mqOutput, mqExitCode) => {
|
||||
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
|
||||
if (_lastArtUrl !== targetUrl)
|
||||
return;
|
||||
|
||||
_commit(mqExitCode === 0 ? localFileUrl : targetUrl, artKey, targetUrl);
|
||||
_commit(mqExitCode === 0 ? localFileUrl : targetUrl);
|
||||
loading = false;
|
||||
}, 50, 15000);
|
||||
}
|
||||
}, 50, 15000);
|
||||
} else {
|
||||
// Standard curl download for other remote URLs (e.g. SoundCloud)
|
||||
const tmpPath = filePath + ".tmp";
|
||||
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, targetUrl, filePath], (dlOutput, dlExitCode) => {
|
||||
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
|
||||
if (_lastArtUrl !== targetUrl)
|
||||
return;
|
||||
|
||||
_commit(dlExitCode === 0 ? localFileUrl : targetUrl, artKey, targetUrl);
|
||||
_commit(dlExitCode === 0 ? localFileUrl : targetUrl);
|
||||
loading = false;
|
||||
}, 50, 15000);
|
||||
}
|
||||
@@ -139,7 +141,7 @@ Singleton {
|
||||
// Cover lands after metadata, so poll; commit a content-addressed copy so identical bytes keep an identical url
|
||||
const script = "f=\"$1\"; d=\"$2\"; i=0; while [ ! -f \"$f\" ] && [ \"$i\" -lt 20 ]; do sleep 0.15; i=$((i + 1)); done; [ -f \"$f\" ] || exit 1; s=$(sha1sum \"$f\" | cut -c1-40); if [ ! -f \"$d/art_$s\" ]; then mkdir -p \"$d\" && cp \"$f\" \"$d/art_$s.tmp\" && mv \"$d/art_$s.tmp\" \"$d/art_$s\" || exit 1; fi; echo \"$s\"";
|
||||
Proc.runCommand(null, ["sh", "-c", script, "sh", filePath, cacheDir], (output, exitCode) => {
|
||||
if (_lastArtUrl !== localUrl || _requestSerial !== requestSerial)
|
||||
if (_lastArtUrl !== localUrl)
|
||||
return;
|
||||
loading = false;
|
||||
if (exitCode !== 0)
|
||||
@@ -147,7 +149,7 @@ Singleton {
|
||||
const sha = (output || "").trim();
|
||||
if (_artHashDenylist.indexOf(sha) !== -1)
|
||||
return;
|
||||
_commit("file://" + cacheDir + "/art_" + sha, artKey, localUrl);
|
||||
_commit("file://" + cacheDir + "/art_" + sha);
|
||||
}, 50, 5000);
|
||||
}
|
||||
|
||||
@@ -156,7 +158,7 @@ Singleton {
|
||||
interval: 800
|
||||
onTriggered: {
|
||||
if (root._lastArtUrl === "")
|
||||
root._commit("", "", "");
|
||||
root._commit("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,8 +167,6 @@ Singleton {
|
||||
property string _committedArtKey: ""
|
||||
property string _pendingArtKey: ""
|
||||
property string _committedSrcUrl: ""
|
||||
property int _requestSerial: 0
|
||||
property int _lastIssuedSerial: -1
|
||||
|
||||
onActivePlayerChanged: _updateArtUrl()
|
||||
|
||||
@@ -182,10 +182,9 @@ Singleton {
|
||||
const p = activePlayer;
|
||||
if (!p)
|
||||
return "";
|
||||
// dbusName is constant per player; uniqueId is per-track and would churn the key.
|
||||
const playerId = p.dbusName || p.identity || "";
|
||||
// +title/artist: KDEconnect reuses one trackid forever; album excluded: Chrome delivers it late, orphaning the lock
|
||||
const tid = p.metadata && p.metadata["mpris:trackid"] ? p.metadata["mpris:trackid"].toString() : "";
|
||||
return playerId + " " + tid + " " + (p.trackTitle || "") + " " + (p.trackArtist || "");
|
||||
return tid + " " + (p.trackTitle || "") + " " + (p.trackArtist || "");
|
||||
}
|
||||
|
||||
function artReadyFor(player) {
|
||||
@@ -195,19 +194,17 @@ Singleton {
|
||||
|
||||
function _updateArtUrl() {
|
||||
const key = _trackKey();
|
||||
if (key !== _pendingArtKey) {
|
||||
_requestSerial++;
|
||||
loading = false;
|
||||
}
|
||||
_pendingArtKey = key;
|
||||
const url = getArtworkUrl(activePlayer);
|
||||
// Ignore Chrome's same-track thumbnail size updates.
|
||||
// Skip once real art is committed for this track (dedup Chrome's multi-size
|
||||
// re-publish). The lock is set in _commit(), never optimistically, so a rejected
|
||||
// placeholder or a short-circuited duplicate url can't wedge the real cover out.
|
||||
if (key !== "" && key === _committedArtKey)
|
||||
return;
|
||||
_pendingArtKey = key;
|
||||
const url = getArtworkUrl(activePlayer);
|
||||
if (key !== "" && url !== "" && url === _committedSrcUrl) {
|
||||
// Chrome can publish track metadata before its new artwork URL.
|
||||
_committedArtKey = key;
|
||||
return;
|
||||
}
|
||||
loadArtwork(url, key, _requestSerial);
|
||||
loadArtwork(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,28 +116,12 @@ Item {
|
||||
|
||||
property var hyprlandOverviewLoaderRef: hyprlandOverviewLoader
|
||||
|
||||
// Horizontal bars must claim their exclusive zones first, so vertical bars wait for every enabled horizontal bar to load
|
||||
readonly property int horizontalWanted: SettingsData.barConfigs.filter(c => (c.enabled ?? false) && c.position !== SettingsData.Position.Left && c.position !== SettingsData.Position.Right).length
|
||||
property int horizontalReady: 0
|
||||
|
||||
function recountHorizontalReady() {
|
||||
let ready = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const loader = itemAt(i);
|
||||
if (loader?.item && !loader.isVertical)
|
||||
ready++;
|
||||
}
|
||||
horizontalReady = ready;
|
||||
}
|
||||
|
||||
delegate: Loader {
|
||||
id: barLoader
|
||||
required property var modelData
|
||||
property var barConfig: SettingsData.barConfigs.find(cfg => cfg.id === modelData.id) || null
|
||||
readonly property bool isVertical: modelData.position === SettingsData.Position.Left || modelData.position === SettingsData.Position.Right
|
||||
active: root.barSurfacesLoaded && (barConfig?.enabled ?? false) && (!isVertical || dankBarRepeater.horizontalReady >= dankBarRepeater.horizontalWanted)
|
||||
active: root.barSurfacesLoaded && (barConfig?.enabled ?? false)
|
||||
asynchronous: false
|
||||
onItemChanged: dankBarRepeater.recountHorizontalReady()
|
||||
|
||||
sourceComponent: DankBar {
|
||||
barConfig: barLoader.barConfig
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v1.5.2
|
||||
v1.6-beta
|
||||
|
||||
@@ -182,12 +182,16 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Timer, not FrameAnimation — a running animation commits frames every vsync (#2863)
|
||||
Timer {
|
||||
running: blobEffect.visible && root.onScreen
|
||||
interval: 33
|
||||
repeat: true
|
||||
onTriggered: root.stepBlob(0.033)
|
||||
FrameAnimation {
|
||||
running: blobEffect.visible
|
||||
property real pending: 0
|
||||
onTriggered: {
|
||||
pending += frameTime;
|
||||
if (pending < 0.03)
|
||||
return;
|
||||
root.stepBlob(Math.min(pending, 0.05));
|
||||
pending = 0;
|
||||
}
|
||||
}
|
||||
|
||||
ShaderEffect {
|
||||
|
||||
@@ -174,15 +174,8 @@ Item {
|
||||
_pendingOpen = false;
|
||||
_pendingOpenTimer.stop();
|
||||
transientSurfaceTracker?.closeAll?.();
|
||||
if (impl.item) {
|
||||
if (impl.item)
|
||||
impl.item.close();
|
||||
return;
|
||||
}
|
||||
PopoutManager.hidePopout(root);
|
||||
if (!shouldBeVisible)
|
||||
return;
|
||||
shouldBeVisible = false;
|
||||
popoutClosed();
|
||||
}
|
||||
|
||||
function cancelHoverDismiss() {
|
||||
@@ -322,8 +315,7 @@ Item {
|
||||
it.hoverDismissEnabled = Qt.binding(() => root.hoverDismissEnabled);
|
||||
it.hoverDismissSuspended = Qt.binding(() => root.effectiveHoverDismissSuspended);
|
||||
|
||||
if (root.shouldBeVisible && !_pendingOpen)
|
||||
root.shouldBeVisible = false;
|
||||
it.shouldBeVisible = root.shouldBeVisible;
|
||||
if (root._primeContent && typeof it.primeContent === "function")
|
||||
it.primeContent();
|
||||
if (_pendingOpen)
|
||||
|
||||
@@ -923,13 +923,6 @@ Item {
|
||||
visible: false
|
||||
color: "transparent"
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible || !Qt.inputMethod)
|
||||
return;
|
||||
Qt.inputMethod.hide();
|
||||
Qt.inputMethod.reset();
|
||||
}
|
||||
|
||||
PopoutHoverDismiss {
|
||||
id: hoverDismissController
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -630,13 +630,6 @@ Item {
|
||||
color: "transparent"
|
||||
readonly property bool closeVisualActive: root.shouldBeVisible || root.isClosing
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible || !Qt.inputMethod)
|
||||
return;
|
||||
Qt.inputMethod.hide();
|
||||
Qt.inputMethod.reset();
|
||||
}
|
||||
|
||||
PopoutHoverDismiss {
|
||||
id: hoverDismissController
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -147,10 +147,6 @@ Item {
|
||||
trackColor: MediaAccentService.accentTrack
|
||||
actualProgressColor: MediaAccentService.accentSubtle
|
||||
isPlaying: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing
|
||||
onFrameTicked: {
|
||||
if (!root.isSeeking)
|
||||
activePlayer.positionChanged();
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: waveMouseArea
|
||||
|
||||
@@ -174,7 +174,7 @@ PanelWindow {
|
||||
id: contentRect
|
||||
layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
|
||||
layer.smooth: false
|
||||
layer.textureSize: Qt.size(0, 0)
|
||||
layer.textureSize: Qt.size(width * root.dpr, height * root.dpr)
|
||||
opacity: 1
|
||||
|
||||
readonly property color slideoutSurfaceColor: root.customTransparency >= 0 ? Theme.withAlpha(Theme.surfaceContainer, root.customTransparency) : Theme.popupLayerColor(Theme.surfaceContainer)
|
||||
|
||||
@@ -50,15 +50,11 @@ Item {
|
||||
fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wave_progress.frag.qsb")
|
||||
}
|
||||
|
||||
signal frameTicked
|
||||
|
||||
FrameAnimation {
|
||||
running: root.visible && (root.isPlaying || root.currentAmp > 0) && (root.Window.window?.visible ?? false)
|
||||
running: root.visible && (root.isPlaying || root.currentAmp > 0)
|
||||
onTriggered: {
|
||||
if (!root.isPlaying)
|
||||
return;
|
||||
root.phase = (root.phase + 0.03 * frameTime * 60) % 6.28318530718;
|
||||
root.frameTicked();
|
||||
if (root.isPlaying)
|
||||
root.phase += 0.03 * frameTime * 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +70,6 @@ Item {
|
||||
iconImage.source = "file:///run/current-system/profile/share/icons/hicolor/scalable/apps/guix-icon.svg"
|
||||
return
|
||||
}
|
||||
if (logo === "zirconium") {
|
||||
iconImage.source = "file:///usr/share/zirconium/pixmaps/logo-z.svg"
|
||||
return
|
||||
}
|
||||
iconImage.source = Quickshell.iconPath(logo, true)
|
||||
}, 0)
|
||||
}, 0)
|
||||
|
||||
+308
-212
File diff suppressed because it is too large
Load Diff
@@ -777,7 +777,7 @@
|
||||
"Authenticate": "صادِق"
|
||||
},
|
||||
"Authenticated!": {
|
||||
"Authenticated!": "تم التحقق بنجاح!"
|
||||
"Authenticated!": ""
|
||||
},
|
||||
"Authenticating...": {
|
||||
"Authenticating...": "جاري المصادقة..."
|
||||
@@ -804,13 +804,13 @@
|
||||
"Authentication error - try again": "خطأ في المصادقة - حاول مرة أخرى"
|
||||
},
|
||||
"Authentication failed - attempt %1 of %2": {
|
||||
"Authentication failed - attempt %1 of %2": "فشل التحقق من الهوية - المحاولة %1 من %2"
|
||||
"Authentication failed - attempt %1 of %2": ""
|
||||
},
|
||||
"Authentication failed - lockout can occur": {
|
||||
"Authentication failed - lockout can occur": "فشل التحقق - قد يؤدي ذلك إلى قفل حسابك"
|
||||
"Authentication failed - lockout can occur": ""
|
||||
},
|
||||
"Authentication failed - try again": {
|
||||
"Authentication failed - try again": "فشل التحقق - يرجى المحاولة مرة أخرى"
|
||||
"Authentication failed - try again": ""
|
||||
},
|
||||
"Authentication failed, please try again": {
|
||||
"Authentication failed, please try again": "فشل التحقق، أعد المحاولة"
|
||||
@@ -987,13 +987,13 @@
|
||||
"Available.": "متاح."
|
||||
},
|
||||
"Awaiting fingerprint authentication": {
|
||||
"Awaiting fingerprint authentication": "بانتظار التحقق ببصمة الإصبع"
|
||||
"Awaiting fingerprint authentication": ""
|
||||
},
|
||||
"Awaiting fingerprint or security key authentication": {
|
||||
"Awaiting fingerprint or security key authentication": "بانتظار التحقق ببصمة الإصبع أو مفتاح الأمان"
|
||||
"Awaiting fingerprint or security key authentication": ""
|
||||
},
|
||||
"Awaiting security key authentication": {
|
||||
"Awaiting security key authentication": "بانتظار التحقق بمفتاح الأمان"
|
||||
"Awaiting security key authentication": ""
|
||||
},
|
||||
"BSSID": {
|
||||
"BSSID": "BSSID"
|
||||
@@ -2175,7 +2175,7 @@
|
||||
"Custom command and terminal params are split on whitespace; paths with spaces will break.": "يتم فصل الأمر المخصص ومعلمات الجهاز على المسافات البيضاء؛ المسارات التي تحتوي على مسافات ستتسبب في خطأ."
|
||||
},
|
||||
"Custom interval in minutes (minimum 5)": {
|
||||
"Custom interval in minutes (minimum 5)": "الفترة الزمنية المخصصة بالدقائق (الحد الأدنى 5)"
|
||||
"Custom interval in minutes (minimum 5)": ""
|
||||
},
|
||||
"Custom open-trash command": {
|
||||
"Custom open-trash command": "أمر مخصص لفتح المهملات"
|
||||
@@ -3435,7 +3435,7 @@
|
||||
"Flags": "الأعلام"
|
||||
},
|
||||
"Flatpak": {
|
||||
"Flatpak": "Flatpak"
|
||||
"Flatpak": ""
|
||||
},
|
||||
"Flipped": {
|
||||
"Flipped": "معكوس"
|
||||
@@ -3852,7 +3852,7 @@
|
||||
"Groups": "المجموعات"
|
||||
},
|
||||
"H": {
|
||||
"H": "H"
|
||||
"H": ""
|
||||
},
|
||||
"HDR (EDID)": {
|
||||
"HDR (EDID)": "HDR (EDID)"
|
||||
@@ -4149,22 +4149,22 @@
|
||||
"Ignore Completely": "تجاهل تمامًا"
|
||||
},
|
||||
"Ignore package": {
|
||||
"Ignore package": "تجاهل البرنامج"
|
||||
"Ignore package": ""
|
||||
},
|
||||
"Ignore this package": {
|
||||
"Ignore this package": "تجاهل هذا التطبيق"
|
||||
"Ignore this package": ""
|
||||
},
|
||||
"Ignored (%1)": {
|
||||
"Ignored (%1)": "تم تجاهله (%1)"
|
||||
"Ignored (%1)": ""
|
||||
},
|
||||
"Ignored Packages": {
|
||||
"Ignored Packages": "التطبيقات المتجاهلة"
|
||||
"Ignored Packages": ""
|
||||
},
|
||||
"Ignored packages are hidden from the updater and skipped by 'Update All'.": {
|
||||
"Ignored packages are hidden from the updater and skipped by 'Update All'.": "يتم إخفاء التطبيقات المتجاهلة من أداة التحديث ويتخطاها خيار \"تحديث الكل\"."
|
||||
"Ignored packages are hidden from the updater and skipped by 'Update All'.": ""
|
||||
},
|
||||
"Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions.": {
|
||||
"Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions.": "تنطبق التطبيقات المتجاهلة فقط على أداة التحديث المدمجة. يتحكم أمرك المخصص في الاستثناءات الخاصة به."
|
||||
"Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions.": ""
|
||||
},
|
||||
"Image": {
|
||||
"Image": "صورة"
|
||||
@@ -4347,7 +4347,7 @@
|
||||
"Invalid configuration": "تكوين غير صالح"
|
||||
},
|
||||
"Invalid package name — letters, digits and @._+:- only.": {
|
||||
"Invalid package name — letters, digits and @._+:- only.": "اسم التطبيق غير صالح — يُسمح فقط بالأحرف، والأرقام، والرموز التالية: @._+:-"
|
||||
"Invalid package name — letters, digits and @._+:- only.": ""
|
||||
},
|
||||
"Invalid password for %1": {
|
||||
"Invalid password for %1": "كلمة مرور غير صالحة لـ %1"
|
||||
@@ -5451,7 +5451,7 @@
|
||||
"No output devices found": "لم يتم العثور على أجهزة إخراج"
|
||||
},
|
||||
"No packages ignored. Add one here or hover an update in the popout and click the hide button.": {
|
||||
"No packages ignored. Add one here or hover an update in the popout and click the hide button.": "لا توجد تطبيقات متجاهلة. أضف تطبيقًا هنا، أو مرر مؤشر الفأرة فوق التحديث في النافذة المنبثقة وانقر على زر الإخفاء."
|
||||
"No packages ignored. Add one here or hover an update in the popout and click the hide button.": ""
|
||||
},
|
||||
"No peers found": {
|
||||
"No peers found": "لم يتم العثور على أقران"
|
||||
@@ -5937,7 +5937,7 @@
|
||||
"PIN": "PIN"
|
||||
},
|
||||
"Package name (e.g., docker)": {
|
||||
"Package name (e.g., docker)": "اسم التطبيق (مثال: docker)"
|
||||
"Package name (e.g., docker)": ""
|
||||
},
|
||||
"Pad": {
|
||||
"Pad": "وسادة"
|
||||
@@ -7812,7 +7812,7 @@
|
||||
"Status": "الحالة"
|
||||
},
|
||||
"Stop ignoring %1": {
|
||||
"Stop ignoring %1": "إلغاء تجاهل %1"
|
||||
"Stop ignoring %1": ""
|
||||
},
|
||||
"Stopped": {
|
||||
"Stopped": "متوقف"
|
||||
@@ -8910,7 +8910,7 @@
|
||||
"Votes": "الأصوات"
|
||||
},
|
||||
"W": {
|
||||
"W": "W"
|
||||
"W": ""
|
||||
},
|
||||
"WPA/WPA2": {
|
||||
"WPA/WPA2": "WPA/WPA2"
|
||||
@@ -9213,16 +9213,16 @@
|
||||
"by %1": "بواسطة %1"
|
||||
},
|
||||
"checked %1d ago": {
|
||||
"checked %1d ago": "تم الفحص قبل %1 من الأيام"
|
||||
"checked %1d ago": ""
|
||||
},
|
||||
"checked %1h ago": {
|
||||
"checked %1h ago": "تم الفحص قبل %1 من الساعات"
|
||||
"checked %1h ago": ""
|
||||
},
|
||||
"checked %1m ago": {
|
||||
"checked %1m ago": "تم الفحص قبل %1 من الدقائق"
|
||||
"checked %1m ago": ""
|
||||
},
|
||||
"checked just now": {
|
||||
"checked just now": "تم الفحص الآن"
|
||||
"checked just now": ""
|
||||
},
|
||||
"days": {
|
||||
"days": "أيام"
|
||||
|
||||
@@ -777,7 +777,7 @@
|
||||
"Authenticate": "Authentifizieren"
|
||||
},
|
||||
"Authenticated!": {
|
||||
"Authenticated!": "Authentifiziert!"
|
||||
"Authenticated!": ""
|
||||
},
|
||||
"Authenticating...": {
|
||||
"Authenticating...": "Authentifizierung..."
|
||||
@@ -804,13 +804,13 @@
|
||||
"Authentication error - try again": "Authentifizierungsfehler - erneut versuchen"
|
||||
},
|
||||
"Authentication failed - attempt %1 of %2": {
|
||||
"Authentication failed - attempt %1 of %2": "Authentifizierung fehlgeschlagen - Versuch %1 von %2"
|
||||
"Authentication failed - attempt %1 of %2": ""
|
||||
},
|
||||
"Authentication failed - lockout can occur": {
|
||||
"Authentication failed - lockout can occur": "Authentifizierung fehlgeschlagen - Sperrung kann erfolgen"
|
||||
"Authentication failed - lockout can occur": ""
|
||||
},
|
||||
"Authentication failed - try again": {
|
||||
"Authentication failed - try again": "Authentifizierung fehlgeschlagen - erneut versuchen"
|
||||
"Authentication failed - try again": ""
|
||||
},
|
||||
"Authentication failed, please try again": {
|
||||
"Authentication failed, please try again": "Authentifizierung nicht erfolgreich, bitte nochmals probieren"
|
||||
@@ -987,13 +987,13 @@
|
||||
"Available.": "Verfügbar."
|
||||
},
|
||||
"Awaiting fingerprint authentication": {
|
||||
"Awaiting fingerprint authentication": "Warten auf Fingerabdruck-Authentifizierung"
|
||||
"Awaiting fingerprint authentication": ""
|
||||
},
|
||||
"Awaiting fingerprint or security key authentication": {
|
||||
"Awaiting fingerprint or security key authentication": "Warten auf Fingerabdruck- oder Sicherheitsschlüssel-Authentifizierung"
|
||||
"Awaiting fingerprint or security key authentication": ""
|
||||
},
|
||||
"Awaiting security key authentication": {
|
||||
"Awaiting security key authentication": "Warten auf Sicherheitsschlüssel-Authentifizierung"
|
||||
"Awaiting security key authentication": ""
|
||||
},
|
||||
"BSSID": {
|
||||
"BSSID": "BSSID"
|
||||
@@ -2175,7 +2175,7 @@
|
||||
"Custom command and terminal params are split on whitespace; paths with spaces will break.": "Benutzerdefinierte Befehle und Terminalparameter werden an Leerzeichen getrennt; Pfade mit Leerzeichen funktionieren nicht."
|
||||
},
|
||||
"Custom interval in minutes (minimum 5)": {
|
||||
"Custom interval in minutes (minimum 5)": "Benutzerdefiniertes Intervall in Minuten (Minimum 5)"
|
||||
"Custom interval in minutes (minimum 5)": ""
|
||||
},
|
||||
"Custom open-trash command": {
|
||||
"Custom open-trash command": "Benutzerdefinierter Befehl zum Öffnen des Papierkorbs"
|
||||
@@ -3435,7 +3435,7 @@
|
||||
"Flags": "Flags"
|
||||
},
|
||||
"Flatpak": {
|
||||
"Flatpak": "Flatpak"
|
||||
"Flatpak": ""
|
||||
},
|
||||
"Flipped": {
|
||||
"Flipped": "Gespiegelt"
|
||||
@@ -3852,7 +3852,7 @@
|
||||
"Groups": "Gruppen"
|
||||
},
|
||||
"H": {
|
||||
"H": "H"
|
||||
"H": ""
|
||||
},
|
||||
"HDR (EDID)": {
|
||||
"HDR (EDID)": "HDR (EDID)"
|
||||
@@ -4149,22 +4149,22 @@
|
||||
"Ignore Completely": "Vollständig ignorieren"
|
||||
},
|
||||
"Ignore package": {
|
||||
"Ignore package": "Paket ignorieren"
|
||||
"Ignore package": ""
|
||||
},
|
||||
"Ignore this package": {
|
||||
"Ignore this package": "Dieses Paket ignorieren"
|
||||
"Ignore this package": ""
|
||||
},
|
||||
"Ignored (%1)": {
|
||||
"Ignored (%1)": "Ignoriert (%1)"
|
||||
"Ignored (%1)": ""
|
||||
},
|
||||
"Ignored Packages": {
|
||||
"Ignored Packages": "Ignorierte Pakete"
|
||||
"Ignored Packages": ""
|
||||
},
|
||||
"Ignored packages are hidden from the updater and skipped by 'Update All'.": {
|
||||
"Ignored packages are hidden from the updater and skipped by 'Update All'.": "Ignorierte Pakete werden im Updater ausgeblendet und bei \"Alle aktualisieren\" übersprungen."
|
||||
"Ignored packages are hidden from the updater and skipped by 'Update All'.": ""
|
||||
},
|
||||
"Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions.": {
|
||||
"Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions.": "Ignorierte Pakete gelten nur für den integrierten Updater. Ihr benutzerdefinierter Befehl steuert seine eigenen Ausschlüsse."
|
||||
"Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions.": ""
|
||||
},
|
||||
"Image": {
|
||||
"Image": "Bild"
|
||||
@@ -4347,7 +4347,7 @@
|
||||
"Invalid configuration": "Invalide Konfiguration"
|
||||
},
|
||||
"Invalid package name — letters, digits and @._+:- only.": {
|
||||
"Invalid package name — letters, digits and @._+:- only.": "Ungültiger Paketname — nur Buchstaben, Ziffern und @._+:- zulässig."
|
||||
"Invalid package name — letters, digits and @._+:- only.": ""
|
||||
},
|
||||
"Invalid password for %1": {
|
||||
"Invalid password for %1": "Ungültiges Passwort für %1"
|
||||
@@ -5451,7 +5451,7 @@
|
||||
"No output devices found": "Keine Ausgabegeräte gefunden"
|
||||
},
|
||||
"No packages ignored. Add one here or hover an update in the popout and click the hide button.": {
|
||||
"No packages ignored. Add one here or hover an update in the popout and click the hide button.": "Keine Pakete ignoriert. Fügen Sie hier eines hinzu oder bewegen Sie den Mauszeiger über ein Update im Popout und klicken Sie auf die Schaltfläche Ausblenden."
|
||||
"No packages ignored. Add one here or hover an update in the popout and click the hide button.": ""
|
||||
},
|
||||
"No peers found": {
|
||||
"No peers found": "Keine Peers gefunden"
|
||||
@@ -5937,7 +5937,7 @@
|
||||
"PIN": "PIN"
|
||||
},
|
||||
"Package name (e.g., docker)": {
|
||||
"Package name (e.g., docker)": "Paketname (z. B. docker)"
|
||||
"Package name (e.g., docker)": ""
|
||||
},
|
||||
"Pad": {
|
||||
"Pad": "Auffüllen"
|
||||
@@ -7812,7 +7812,7 @@
|
||||
"Status": "Status"
|
||||
},
|
||||
"Stop ignoring %1": {
|
||||
"Stop ignoring %1": "Ignorieren von %1 beenden"
|
||||
"Stop ignoring %1": ""
|
||||
},
|
||||
"Stopped": {
|
||||
"Stopped": "Gestoppt"
|
||||
@@ -8910,7 +8910,7 @@
|
||||
"Votes": "Stimmen"
|
||||
},
|
||||
"W": {
|
||||
"W": "W"
|
||||
"W": ""
|
||||
},
|
||||
"WPA/WPA2": {
|
||||
"WPA/WPA2": "WPA/WPA2"
|
||||
@@ -9213,16 +9213,16 @@
|
||||
"by %1": "von %1"
|
||||
},
|
||||
"checked %1d ago": {
|
||||
"checked %1d ago": "vor %1 T geprüft"
|
||||
"checked %1d ago": ""
|
||||
},
|
||||
"checked %1h ago": {
|
||||
"checked %1h ago": "vor %1 Std. geprüft"
|
||||
"checked %1h ago": ""
|
||||
},
|
||||
"checked %1m ago": {
|
||||
"checked %1m ago": "vor %1 Min. geprüft"
|
||||
"checked %1m ago": ""
|
||||
},
|
||||
"checked just now": {
|
||||
"checked just now": "gerade eben geprüft"
|
||||
"checked just now": ""
|
||||
},
|
||||
"days": {
|
||||
"days": "Tage"
|
||||
|
||||
@@ -2374,29 +2374,6 @@
|
||||
],
|
||||
"icon": "extension"
|
||||
},
|
||||
{
|
||||
"section": "modalDarkenBackground",
|
||||
"label": "Darken Modal Background",
|
||||
"tabIndex": 9,
|
||||
"category": "Launcher",
|
||||
"keywords": [
|
||||
"background",
|
||||
"behind",
|
||||
"dark mode",
|
||||
"darken",
|
||||
"darkened",
|
||||
"dialogs",
|
||||
"drawer",
|
||||
"launcher",
|
||||
"menu",
|
||||
"modal",
|
||||
"night",
|
||||
"overlay",
|
||||
"show",
|
||||
"start"
|
||||
],
|
||||
"description": "Show darkened overlay behind modal dialogs"
|
||||
},
|
||||
{
|
||||
"section": "launcherStyle",
|
||||
"label": "Default Launcher",
|
||||
@@ -2637,30 +2614,6 @@
|
||||
],
|
||||
"icon": "apps"
|
||||
},
|
||||
{
|
||||
"section": "modalBackground",
|
||||
"label": "Modal Background",
|
||||
"tabIndex": 9,
|
||||
"category": "Launcher",
|
||||
"keywords": [
|
||||
"background",
|
||||
"behind",
|
||||
"dark mode",
|
||||
"darken",
|
||||
"darkened",
|
||||
"dialogs",
|
||||
"drawer",
|
||||
"launcher",
|
||||
"menu",
|
||||
"modal",
|
||||
"night",
|
||||
"overlay",
|
||||
"show",
|
||||
"start"
|
||||
],
|
||||
"icon": "layers",
|
||||
"description": "Show darkened overlay behind modal dialogs"
|
||||
},
|
||||
{
|
||||
"section": "spotlightCloseNiriOverview",
|
||||
"label": "Niri Integration",
|
||||
@@ -3430,6 +3383,31 @@
|
||||
],
|
||||
"description": "Base to derive dark theme from"
|
||||
},
|
||||
{
|
||||
"section": "modalDarkenBackground",
|
||||
"label": "Darken Modal Background",
|
||||
"tabIndex": 10,
|
||||
"category": "Theme & Colors",
|
||||
"keywords": [
|
||||
"appearance",
|
||||
"background",
|
||||
"behind",
|
||||
"colors",
|
||||
"dark mode",
|
||||
"darken",
|
||||
"darkened",
|
||||
"dialogs",
|
||||
"look",
|
||||
"modal",
|
||||
"night",
|
||||
"overlay",
|
||||
"scheme",
|
||||
"show",
|
||||
"style",
|
||||
"theme"
|
||||
],
|
||||
"description": "Show darkened overlay behind modal dialogs"
|
||||
},
|
||||
{
|
||||
"section": "matugenTemplateEmacs",
|
||||
"label": "Emacs",
|
||||
@@ -3849,6 +3827,32 @@
|
||||
"icon": "auto_awesome",
|
||||
"conditionKey": "matugenAvailable"
|
||||
},
|
||||
{
|
||||
"section": "modalBackground",
|
||||
"label": "Modal Background",
|
||||
"tabIndex": 10,
|
||||
"category": "Theme & Colors",
|
||||
"keywords": [
|
||||
"appearance",
|
||||
"background",
|
||||
"behind",
|
||||
"colors",
|
||||
"dark mode",
|
||||
"darken",
|
||||
"darkened",
|
||||
"dialogs",
|
||||
"look",
|
||||
"modal",
|
||||
"night",
|
||||
"overlay",
|
||||
"scheme",
|
||||
"show",
|
||||
"style",
|
||||
"theme"
|
||||
],
|
||||
"icon": "layers",
|
||||
"description": "Show darkened overlay behind modal dialogs"
|
||||
},
|
||||
{
|
||||
"section": "modalElevationEnabled",
|
||||
"label": "Modal Shadows",
|
||||
@@ -4138,30 +4142,6 @@
|
||||
],
|
||||
"description": "Controls the outline of popouts, modals, and other shell surfaces"
|
||||
},
|
||||
{
|
||||
"section": "blurBorderEnabled",
|
||||
"label": "Surface Border Outline",
|
||||
"tabIndex": 10,
|
||||
"category": "Theme & Colors",
|
||||
"keywords": [
|
||||
"appearance",
|
||||
"around",
|
||||
"border",
|
||||
"colors",
|
||||
"edge",
|
||||
"look",
|
||||
"modal",
|
||||
"outline",
|
||||
"popup",
|
||||
"scheme",
|
||||
"shell",
|
||||
"style",
|
||||
"surface",
|
||||
"surfaces",
|
||||
"theme"
|
||||
],
|
||||
"description": "Outline around shell surfaces"
|
||||
},
|
||||
{
|
||||
"section": "popupTransparency",
|
||||
"label": "Surface Opacity",
|
||||
@@ -4654,72 +4634,6 @@
|
||||
"zenbrowser"
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "lockAppearance",
|
||||
"label": "Appearance",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"appearance",
|
||||
"clock",
|
||||
"date",
|
||||
"font",
|
||||
"lock",
|
||||
"lockscreen",
|
||||
"login",
|
||||
"password",
|
||||
"screen",
|
||||
"security",
|
||||
"time",
|
||||
"typography",
|
||||
"watch"
|
||||
],
|
||||
"icon": "palette",
|
||||
"description": "Font used for the clock and date on the lock screen"
|
||||
},
|
||||
{
|
||||
"section": "lockAuthSource",
|
||||
"label": "Authentication",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"authenticate",
|
||||
"authentication",
|
||||
"lock",
|
||||
"lockscreen",
|
||||
"login",
|
||||
"pam",
|
||||
"password",
|
||||
"screen",
|
||||
"security",
|
||||
"service",
|
||||
"source",
|
||||
"uses"
|
||||
],
|
||||
"icon": "key",
|
||||
"description": "Which PAM service the lock screen uses to authenticate"
|
||||
},
|
||||
{
|
||||
"section": "lockPamPath",
|
||||
"label": "Authentication Source",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"authenticate",
|
||||
"authentication",
|
||||
"lock",
|
||||
"lockscreen",
|
||||
"login",
|
||||
"pam",
|
||||
"password",
|
||||
"screen",
|
||||
"security",
|
||||
"service",
|
||||
"source",
|
||||
"uses"
|
||||
],
|
||||
"description": "Which PAM service the lock screen uses to authenticate"
|
||||
},
|
||||
{
|
||||
"section": "lockScreenVideoCycling",
|
||||
"label": "Automatic Cycling",
|
||||
@@ -4745,48 +4659,6 @@
|
||||
],
|
||||
"description": "Pick a different random video each time from the same folder"
|
||||
},
|
||||
{
|
||||
"section": "lockBehavior",
|
||||
"label": "Behavior",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"behavior",
|
||||
"bind",
|
||||
"dbus",
|
||||
"disable",
|
||||
"external",
|
||||
"integration",
|
||||
"lock",
|
||||
"lockscreen",
|
||||
"login",
|
||||
"loginctl",
|
||||
"password",
|
||||
"screen",
|
||||
"security",
|
||||
"signals"
|
||||
],
|
||||
"icon": "lock",
|
||||
"description": "Bind lock screen to dbus signals from loginctl. Disable if using an external lock screen"
|
||||
},
|
||||
{
|
||||
"section": "lockDisplay",
|
||||
"label": "Display Assignment",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"assignment",
|
||||
"display",
|
||||
"lock",
|
||||
"login",
|
||||
"monitor",
|
||||
"output",
|
||||
"password",
|
||||
"screen",
|
||||
"security"
|
||||
],
|
||||
"icon": "monitor"
|
||||
},
|
||||
{
|
||||
"section": "lockScreenVideoEnabled",
|
||||
"label": "Enable Video Screensaver",
|
||||
@@ -4821,14 +4693,10 @@
|
||||
"fprint",
|
||||
"lock",
|
||||
"login",
|
||||
"managed",
|
||||
"password",
|
||||
"primary",
|
||||
"screen",
|
||||
"security",
|
||||
"source"
|
||||
],
|
||||
"description": "Managed by the primary PAM source."
|
||||
"security"
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "loginctlLockIntegration",
|
||||
@@ -4866,41 +4734,12 @@
|
||||
"key",
|
||||
"lock",
|
||||
"login",
|
||||
"managed",
|
||||
"password",
|
||||
"primary",
|
||||
"screen",
|
||||
"security",
|
||||
"source",
|
||||
"u2f",
|
||||
"yubikey"
|
||||
],
|
||||
"description": "Managed by the primary PAM source."
|
||||
},
|
||||
{
|
||||
"section": "lockLayout",
|
||||
"label": "Layout",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"actions",
|
||||
"appear",
|
||||
"field",
|
||||
"hidden",
|
||||
"layout",
|
||||
"lock",
|
||||
"login",
|
||||
"password",
|
||||
"power",
|
||||
"pressed",
|
||||
"reboot",
|
||||
"screen",
|
||||
"security",
|
||||
"shutdown",
|
||||
"soon"
|
||||
],
|
||||
"icon": "lock",
|
||||
"description": "If the field is hidden, it will appear as soon as a key is pressed."
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "_tab_11",
|
||||
@@ -4917,6 +4756,97 @@
|
||||
],
|
||||
"icon": "lock"
|
||||
},
|
||||
{
|
||||
"section": "lockAppearance",
|
||||
"label": "Lock Screen Appearance",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"appearance",
|
||||
"clock",
|
||||
"date",
|
||||
"font",
|
||||
"lock",
|
||||
"lockscreen",
|
||||
"login",
|
||||
"password",
|
||||
"screen",
|
||||
"security",
|
||||
"time",
|
||||
"typography",
|
||||
"watch"
|
||||
],
|
||||
"icon": "palette",
|
||||
"description": "Font used for the clock and date on the lock screen"
|
||||
},
|
||||
{
|
||||
"section": "lockDisplay",
|
||||
"label": "Lock Screen Display",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"display",
|
||||
"lock",
|
||||
"lockscreen",
|
||||
"login",
|
||||
"monitor",
|
||||
"output",
|
||||
"password",
|
||||
"screen",
|
||||
"security"
|
||||
],
|
||||
"icon": "monitor"
|
||||
},
|
||||
{
|
||||
"section": "lockBehavior",
|
||||
"label": "Lock Screen behaviour",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"behaviour",
|
||||
"bind",
|
||||
"dbus",
|
||||
"disable",
|
||||
"external",
|
||||
"integration",
|
||||
"lock",
|
||||
"lockscreen",
|
||||
"login",
|
||||
"loginctl",
|
||||
"password",
|
||||
"screen",
|
||||
"security",
|
||||
"signals"
|
||||
],
|
||||
"icon": "lock",
|
||||
"description": "Bind lock screen to dbus signals from loginctl. Disable if using an external lock screen"
|
||||
},
|
||||
{
|
||||
"section": "lockLayout",
|
||||
"label": "Lock Screen layout",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"actions",
|
||||
"appear",
|
||||
"field",
|
||||
"hidden",
|
||||
"layout",
|
||||
"lock",
|
||||
"lockscreen",
|
||||
"login",
|
||||
"password",
|
||||
"power",
|
||||
"pressed",
|
||||
"reboot",
|
||||
"screen",
|
||||
"security",
|
||||
"shutdown",
|
||||
"soon"
|
||||
],
|
||||
"icon": "lock",
|
||||
"description": "If the field is hidden, it will appear as soon as a key is pressed."
|
||||
},
|
||||
{
|
||||
"section": "lockAtStartup",
|
||||
"label": "Lock at startup",
|
||||
@@ -5040,29 +4970,6 @@
|
||||
],
|
||||
"description": "Turn off all displays immediately when the lock screen activates"
|
||||
},
|
||||
{
|
||||
"section": "lockU2fPamPath",
|
||||
"label": "Security Key PAM Source",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"auto",
|
||||
"bundled",
|
||||
"installed",
|
||||
"key",
|
||||
"lock",
|
||||
"login",
|
||||
"pam",
|
||||
"password",
|
||||
"screen",
|
||||
"security",
|
||||
"service",
|
||||
"source",
|
||||
"u2f",
|
||||
"uses"
|
||||
],
|
||||
"description": "Auto uses an installed or bundled key-only service."
|
||||
},
|
||||
{
|
||||
"section": "u2fMode",
|
||||
"label": "Security key mode",
|
||||
@@ -5217,25 +5124,6 @@
|
||||
"time"
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "lockPamExternallyManaged",
|
||||
"label": "Use system PAM authentication",
|
||||
"tabIndex": 11,
|
||||
"category": "Lock Screen",
|
||||
"keywords": [
|
||||
"authentication",
|
||||
"external",
|
||||
"lock",
|
||||
"login",
|
||||
"managed",
|
||||
"pam",
|
||||
"password",
|
||||
"policy",
|
||||
"screen",
|
||||
"security",
|
||||
"system"
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "videoScreensaver",
|
||||
"label": "Video Screensaver",
|
||||
@@ -6125,31 +6013,6 @@
|
||||
],
|
||||
"description": "Show notification popups only on the currently focused monitor"
|
||||
},
|
||||
{
|
||||
"section": "notificationForegroundLayers",
|
||||
"label": "Foreground Layers",
|
||||
"tabIndex": 17,
|
||||
"category": "Notifications",
|
||||
"keywords": [
|
||||
"alert",
|
||||
"alerts",
|
||||
"blur",
|
||||
"cards",
|
||||
"contrast",
|
||||
"foreground",
|
||||
"glass",
|
||||
"layers",
|
||||
"messages",
|
||||
"notif",
|
||||
"notification",
|
||||
"notifications",
|
||||
"show",
|
||||
"surface",
|
||||
"surfaces",
|
||||
"toast"
|
||||
],
|
||||
"description": "Show foreground surfaces on notification cards"
|
||||
},
|
||||
{
|
||||
"section": "notificationHistoryMaxAgeDays",
|
||||
"label": "History Retention",
|
||||
@@ -7906,47 +7769,6 @@
|
||||
],
|
||||
"description": "Change the locale used for date and time formatting, independent of the interface language."
|
||||
},
|
||||
{
|
||||
"section": "greeterAppearance",
|
||||
"label": "Appearance",
|
||||
"tabIndex": 31,
|
||||
"category": "Greeter",
|
||||
"keywords": [
|
||||
"appearance",
|
||||
"display manager",
|
||||
"font",
|
||||
"greetd",
|
||||
"greeter",
|
||||
"login",
|
||||
"screen",
|
||||
"typography"
|
||||
],
|
||||
"icon": "palette",
|
||||
"description": "Font used on the login screen"
|
||||
},
|
||||
{
|
||||
"section": "greeterAuth",
|
||||
"label": "Authentication",
|
||||
"tabIndex": 31,
|
||||
"category": "Greeter",
|
||||
"keywords": [
|
||||
"auth",
|
||||
"authentication",
|
||||
"block",
|
||||
"display manager",
|
||||
"external",
|
||||
"greetd",
|
||||
"greeter",
|
||||
"login",
|
||||
"managed",
|
||||
"pam",
|
||||
"removes",
|
||||
"stops",
|
||||
"writing"
|
||||
],
|
||||
"icon": "fingerprint",
|
||||
"description": "DMS removes its managed block from /etc/pam.d/greetd and stops writing to it"
|
||||
},
|
||||
{
|
||||
"section": "greeterAutoLogin",
|
||||
"label": "Auto-login on startup",
|
||||
@@ -7980,25 +7802,6 @@
|
||||
],
|
||||
"description": "Skip the greeter password after boot until you sign out. Lock screen unlock is unchanged. Takes effect on the next reboot after sync."
|
||||
},
|
||||
{
|
||||
"section": "greeterBehavior",
|
||||
"label": "Behavior",
|
||||
"tabIndex": 31,
|
||||
"category": "Greeter",
|
||||
"keywords": [
|
||||
"behavior",
|
||||
"display manager",
|
||||
"greetd",
|
||||
"greeter",
|
||||
"last",
|
||||
"login",
|
||||
"remember",
|
||||
"select",
|
||||
"session"
|
||||
],
|
||||
"icon": "history",
|
||||
"description": "Pre-select the last used session on the greeter"
|
||||
},
|
||||
{
|
||||
"section": "greeterLockDateFormat",
|
||||
"label": "Date Format",
|
||||
@@ -8076,6 +7879,57 @@
|
||||
],
|
||||
"icon": "login"
|
||||
},
|
||||
{
|
||||
"section": "greeterAppearance",
|
||||
"label": "Greeter Appearance",
|
||||
"tabIndex": 31,
|
||||
"category": "Greeter",
|
||||
"keywords": [
|
||||
"appearance",
|
||||
"display manager",
|
||||
"font",
|
||||
"greetd",
|
||||
"greeter",
|
||||
"login",
|
||||
"screen",
|
||||
"typography"
|
||||
],
|
||||
"icon": "palette",
|
||||
"description": "Font used on the login screen"
|
||||
},
|
||||
{
|
||||
"section": "greeterBehavior",
|
||||
"label": "Greeter Behavior",
|
||||
"tabIndex": 31,
|
||||
"category": "Greeter",
|
||||
"keywords": [
|
||||
"behavior",
|
||||
"display manager",
|
||||
"greetd",
|
||||
"greeter",
|
||||
"last",
|
||||
"login",
|
||||
"remember",
|
||||
"select",
|
||||
"session"
|
||||
],
|
||||
"icon": "history",
|
||||
"description": "Pre-select the last used session on the greeter"
|
||||
},
|
||||
{
|
||||
"section": "greeterStatus",
|
||||
"label": "Greeter Status",
|
||||
"tabIndex": 31,
|
||||
"category": "Greeter",
|
||||
"keywords": [
|
||||
"display manager",
|
||||
"greetd",
|
||||
"greeter",
|
||||
"login",
|
||||
"status"
|
||||
],
|
||||
"icon": "info"
|
||||
},
|
||||
{
|
||||
"section": "greeterFontFamily",
|
||||
"label": "Greeter font",
|
||||
@@ -8092,6 +7946,23 @@
|
||||
],
|
||||
"description": "Font used on the login screen"
|
||||
},
|
||||
{
|
||||
"section": "greeterAuth",
|
||||
"label": "Login Authentication",
|
||||
"tabIndex": 31,
|
||||
"category": "Greeter",
|
||||
"keywords": [
|
||||
"auth",
|
||||
"authentication",
|
||||
"display manager",
|
||||
"fingerprint",
|
||||
"fprintd",
|
||||
"greetd",
|
||||
"greeter",
|
||||
"login"
|
||||
],
|
||||
"icon": "fingerprint"
|
||||
},
|
||||
{
|
||||
"section": "greeterRememberLastSession",
|
||||
"label": "Remember last session",
|
||||
@@ -8128,43 +7999,6 @@
|
||||
],
|
||||
"description": "Pre-fill the last successful username on the greeter"
|
||||
},
|
||||
{
|
||||
"section": "greeterStatus",
|
||||
"label": "Status",
|
||||
"tabIndex": 31,
|
||||
"category": "Greeter",
|
||||
"keywords": [
|
||||
"display manager",
|
||||
"greetd",
|
||||
"greeter",
|
||||
"login",
|
||||
"status"
|
||||
],
|
||||
"icon": "info"
|
||||
},
|
||||
{
|
||||
"section": "greeterPamExternallyManaged",
|
||||
"label": "Use system PAM authentication",
|
||||
"tabIndex": 31,
|
||||
"category": "Greeter",
|
||||
"keywords": [
|
||||
"auth",
|
||||
"authentication",
|
||||
"block",
|
||||
"display manager",
|
||||
"external",
|
||||
"greetd",
|
||||
"greeter",
|
||||
"login",
|
||||
"managed",
|
||||
"pam",
|
||||
"removes",
|
||||
"stops",
|
||||
"system",
|
||||
"writing"
|
||||
],
|
||||
"description": "DMS removes its managed block from /etc/pam.d/greetd and stops writing to it"
|
||||
},
|
||||
{
|
||||
"section": "muxType",
|
||||
"label": "Multiplexer",
|
||||
|
||||
@@ -1528,7 +1528,7 @@
|
||||
{
|
||||
"term": "Apply Changes",
|
||||
"translation": "",
|
||||
"context": "validate and apply custom PAM authentication source",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
@@ -1836,7 +1836,14 @@
|
||||
{
|
||||
"term": "Authentication changes apply automatically.",
|
||||
"translation": "",
|
||||
"context": "greeter auth setting description",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Authentication changes apply automatically. Fingerprint-only login may not unlock Keyring.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
@@ -1906,7 +1913,7 @@
|
||||
{
|
||||
"term": "Auto",
|
||||
"translation": "",
|
||||
"context": "automatic PAM authentication source option | calendar backend option | theme category option",
|
||||
"context": "calendar backend option | theme category option",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
@@ -2274,6 +2281,13 @@
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Available.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Awaiting fingerprint authentication",
|
||||
"translation": "",
|
||||
@@ -4979,7 +4993,7 @@
|
||||
{
|
||||
"term": "Custom...",
|
||||
"translation": "",
|
||||
"context": "custom PAM authentication source option | date format option",
|
||||
"context": "date format option",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
@@ -6537,6 +6551,76 @@
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Enabled, but fingerprint availability could not be confirmed.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Enabled, but no fingerprint reader was detected.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Enabled, but no prints are enrolled yet. Authentication changes apply automatically once you enroll fingerprints.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Enabled, but no prints are enrolled yet. Enroll fingerprints and run Sync.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Enabled, but no registered security key was found yet. Authentication changes apply automatically once your key is registered or your U2F config is updated.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Enabled, but no registered security key was found yet. Register a key and run Sync.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Enabled, but security-key availability could not be confirmed.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Enabled. PAM already provides fingerprint auth.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Enabled. PAM already provides security-key auth.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Enabled. PAM provides fingerprint auth, but no prints are enrolled yet.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Enabling WiFi...",
|
||||
"translation": "",
|
||||
@@ -7716,7 +7800,7 @@
|
||||
{
|
||||
"term": "Fingerprint availability could not be confirmed.",
|
||||
"translation": "",
|
||||
"context": "fingerprint setting status",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
@@ -7737,14 +7821,14 @@
|
||||
{
|
||||
"term": "Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and enroll later.",
|
||||
"translation": "",
|
||||
"context": "lock screen fingerprint setting",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and run Sync later.",
|
||||
"translation": "",
|
||||
"context": "greeter fingerprint login setting",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
@@ -12308,7 +12392,7 @@
|
||||
{
|
||||
"term": "No fingerprint reader detected.",
|
||||
"translation": "",
|
||||
"context": "fingerprint setting status",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
@@ -12749,28 +12833,28 @@
|
||||
{
|
||||
"term": "Not available — install fprintd and pam_fprintd, or configure greetd PAM.",
|
||||
"translation": "",
|
||||
"context": "greeter fingerprint login setting",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Not available — install fprintd and pam_fprintd.",
|
||||
"translation": "",
|
||||
"context": "lock screen fingerprint setting",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Not available — install or configure pam_u2f, or configure greetd PAM.",
|
||||
"translation": "",
|
||||
"context": "greeter security key login setting",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Not available — install or configure pam_u2f.",
|
||||
"translation": "",
|
||||
"context": "lock screen security key setting",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
@@ -13082,6 +13166,13 @@
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Only affects DMS-managed PAM. If greetd already includes pam_fprintd, fingerprint stays enabled.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Only on Battery",
|
||||
"translation": "",
|
||||
@@ -13512,14 +13603,35 @@
|
||||
{
|
||||
"term": "PAM already provides fingerprint auth. Enable this to show it at login.",
|
||||
"translation": "",
|
||||
"context": "greeter fingerprint login setting",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "PAM already provides security-key auth. Enable this to show it at login.",
|
||||
"translation": "",
|
||||
"context": "greeter security key login setting",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "PAM provides fingerprint auth, but availability could not be confirmed.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "PAM provides fingerprint auth, but no prints are enrolled yet.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "PAM provides fingerprint auth, but no reader was detected.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
@@ -16116,14 +16228,14 @@
|
||||
{
|
||||
"term": "Security-key availability could not be confirmed.",
|
||||
"translation": "",
|
||||
"context": "security key setting status",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Security-key support was detected, but no registered key was found yet. You can enable this now and register one later.",
|
||||
"translation": "",
|
||||
"context": "security key setting status",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
@@ -19875,7 +19987,7 @@
|
||||
{
|
||||
"term": "Use fingerprint authentication for the lock screen.",
|
||||
"translation": "",
|
||||
"context": "lock screen fingerprint setting",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ for n in $refs; do
|
||||
gh issue comment "$n" --repo "$REPO" --body "$(cat <<EOF
|
||||
:package: A fix referencing this issue shipped in [**${TAG}**](${RELEASE_URL}).
|
||||
|
||||
A New Release has been deployed! Please retest when you get a chance. If it's resolved, this issue can be closed. ~ Cheers, the DMS Team!
|
||||
Please retest on the new release when you get a chance — if it's resolved, this issue can be closed. Thanks!
|
||||
EOF
|
||||
)"
|
||||
echo "notified #${n} — ${title}"
|
||||
|
||||
@@ -20,7 +20,6 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
BOT_RE = re.compile(r"\[bot\]$|^github-actions$|^dependabot$", re.I)
|
||||
# default blog-table exclusions
|
||||
@@ -312,13 +311,6 @@ def main():
|
||||
help="github format: omit heading and Full Changelog footer")
|
||||
ap.add_argument("--no-api", action="store_true",
|
||||
help="skip GitHub API, use git data only (degraded attribution)")
|
||||
ap.add_argument("-o", "--output", default=None, metavar="PATH",
|
||||
help="write the output to PATH instead of stdout (parent dirs "
|
||||
"are created)")
|
||||
ap.add_argument("--version", default=None, metavar="X.Y.Z",
|
||||
help="release version being cut; if set and -o/--output isn't, "
|
||||
"writes to ~/Documents/dms-vX.Y.Z-changelog.md instead of "
|
||||
"stdout")
|
||||
args = ap.parse_args()
|
||||
|
||||
repo = args.repo
|
||||
@@ -337,25 +329,16 @@ def main():
|
||||
drop = {x.lower() for x in excludes if x}
|
||||
if args.format == "blog":
|
||||
# blog excludes from tables only; fixes list keeps everyone
|
||||
text = format_blog(repo, entries, args.range, exclude=frozenset(drop))
|
||||
print(format_blog(repo, entries, args.range, exclude=drop))
|
||||
return 0
|
||||
if drop:
|
||||
entries = [e for e in entries
|
||||
if (e["login"] or "").lower() not in drop
|
||||
and e["author_name"].lower() not in drop]
|
||||
if args.format == "github":
|
||||
print(format_github(repo, entries, args.range, bare=args.bare))
|
||||
else:
|
||||
if drop:
|
||||
entries = [e for e in entries
|
||||
if (e["login"] or "").lower() not in drop
|
||||
and e["author_name"].lower() not in drop]
|
||||
if args.format == "github":
|
||||
text = format_github(repo, entries, args.range, bare=args.bare)
|
||||
else:
|
||||
text = format_checklist(entries)
|
||||
|
||||
out_target = args.output or (f"~/Documents/dms-v{args.version}-changelog.md" if args.version else None)
|
||||
if out_target:
|
||||
out_path = Path(out_target).expanduser()
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(text + "\n")
|
||||
print(f"written to {out_path}", file=sys.stderr)
|
||||
else:
|
||||
print(text)
|
||||
print(format_checklist(entries))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user