From 9cf2ca71964fd4ea900f886b3aff13a56a9a4b1b Mon Sep 17 00:00:00 2001 From: bbedward Date: Thu, 9 Jul 2026 15:06:49 -0400 Subject: [PATCH] auth: add some more intelligent pam config resolution for lock screen and greeter related #2789 port: 1.5 --- core/cmd/dms/commands_auth.go | 22 ++ core/cmd/dms/main.go | 2 +- core/cmd/dms/main_distro.go | 2 +- core/internal/pam/pam.go | 216 ++++++++++++++---- core/internal/pam/pam_test.go | 213 ++++++++++++++++- .../DankDash/Overview/CalendarEventDetail.qml | 23 +- quickshell/Modules/Lock/Pam.qml | 52 ++++- 7 files changed, 474 insertions(+), 56 deletions(-) diff --git a/core/cmd/dms/commands_auth.go b/core/cmd/dms/commands_auth.go index 308d03be1..aee7a3354 100644 --- a/core/cmd/dms/commands_auth.go +++ b/core/cmd/dms/commands_auth.go @@ -35,9 +35,31 @@ var authSyncCmd = &cobra.Command{ }, } +var authResolveLockCmd = &cobra.Command{ + Use: "resolve-lock", + Short: "Generate the lock-screen PAM config from the system auth stack", + Long: "Resolve the distribution's PAM auth stack into a self-contained lock-screen config under the user state directory.\n" + + "Runs unprivileged (reads /etc/pam.d, writes to the user's state dir) and is used by the shell as a fallback when /etc/pam.d/dankshell is not managed.\n" + + "Prints the path of the generated file.", + Run: func(cmd *cobra.Command, args []string) { + quiet, _ := cmd.Flags().GetBool("quiet") + logFunc := func(msg string) { + if !quiet { + fmt.Println(msg) + } + } + path, err := sharedpam.WriteUserLockscreenPamConfig(logFunc) + if err != nil { + log.Fatalf("Error resolving lock-screen PAM config: %v", err) + } + fmt.Println(path) + }, +} + 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") } func syncAuth(nonInteractive bool) error { diff --git a/core/cmd/dms/main.go b/core/cmd/dms/main.go index 3285f04e4..d738ff969 100644 --- a/core/cmd/dms/main.go +++ b/core/cmd/dms/main.go @@ -20,7 +20,7 @@ func init() { runCmd.Flags().MarkHidden("daemon-child") greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd) - authCmd.AddCommand(authSyncCmd) + authCmd.AddCommand(authSyncCmd, authResolveLockCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) updateCmd.AddCommand(updateCheckCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) diff --git a/core/cmd/dms/main_distro.go b/core/cmd/dms/main_distro.go index d2ea33826..a28d9a15f 100644 --- a/core/cmd/dms/main_distro.go +++ b/core/cmd/dms/main_distro.go @@ -20,7 +20,7 @@ func init() { runCmd.Flags().MarkHidden("daemon-child") greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd) - authCmd.AddCommand(authSyncCmd) + authCmd.AddCommand(authSyncCmd, authResolveLockCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) rootCmd.AddCommand(getCommonCommands()...) diff --git a/core/internal/pam/pam.go b/core/internal/pam/pam.go index 9759542ff..e468647fb 100644 --- a/core/internal/pam/pam.go +++ b/core/internal/pam/pam.go @@ -12,6 +12,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" ) const ( @@ -32,6 +33,32 @@ const ( DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f" ) +// lockscreenPamEntryCandidates are the /etc/pam.d entry-point services tried in +// order. "login" is first so systems that ship it behave exactly as before; the +// rest cover distros (or minimal installs) with no /etc/pam.d/login. +// lockscreenPamBaseDirs mirrors libpam's search order: /etc overrides, then the +// vendor dir (/usr/lib) and the stateless-distro default (/usr/share). +var lockscreenPamBaseDirs = []string{"/etc/pam.d", "/usr/lib/pam.d", "/usr/share/pam.d"} + +// Standalone auth+account services, most universal first. login exists almost +// everywhere (util-linux); system-* cover Fedora/Arch/Gentoo/SUSE-Leap. +var lockscreenPamEntryCandidates = []string{ + "login", + "system-auth", + "system-login", + "system-local-login", +} + +// Fallback for distros with no standalone login service, only shared building +// blocks: openSUSE/Debian (common-*), Alpine/postmarketOS (base-*). +var lockscreenPamSharedIncludePairs = []struct { + auth string + account string +}{ + {auth: "common-auth", account: "common-account"}, + {auth: "base-auth", account: "base-account"}, +} + var includedPamAuthFiles = []string{ "system-auth", "common-auth", @@ -75,10 +102,50 @@ type lockscreenPamIncludeDirective struct { } type lockscreenPamResolver struct { - pamDir string + baseDirs []string readFile func(string) ([]byte, error) } +// locate resolves a service/include name across baseDirs (libpam vendor-dir +// fallback). Targets may not escape the base dirs. +func (r lockscreenPamResolver) locate(target string) (string, error) { + target = strings.TrimSpace(target) + if target == "" { + return "", fmt.Errorf("empty PAM include target") + } + + if filepath.IsAbs(target) { + clean := filepath.Clean(target) + for _, dir := range r.baseDirs { + if filepath.Dir(clean) == filepath.Clean(dir) { + return clean, nil + } + } + return "", fmt.Errorf("unsupported PAM include outside PAM dirs: %s", target) + } + + clean := filepath.Clean(target) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("invalid PAM include target: %s", target) + } + + var firstErr error + for _, dir := range r.baseDirs { + path := filepath.Join(filepath.Clean(dir), clean) + if _, err := r.readFile(path); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + return path, nil + } + if firstErr == nil { + firstErr = os.ErrNotExist + } + return "", firstErr +} + func defaultSyncDeps() syncDeps { return syncDeps{ pamDir: "/etc/pam.d", @@ -375,32 +442,10 @@ func parseLockscreenPamIncludeDirective(trimmed string, inheritedFilter string) return lockscreenPamIncludeDirective{}, false } -func resolveLockscreenPamIncludePath(pamDir, target string) (string, error) { - if strings.TrimSpace(target) == "" { - return "", fmt.Errorf("empty PAM include target") - } - - cleanPamDir := filepath.Clean(pamDir) - if filepath.IsAbs(target) { - cleanTarget := filepath.Clean(target) - if filepath.Dir(cleanTarget) != cleanPamDir { - return "", fmt.Errorf("unsupported PAM include outside %s: %s", cleanPamDir, target) - } - return cleanTarget, nil - } - - cleanTarget := filepath.Clean(target) - if cleanTarget == "." || cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(os.PathSeparator)) { - return "", fmt.Errorf("invalid PAM include target: %s", target) - } - - return filepath.Join(cleanPamDir, cleanTarget), nil -} - func (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) { - path, err := resolveLockscreenPamIncludePath(r.pamDir, serviceName) + path, err := r.locate(serviceName) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read PAM file %s: %w", serviceName, err) } for _, seen := range stack { @@ -458,29 +503,73 @@ func (r lockscreenPamResolver) resolveService(serviceName string, filterType str return resolved, nil } -func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]byte, error)) (string, error) { - resolver := lockscreenPamResolver{ - pamDir: pamDir, - readFile: readFile, - } - - resolvedLines, err := resolver.resolveService("login", "", nil) - if err != nil { - return "", err - } - if len(resolvedLines) == 0 { - return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login")) - } - - hasAuth := false - for _, line := range resolvedLines { +func resolvedLinesHaveAuth(lines []string) bool { + for _, line := range lines { if pamDirectiveType(strings.TrimSpace(line)) == "auth" { - hasAuth = true - break + return true } } - if !hasAuth { - return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login")) + return false +} + +func (r lockscreenPamResolver) resolveLines() ([]string, error) { + var lastErr error + + // Standalone login-like services: an existing one is authoritative. + for _, service := range lockscreenPamEntryCandidates { + if _, err := r.locate(service); err != nil { + lastErr = err + continue + } + lines, err := r.resolveService(service, "", nil) + if err != nil { + return nil, err + } + if !resolvedLinesHaveAuth(lines) { + return nil, fmt.Errorf("no auth directives remained after filtering %s", service) + } + return lines, nil + } + + // Shared building blocks for distros without a login service (openSUSE, + // Alpine): stitch the auth stanza to the account stanza when present. + for _, pair := range lockscreenPamSharedIncludePairs { + if _, err := r.locate(pair.auth); err != nil { + lastErr = err + continue + } + authLines, err := r.resolveService(pair.auth, "auth", nil) + if err != nil { + return nil, err + } + if !resolvedLinesHaveAuth(authLines) { + lastErr = fmt.Errorf("no auth directives remained after filtering %s", pair.auth) + continue + } + + resolved := append([]string{}, authLines...) + if _, err := r.locate(pair.account); err == nil { + acctLines, err := r.resolveService(pair.account, "account", nil) + if err != nil { + return nil, err + } + resolved = append(resolved, acctLines...) + } + return resolved, nil + } + + if lastErr != nil { + return nil, fmt.Errorf("no usable PAM auth service found: %w", lastErr) + } + return nil, fmt.Errorf("no usable PAM auth service found") +} + +func buildManagedLockscreenPamContent(baseDirs []string, readFile func(string) ([]byte, error)) (string, error) { + resolver := lockscreenPamResolver{baseDirs: baseDirs, readFile: readFile} + + resolvedLines, err := resolver.resolveLines() + if err != nil { + return "", err } var b strings.Builder @@ -494,6 +583,41 @@ func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]by return b.String(), nil } +const UserLockscreenPamService = "dankshell" + +func UserLockscreenPamDir() string { + return filepath.Join(utils.XDGStateHome(), "DankMaterialShell", "pam") +} + +// WriteUserLockscreenPamConfig resolves the distro's real auth stack into a +// self-contained lock-screen service under the user state dir, unprivileged +// (reads world-readable PAM dirs, writes the user's own state dir). Rewrites +// only on change to avoid inotify churn. Returns the written path. +func WriteUserLockscreenPamConfig(logFunc func(string)) (string, error) { + content, err := buildManagedLockscreenPamContent(lockscreenPamBaseDirs, os.ReadFile) + if err != nil { + return "", fmt.Errorf("failed to resolve system PAM auth stack: %w", err) + } + + dir := UserLockscreenPamDir() + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("failed to create %s: %w", dir, err) + } + + path := filepath.Join(dir, UserLockscreenPamService) + if existing, err := os.ReadFile(path); err == nil && string(existing) == content { + return path, nil + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + return "", fmt.Errorf("failed to write %s: %w", path, err) + } + + if logFunc != nil { + logFunc("✓ Wrote lock-screen PAM config " + path) + } + return path, nil +} + func buildManagedLockscreenU2FPamContent() string { var b strings.Builder b.WriteString("#%PAM-1.0\n") @@ -522,7 +646,7 @@ func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err) } - content, err := buildManagedLockscreenPamContent(deps.pamDir, deps.readFile) + content, err := buildManagedLockscreenPamContent([]string{deps.pamDir}, deps.readFile) if err != nil { return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err) } diff --git a/core/internal/pam/pam_test.go b/core/internal/pam/pam_test.go index 113bf108b..065123e86 100644 --- a/core/internal/pam/pam_test.go +++ b/core/internal/pam/pam_test.go @@ -246,6 +246,31 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) { "session optional pam_lastlog.so silent": 1, }, }, + { + name: "falls back to system-auth when login is absent", + files: map[string]string{ + "system-auth": "#%PAM-1.0\nauth sufficient pam_unix.so try_first_pass nullok\naccount required pam_unix.so\n", + }, + wantContains: []string{ + "auth sufficient pam_unix.so try_first_pass nullok", + "account required pam_unix.so", + }, + }, + { + name: "no usable service when none of the candidates exist", + files: map[string]string{ + "other": "#%PAM-1.0\nauth required pam_deny.so\n", + }, + wantErr: "no usable PAM auth service found", + }, + { + name: "existing login with bad include is authoritative and does not fall back", + files: map[string]string{ + "login": "#%PAM-1.0\nauth include missing-auth\n", + "system-auth": "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n", + }, + wantErr: "failed to read PAM file", + }, { name: "missing include fails", files: map[string]string{ @@ -281,7 +306,7 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) { env.writePamFile(t, name, content) } - content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile) + content, err := buildManagedLockscreenPamContent([]string{env.pamDir}, os.ReadFile) if tt.wantErr != "" { if err == nil { t.Fatalf("expected error containing %q, got nil", tt.wantErr) @@ -314,6 +339,192 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) { } } +// Real /etc/pam.d layouts of the non-Arch-shaped distros (#2789). +func TestBuildManagedLockscreenPamContent_DistroShapes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + files map[string]string + wantContains []string + wantNotContains []string + }{ + { + // openSUSE: `include` (not @include), common-auth symlinked to + // common-auth-pc (here just a plain file), bracketed securetty + // control, keyring modules, pam_sss. + name: "openSUSE include + common-auth + bracket control", + files: map[string]string{ + "login": "#%PAM-1.0\n" + + "auth requisite pam_nologin.so\n" + + "auth [user_unknown=ignore success=ok ignore=ignore auth_err=die default=bad] pam_securetty.so\n" + + "auth include common-auth\n" + + "account include common-account\n" + + "session required pam_loginuid.so\n" + + "session include common-session\n", + "common-auth": "auth required pam_env.so\n" + + "auth optional pam_gnome_keyring.so\n" + + "auth sufficient pam_unix.so try_first_pass\n" + + "auth required pam_sss.so use_first_pass\n", + "common-account": "account required pam_unix.so try_first_pass\naccount sufficient pam_localuser.so\n", + "common-session": "session optional pam_gnome_keyring.so auto_start\n", + }, + wantContains: []string{ + "pam_securetty.so", + "auth sufficient pam_unix.so try_first_pass", + "auth required pam_sss.so use_first_pass", + "account required pam_unix.so try_first_pass", + }, + }, + { + name: "openSUSE without login stitches common-auth and common-account", + files: map[string]string{ + "common-auth": "auth required pam_env.so\n" + + "auth optional pam_gnome_keyring.so\n" + + "auth sufficient pam_unix.so try_first_pass\n" + + "auth required pam_sss.so use_first_pass\n", + "common-account": "account required pam_unix.so try_first_pass\n" + + "account sufficient pam_localuser.so\n" + + "account required pam_sss.so use_first_pass\n", + }, + wantContains: []string{ + "auth sufficient pam_unix.so try_first_pass", + "auth required pam_sss.so use_first_pass", + "account required pam_unix.so try_first_pass", + "account required pam_sss.so use_first_pass", + }, + }, + { + name: "openSUSE with only common-auth resolves auth-only", + files: map[string]string{ + "common-auth": "auth sufficient pam_unix.so try_first_pass\nauth required pam_deny.so\n", + }, + wantContains: []string{"auth sufficient pam_unix.so try_first_pass"}, + wantNotContains: []string{ + "account", + }, + }, + { + name: "Debian @include common-auth and common-account", + files: map[string]string{ + "login": "#%PAM-1.0\n" + + "auth requisite pam_nologin.so\n" + + "@include common-auth\n" + + "@include common-account\n" + + "session required pam_loginuid.so\n" + + "@include common-session\n", + "common-auth": "auth\t[success=1 default=ignore]\tpam_unix.so nullok\n" + + "auth\trequisite\t\t\tpam_deny.so\n" + + "auth\trequired\t\t\tpam_permit.so\n", + "common-account": "account\t[success=1 new_authtok_reqd=done default=ignore]\tpam_unix.so\naccount\trequisite\t\t\tpam_deny.so\n", + "common-session": "session\t[default=1]\t\t\tpam_permit.so\n", + }, + wantContains: []string{ + "auth\t[success=1 default=ignore]\tpam_unix.so nullok", + "account\t[success=1 new_authtok_reqd=done default=ignore]\tpam_unix.so", + }, + }, + { + name: "NixOS flat login with absolute paths and dash directives", + files: map[string]string{ + "login": "#%PAM-1.0\n" + + "auth required /nix/store/abc-pam/lib/security/pam_unix.so likeauth nullok try_first_pass\n" + + "auth sufficient /nix/store/abc-pam-u2f/lib/security/pam_u2f.so\n" + + "-auth optional /nix/store/abc-kbd/lib/security/pam_gnome_keyring.so\n" + + "account required /nix/store/abc-pam/lib/security/pam_unix.so\n" + + "-session optional /nix/store/abc-sd/lib/security/pam_systemd.so\n", + }, + wantContains: []string{ + "auth required /nix/store/abc-pam/lib/security/pam_unix.so likeauth nullok try_first_pass", + "-auth optional /nix/store/abc-kbd/lib/security/pam_gnome_keyring.so", + "account required /nix/store/abc-pam/lib/security/pam_unix.so", + }, + wantNotContains: []string{"pam_u2f"}, + }, + { + name: "Gentoo deep include chain login->system-local-login->system-login->system-auth", + files: map[string]string{ + "login": "#%PAM-1.0\nauth\tinclude\t\tsystem-local-login\naccount\tinclude\t\tsystem-local-login\n", + "system-local-login": "auth\trequired\tpam_group.so\nauth\tinclude\t\tsystem-login\naccount\tinclude\t\tsystem-login\n", + "system-login": "auth\tinclude\t\tsystem-auth\naccount\tinclude\t\tsystem-auth\n", + "system-auth": "auth\trequired\tpam_env.so\nauth\tsufficient\tpam_unix.so try_first_pass likeauth nullok\nauth\trequired\tpam_deny.so\naccount\trequired\tpam_unix.so\n", + }, + wantContains: []string{ + "auth\trequired\tpam_group.so", + "auth\tsufficient\tpam_unix.so try_first_pass likeauth nullok", + "account\trequired\tpam_unix.so", + }, + }, + { + name: "no login, entry falls through to system-auth", + files: map[string]string{ + "system-auth": "#%PAM-1.0\n" + + "auth required pam_env.so\n" + + "auth sufficient pam_unix.so nullok\n" + + "auth sufficient pam_sss.so forward_pass\n" + + "auth required pam_deny.so\n" + + "account required pam_unix.so\n" + + "account [default=bad success=ok user_unknown=ignore] pam_sss.so\n", + }, + wantContains: []string{ + "auth sufficient pam_unix.so nullok", + "auth sufficient pam_sss.so forward_pass", + "account required pam_unix.so", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + for name, content := range tt.files { + env.writePamFile(t, name, content) + } + + content, err := buildManagedLockscreenPamContent([]string{env.pamDir}, os.ReadFile) + if err != nil { + t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err) + } + if !strings.Contains(content, "auth") { + t.Fatalf("resolved content has no auth line:\n%s", content) + } + for _, want := range tt.wantContains { + if !strings.Contains(content, want) { + t.Errorf("missing expected string %q in output:\n%s", want, content) + } + } + for _, notWant := range tt.wantNotContains { + if strings.Contains(content, notWant) { + t.Errorf("unexpected string %q found in output:\n%s", notWant, content) + } + } + }) + } +} + +func TestBuildManagedLockscreenPamContent_VendorDirFallback(t *testing.T) { + t.Parallel() + + // Stateless/vendored-PAM systems (Clear Linux) ship the stack in + // /usr/lib|share/pam.d with /etc/pam.d empty; includes resolve in that dir. + etcDir := t.TempDir() + vendorDir := t.TempDir() + writeTestFile(t, filepath.Join(vendorDir, "login"), "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n") + writeTestFile(t, filepath.Join(vendorDir, "system-auth"), "auth sufficient pam_unix.so nullok\naccount required pam_unix.so\n") + + content, err := buildManagedLockscreenPamContent([]string{etcDir, vendorDir}, os.ReadFile) + if err != nil { + t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err) + } + for _, want := range []string{"auth sufficient pam_unix.so nullok", "account required pam_unix.so"} { + if !strings.Contains(content, want) { + t.Errorf("missing %q in output:\n%s", want, content) + } + } +} + func TestSyncLockscreenPamConfigWithDeps(t *testing.T) { t.Parallel() diff --git a/quickshell/Modules/DankDash/Overview/CalendarEventDetail.qml b/quickshell/Modules/DankDash/Overview/CalendarEventDetail.qml index c3b1b8608..ae6fb66a5 100644 --- a/quickshell/Modules/DankDash/Overview/CalendarEventDetail.qml +++ b/quickshell/Modules/DankDash/Overview/CalendarEventDetail.qml @@ -18,10 +18,9 @@ Item { readonly property bool _descriptionIsHtml: /<[a-z][^>]*>/i.test((eventData && eventData.description) || "") - // _locationUrl makes the location row clickable: the location itself when - // it is a URL, the meeting link when the event has one (conference - // events carry placeholder locations like "Microsoft Teams Meeting"), - // otherwise a geo: search (RFC 5870) so addresses open in the maps app. + // _locationUrl makes the location row clickable: a URL location opens + // directly, conference placeholders open the meeting link, and anything + // else opens as a geo: search in the maps app. function _locationUrl() { const loc = ((eventData && eventData.location) || "").trim(); if (loc === "") @@ -249,7 +248,21 @@ Item { anchors.fill: parent enabled: root._locationUrl() !== "" cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: Qt.openUrlExternally(root._locationUrl()) + // Qt.openUrlExternally can't handle geo: URIs, so + // route those through the dankcal daemon's opener. + onClicked: { + const url = root._locationUrl(); + if (url.startsWith("geo:") && CalendarDankBackend.connected) { + CalendarDankBackend.sendRequest("system.openUri", { + "uri": url + }, response => { + if (response && response.error) + Qt.openUrlExternally(url); + }); + return; + } + Qt.openUrlExternally(url); + } } } } diff --git a/quickshell/Modules/Lock/Pam.qml b/quickshell/Modules/Lock/Pam.qml index 11ad99d76..66f621adf 100644 --- a/quickshell/Modules/Lock/Pam.qml +++ b/quickshell/Modules/Lock/Pam.qml @@ -111,14 +111,60 @@ Scope { printErrors: false } + // Fallback stack written by `dms auth resolve-lock` when no managed + // /etc/pam.d/dankshell exists. See #2789. + readonly property string userPamDir: Paths.strip(Paths.state) + "/pam" + + FileView { + id: userPamWatcher + + path: root.userPamDir + "/dankshell" + printErrors: false + } + + Process { + id: resolveUserPam + + command: ["dms", "auth", "resolve-lock", "--quiet"] + running: false + onExited: exitCode => { + if (exitCode === 0) + userPamWatcher.reload(); + } + } + + function ensureUserPamConfig(): void { + 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: dankshellConfigWatcher.loaded ? "dankshell" : "login" - configDirectory: (dankshellConfigWatcher.loaded || nixosMarker.loaded || root.runningFromNixStore) ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" + config: { + if (dankshellConfigWatcher.loaded) + return "dankshell"; + if (nixosMarker.loaded || root.runningFromNixStore) + return "login"; + if (userPamWatcher.loaded) + return "dankshell"; + return "login"; + } + configDirectory: { + 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"; + } onMessageChanged: { // collected by position, not text, so it works in any locale @@ -415,6 +461,8 @@ Scope { root.attemptInfoMessages = []; root.lockoutAnnouncedThisAttempt = false; root.resetAuthFlows(); + if (!dankshellConfigWatcher.loaded && !nixosMarker.loaded && !userPamWatcher.loaded) + ensureUserPamConfig(); fprint.checkAvail(); u2f.checkAvail(); }