;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2019, 2020 L  p R n  d n <guix@lprndn.info>
;;; Copyright © 2020 Ricardo Wurmus <rekado@elephly.net>
;;; Copyright © 2022 Maxim Cournoyer <maxim.cournoyer@gmail.com>
;;;
;;; This file is part of GNU Guix.
;;;
;;; GNU Guix is free software; you can redistribute it and/or modify it
;;; under the terms of the GNU General Public License as published by
;;; the Free Software Foundation; either version 3 of the License, or (at
;;; your option) any later version.
;;;
;;; GNU Guix is distributed in the hope that it will be useful, but
;;; WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;;; GNU General Public License for more details.
;;;
;;; You should have received a copy of the GNU General Public License
;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.

(define-module (gnu services lightdm)
  #:use-module (gnu artwork)
  #:use-module (gnu packages admin)
  #:use-module (gnu packages display-managers)
  #:use-module (gnu packages freedesktop)
  #:use-module (gnu packages gnome)
  #:use-module (gnu packages vnc)
  #:use-module (gnu packages xorg)
  #:use-module (gnu services configuration)
  #:use-module (gnu services dbus)
  #:use-module (gnu services desktop)
  #:use-module (gnu services shepherd)
  #:use-module (gnu services xorg)
  #:use-module (gnu services)
  #:use-module (gnu system pam)
  #:use-module (gnu system shadow)
  #:use-module (guix diagnostics)
  #:use-module (guix gexp)
  #:use-module (guix i18n)
  #:use-module (guix records)
  #:use-module (ice-9 format)
  #:use-module (ice-9 match)
  #:use-module (srfi srfi-1)
  #:use-module (srfi srfi-26)
  #:export (lightdm-seat-configuration
            lightdm-seat-configuration?
            lightdm-seat-configuration-name
            lightdm-seat-configuration-type
            lightdm-seat-configuration-user-session
            lightdm-seat-configuration-autologin-user
            lightdm-seat-configuration-greeter-session
            lightdm-seat-configuration-xserver-command
            lightdm-seat-configuration-session-wrapper
            lightdm-seat-configuration-extra-config

            lightdm-gtk-greeter-configuration
            lightdm-gtk-greeter-configuration?
            lightdm-gtk-greeter-configuration-lightdm-gtk-greeter
            lightdm-gtk-greeter-configuration-assets
            lightdm-gtk-greeter-configuration-theme-name
            lightdm-gtk-greeter-configuration-icon-theme-name
            lightdm-gtk-greeter-configuration-cursor-theme-name
            lightdm-gtk-greeter-configuration-allow-debug
            lightdm-gtk-greeter-configuration-background
            lightdm-gtk-greeter-configuration-a11y-states
            lightdm-gtk-greeter-configuration-reader
            lightdm-gtk-greeter-configuration-extra-config

            lightdm-configuration
            lightdm-configuration?
            lightdm-configuration-lightdm
            lightdm-configuration-allow-empty-passwords?
            lightdm-configuration-xorg-configuration
            lightdm-configuration-greeters
            lightdm-configuration-seats
            lightdm-configuration-xdmcp?
            lightdm-configuration-xdmcp-listen-address
            lightdm-configuration-vnc-server?
            lightdm-configuration-vnc-server-command
            lightdm-configuration-vnc-server-listen-address
            lightdm-configuration-vnc-server-port
            lightdm-configuration-extra-config

            lightdm-service-type))

;;;
;;; Greeters.
;;;

(define list-of-file-likes?
  (list-of file-like?))

(define %a11y-states '(contrast font keyboard reader))

(define (a11y-state? value)
  (memq value %a11y-states))

(define list-of-a11y-states?
  (list-of a11y-state?))

(define-maybe boolean)

(define (serialize-boolean name value)
  (define (strip-trailing-? name)
    ;; field? -> field
    (let ((str (symbol->string name)))
      (if (string-suffix? "?" str)
          (string-drop-right str 1)
          str)))
  (format #f "~a=~:[false~;true~]~%" (strip-trailing-? name) value))

(define-maybe file-like)

(define (serialize-file-like name value)
  #~(format #f "~a=~a~%" '#$name #$value))

(define (serialize-list-of-a11y-states name value)
  (format #f "~a=~a~%" name (string-join (map symbol->string value) ";")))

(define (serialize-string name value)
  (format #f "~a=~a~%" name value))

(define (serialize-number name value)
  (format #f "~a=~a~%" name value))

(define (serialize-list-of-strings _ value)
  (string-join value "\n"))

(define-configuration lightdm-gtk-greeter-configuration
  (lightdm-gtk-greeter
   (file-like lightdm-gtk-greeter)
   "The lightdm-gtk-greeter package to use."
   empty-serializer)
  (assets
   (list-of-file-likes (list adwaita-icon-theme
                             gnome-themes-extra
                             ;; FIXME: hicolor-icon-theme should be in the
                             ;; packages of the desktop templates.
                             hicolor-icon-theme))
   "The list of packages complementing the greeter, such as package providing
icon themes."
   empty-serializer)
  (theme-name
   (string "Adwaita")
   "The name of the theme to use.")
  (icon-theme-name
   (string "Adwaita")
   "The name of the icon theme to use.")
  (cursor-theme-name
   (string "Adwaita")
   "The name of the cursor theme to use.")
  (cursor-theme-size
   (number 16)
   "The size to use for the cursor theme.")
  (allow-debugging?
   maybe-boolean
   "Set to #t to enable debug log level.")
  (background
   (file-like (file-append %artwork-repository
                           "/backgrounds/guix-checkered-16-9.svg"))
   "The background image to use.")
  ;; FIXME: This should be enabled by default, but it currently doesn't work,
  ;; failing to connect to D-Bus, causing the login to fail.
  (at-spi-enabled?
   (boolean #f)
   "Enable accessibility support through the Assistive Technology Service
Provider Interface (AT-SPI).")
  (a11y-states
   (list-of-a11y-states %a11y-states)
   "The accessibility features to enable, given as list of symbols.")
  (reader
   maybe-file-like
   "The command to use to launch a screen reader.")
  (extra-config
   (list-of-strings '())
   "Extra configuration values to append to the LightDM GTK Greeter
configuration file."))

(define (strip-record-type-name-brackets name)
  "Remove the '<' and '>' brackets from NAME, a symbol."
  (let ((name (symbol->string name)))
    (if (and (string-prefix? "<" name)
             (string-suffix? ">" name))
        (string->symbol (string-drop (string-drop-right name 1) 1))
        (error "unexpected record type name" name))))

(define (config->name config)
  "Return the constructor name (a symbol) from CONFIG."
  (strip-record-type-name-brackets
   (record-type-name (struct-vtable config))))

(define (greeter-configuration->greeter-fields config)
  "Return the fields of CONFIG, a greeter configuration."
  (match config
    ;; Note: register any new greeter configuration here.
    ((? lightdm-gtk-greeter-configuration?)
     lightdm-gtk-greeter-configuration-fields)))

(define (greeter-configuration->packages config)
  "Return the list of greeter packages, including assets, used by CONFIG, a
greeter configuration."
  (match config
    ;; Note: register any new greeter configuration here.
    ((? lightdm-gtk-greeter-configuration?)
     (cons (lightdm-gtk-greeter-configuration-lightdm-gtk-greeter config)
           (lightdm-gtk-greeter-configuration-assets config)))))

;;; TODO: Implement directly in (gnu services configuration), perhaps by
;;; making the FIELDS argument optional.
(define (serialize-configuration* config)
  "Like `serialize-configuration', but not requiring to provide a FIELDS
argument."
  (define fields (greeter-configuration->greeter-fields config))
  (serialize-configuration config fields))

(define (greeter-configuration->conf-name config)
  "Return the file name of CONFIG, a greeter configuration."
  (format #f "~a.conf" (greeter-configuration->greeter-session config)))

(define (greeter-configuration->file config)
  "Serialize CONFIG into a file under the output directory, so that it can be
easily added to XDG_CONF_DIRS."
  (computed-file
   (greeter-configuration->conf-name config)
   #~(begin
       (call-with-output-file #$output
         (lambda (port)
           (format port (string-append
                         "[greeter]\n"
                         #$(serialize-configuration* config))))))))


;;;
;;; Seats.
;;;

(define seat-name? string?)

(define (serialize-seat-name _ value)
  (format #f "[Seat:~a]~%" value))

(define (seat-type? type)
  (memq type '(local xremote)))

(define (serialize-seat-type name value)
  (format #f "~a=~a~%" name value))

(define-maybe seat-type)

(define (greeter-session? value)
  (memq value '(lightdm-gtk-greeter)))

(define (serialize-greeter-session name value)
  (format #f "~a=~a~%" name value))

(define-maybe greeter-session)

(define-maybe string)

;;; Note: all the fields except for the seat name should be 'maybe's, since
;;; the real default value is set by the %lightdm-seat-default define later,
;;; and this avoids repeating ourselves in the serialized configuration file.
(define-configuration lightdm-seat-configuration
  (name
   seat-name
   "The name of the seat.  An asterisk (*) can be used in the name
to apply the seat configuration to all the seat names it matches.")
  (user-session
   maybe-string
   "The session to use by default.  The session name must be provided as a
lowercase string, such as @code{\"gnome\"}, @code{\"ratpoison\"}, etc.")
  (type
   (seat-type 'local)
   "The type of the seat, either the @code{local} or @code{xremote} symbol.")
  (autologin-user
   maybe-string
   "The username to automatically log in with by default.")
  (greeter-session
   (greeter-session 'lightdm-gtk-greeter)
   "The greeter session to use, specified as a symbol.  Currently, only
@code{lightdm-gtk-greeter} is supported.")
  ;; Note: xserver-command must be lazily computed, so that it can be
  ;; overridden via 'lightdm-configuration-xorg-configuration'.
  (xserver-command
   maybe-file-like
   "The Xorg server command to run.")
  (session-wrapper
   (file-like (xinitrc))
   "The xinitrc session wrapper to use.")
  (extra-config
   (list-of-strings '())
   "Extra configuration values to append to the seat configuration section."))

(define (greeter-session->greater-configuration-pred identifier)
  "Return the predicate to check if a configuration is of the type specifying
a greeter identified by IDENTIFIER."
  (match identifier
    ;; Note: register any new greeter identifier here.
    ('lightdm-gtk-greeter
     lightdm-gtk-greeter-configuration?)))

(define (greeter-configuration->greeter-session config)
  "Given CONFIG, a greeter configuration object, return its identifier,
a symbol."
  (let ((suffix "-configuration")
        (greeter-conf-name (config->name config)))
    (string->symbol (string-drop-right (symbol->string greeter-conf-name)
                                       (string-length suffix)))))

(define list-of-seat-configurations?
  (list-of lightdm-seat-configuration?))


;;;
;;; LightDM.
;;;

(define (greeter-configuration? config)
  (or (lightdm-gtk-greeter-configuration? config)
      ;; Note: register any new greeter configuration here.
      ))

(define (list-of-greeter-configurations? greeter-configs)
  (and ((list-of greeter-configuration?) greeter-configs)
       ;; Greeter configurations must also not be provided more than once.
       (let* ((types (map (compose record-type-name struct-vtable)
                          greeter-configs))
              (dupes (filter (lambda (type)
                               (< 1 (count (cut eq? type <>) types)))
                             types)))
         (unless (null? dupes)
           (leave (G_ "duplicate greeter configurations: ~a~%") dupes)))))

(define-configuration/no-serialization lightdm-configuration
  (lightdm
   (file-like lightdm)
   "The lightdm package to use.")
  (allow-empty-passwords?
   (boolean #f)
   "Whether users not having a password set can login.")
  (debug?
   (boolean #f)
   "Enable verbose output.")
  (xorg-configuration
   (xorg-configuration (xorg-configuration))
   "The default Xorg server configuration to use to generate the Xorg server
start script.  It can be refined per seat via the @code{xserver-command} of
the @code{<lightdm-seat-configuration>} record, if desired.")
  (greeters
   (list-of-greeter-configurations (list (lightdm-gtk-greeter-configuration)))
   "The LightDM greeter configurations specifying the greeters to use.")
  (seats
   (list-of-seat-configurations (list (lightdm-seat-configuration
                                       (name "*"))))
   "The seat configurations to use.  A LightDM seat is akin to a user.")
  (xdmcp?
   (boolean #f)
   "Whether a XDMCP server should listen on port UDP 177.")
  (xdmcp-listen-address
   maybe-string
   "The host or IP address the XDMCP server listens for incoming connections.
When unspecified, listen on for any hosts/IP addresses.")
  (vnc-server?
   (boolean #f)
   "Whether a VNC server is started.")
  (vnc-server-command
   (file-like (file-append tigervnc-server "bin/Xvnc"))
   "The Xvnc command to use for the VNC server, it's possible to provide extra
options not otherwise exposed along the command, for example to disable
security:
@lisp
(vnc-server-command
 (file-append tigervnc-server \"/bin/Xvnc\"
             \" -SecurityTypes None\" ))
@end lisp

Or to set a PasswordFile for the classic (unsecure) VncAuth mechanism:
@lisp
(vnc-server-command
 (file-append tigervnc-server \"/bin/Xvnc\"
             \" -PasswordFile /var/lib/lightdm/.vnc/passwd\"))
@end lisp
The password file should be manually created using the @command{vncpasswd}
command.

Note that LightDM will create new sessions for VNC users, which means they
need to authenticate in the same way as local users would.
")
  (vnc-server-listen-address
   maybe-string
   "The host or IP address the VNC server listens for incoming connections.
When unspecified, listen for any hosts/IP addresses.")
  (vnc-server-port
   (number 5900)
   "The TCP port the VNC server should listen to.")
  (extra-config
   (list-of-strings '())
   "Extra configuration values to append to the LightDM configuration file."))

(define (lightdm-configuration->greeters-config-dir config)
  "Return a directory containing all the serialized greeter configurations
from CONFIG, a <lightdm-configuration> object."
  (file-union "etc-lightdm"
              (append-map (lambda (g)
                            `((,(greeter-configuration->conf-name g)
                               ,(greeter-configuration->file g))))
                          (lightdm-configuration-greeters config))))

(define (lightdm-configuration->packages config)
  "Return all the greeter packages and their assets defined in CONFIG, a
<lightdm-configuration> object, as well as the lightdm package itself."
  (cons (lightdm-configuration-lightdm config)
        (append-map greeter-configuration->packages
                    (lightdm-configuration-greeters config))))

(define (validate-lightdm-configuration config)
  "Sanity check CONFIG, a <lightdm-configuration> record instance."
  ;; This is required to make inter-field validations, such as between the
  ;; seats and greeters.
  (let* ((seats (lightdm-configuration-seats config))
         (greeter-sessions (delete-duplicates
                            (map lightdm-seat-configuration-greeter-session
                                 seats)
                            eq?))
         (greeter-configurations (lightdm-configuration-greeters config))
         (missing-greeters
          (filter-map
           (lambda (id)
             (define pred (greeter-session->greater-configuration-pred id))
             (if (find pred greeter-configurations)
                 #f                     ;happy path
                 id))
           greeter-sessions)))
    (unless (null? missing-greeters)
      (leave (G_ "no greeter configured for seat greeter sessions: ~a~%")
             missing-greeters))))

(define (lightdm-configuration-file config)
  (match-record config <lightdm-configuration>
    (xorg-configuration seats
     xdmcp? xdmcp-listen-address
     vnc-server? vnc-server-command vnc-server-listen-address vnc-server-port
     extra-config)
    (apply
     mixed-text-file
     "lightdm.conf" "
#
# General configuration
#
[LightDM]
greeter-user=lightdm
sessions-directory=/run/current-system/profile/share/xsessions\
:/run/current-system/profile/share/wayland-sessions
remote-sessions-directory=/run/current-system/profile/share/remote-sessions
"
     #~(string-join '#$extra-config "\n")
     "
#
# XDMCP Server configuration
#
[XDMCPServer]
enabled=" (if xdmcp? "true" "false") "\n"
(if (maybe-value-set? xdmcp-listen-address)
    (format #f "xdmcp-listen-address=~a" xdmcp-listen-address)
    "") "

#
# VNC Server configuration
#
[VNCServer]
enabled=" (if vnc-server? "true" "false") "
command=" vnc-server-command "
port=" (number->string vnc-server-port) "\n"
(if (maybe-value-set? vnc-server-listen-address)
    (format #f "vnc-server-listen-address=~a" vnc-server-listen-address)
    "") "

#
# Seat configuration.
#
"
     (map (lambda (seat)
            ;; This complication exists to propagate a default value for
            ;; the 'xserver-command' field of the seats.  Having a
            ;; 'xorg-configuration' field at the root of the
            ;; lightdm-configuration enables the use of
            ;; 'set-xorg-configuration' and can be more convenient.
            (let ((seat* (if (maybe-value-set?
                              (lightdm-seat-configuration-xserver-command seat))
                             seat
                             (lightdm-seat-configuration
                              (inherit seat)
                              (xserver-command (xorg-start-command
                                                xorg-configuration))))))
              (serialize-configuration seat*
                                       lightdm-seat-configuration-fields)))
          seats))))

(define %lightdm-accounts
  (list (user-group (name "lightdm") (system? #t))
        (user-account
         (name "lightdm")
         (group "lightdm")
         (system? #t)
         (comment "LightDM user")
         (home-directory "/var/lib/lightdm")
         (shell (file-append shadow "/sbin/nologin")))))

(define %lightdm-activation
  ;; Ensure /var/lib/lightdm is owned by the "lightdm" user.  Adapted from the
  ;; %gdm-activation.
  (with-imported-modules '((guix build utils))
    #~(begin
        (use-modules (guix build utils))

        (define (ensure-ownership directory)
          (let* ((lightdm (getpwnam "lightdm"))
                 (uid (passwd:uid lightdm))
                 (gid (passwd:gid lightdm))
                 (st  (stat directory #f)))
            ;; Recurse into directory only if it has wrong ownership.
            (when (and st
                       (or (not (= uid (stat:uid st)))
                           (not (= gid (stat:gid st)))))
              (for-each (lambda (file)
                          (chown file uid gid))
                        (find-files "directory"
                                    #:directories? #t)))))

        (when (not (stat "/var/lib/lightdm-data" #f))
          (mkdir-p "/var/lib/lightdm-data"))
        (for-each ensure-ownership
                  '("/var/lib/lightdm"
                    "/var/lib/lightdm-data")))))

(define (lightdm-pam-service config)
  "Return a PAM service for @command{lightdm}."
  (unix-pam-service "lightdm"
                    #:login-uid? #t
                    #:allow-empty-passwords?
                    (lightdm-configuration-allow-empty-passwords? config)))

(define (lightdm-greeter-pam-service)
  "Return a PAM service for @command{lightdm-greeter}."
  (pam-service
   (name "lightdm-greeter")
   (auth (list
          ;; Load environment from /etc/environment and ~/.pam_environment.
          (pam-entry (control "required") (module "pam_env.so"))
          ;; Always let the greeter start without authentication.
          (pam-entry (control "required") (module "pam_permit.so"))))
   ;; No action required for account management
   (account (list (pam-entry (control "required") (module "pam_permit.so"))))
   ;; Prohibit changing password.
   (password (list (pam-entry (control "required") (module "pam_deny.so"))))
   ;; Setup session.
   (session (list (pam-entry (control "required") (module "pam_unix.so"))))))

(define (lightdm-autologin-pam-service)
  "Return a PAM service for @command{lightdm-autologin}}."
  (pam-service
   (name "lightdm-autologin")
   (auth
    (list
     ;; Block login if user is globally disabled.
     (pam-entry (control "required") (module "pam_nologin.so"))
     (pam-entry (control "required") (module "pam_succeed_if.so")
                (arguments (list "uid >= 1000")))
     ;; Allow access without authentication.
     (pam-entry (control "required") (module "pam_permit.so"))))
   ;; Stop autologin if account requires action.
   (account (list (pam-entry (control "required") (module "pam_unix.so"))))
   ;; Prohibit changing password.
   (password (list (pam-entry (control "required") (module "pam_deny.so"))))
   ;; Setup session.
   (session (list (pam-entry (control "required") (module "pam_unix.so"))))))

(define (lightdm-pam-services config)
  (list (lightdm-pam-service config)
        (lightdm-greeter-pam-service)
        (lightdm-autologin-pam-service)))

(define (lightdm-shepherd-service config)
  "Return a <lightdm-service> for LightDM using CONFIG."

  (validate-lightdm-configuration config)

  (define lightdm-command
    #~(list #$(file-append (lightdm-configuration-lightdm config)
                           "/sbin/lightdm")
            #$@(if (lightdm-configuration-debug? config)
                   #~("--debug")
                   #~())
            "--config"
            #$(lightdm-configuration-file config)))

  (define lightdm-paths
    (let ((lightdm (lightdm-configuration-lightdm config)))
      #~(string-join
         '#$(map (lambda (dir)
                   (file-append lightdm dir))
                 '("/bin" "/sbin" "/libexec"))
         ":")))

  (define greeters-config-dir
    (lightdm-configuration->greeters-config-dir config))

  (define data-dirs
    ;; LightDM itself needs to be in XDG_DATA_DIRS for the accountsservice
    ;; interface it provides to be picked up.  The greeters must also be in
    ;; XDG_DATA_DIRS to be found.
    (let ((packages (lightdm-configuration->packages config)))
      #~(string-join '#$(map (cut file-append <> "/share") packages)
                     ":")))

  (list
   (shepherd-service
    (documentation "LightDM display manager")
    (requirement '(pam dbus-system user-processes host-name))
    (provision '(lightdm display-manager xorg-server))
    (respawn? #f)
    (start
     ;; Note: sadly, environment variables defined for 'lightdm' are
     ;; cleared and/or overridden by /etc/profile by its spawned greeters,
     ;; so an out-of-band means such as /etc is required.
     #~(make-forkexec-constructor #$lightdm-command
                                  ;; Lightdm needs itself in its PATH.
                                  #:environment-variables
                                  (list
                                   ;; It knows to look for greeter
                                   ;; configurations in XDG_CONFIG_DIRS...
                                   (string-append "XDG_CONFIG_DIRS="
                                                  #$greeters-config-dir)
                                   ;; ... and for greeter .desktop files as
                                   ;; well as lightdm accountsservice
                                   ;; interface in XDG_DATA_DIRS.
                                   (string-append "XDG_DATA_DIRS="
                                                  #$data-dirs)
                                   (string-append "PATH=" #$lightdm-paths))))
    (stop #~(make-kill-destructor)))))

(define lightdm-service-type
  (handle-xorg-configuration
   lightdm-configuration
   (service-type
    (name 'lightdm)
    (default-value (lightdm-configuration))
    (extensions
     (list (service-extension pam-root-service-type lightdm-pam-services)
           (service-extension shepherd-root-service-type
                              lightdm-shepherd-service)
           (service-extension activation-service-type
                              (const %lightdm-activation))
           (service-extension dbus-root-service-type
                              (compose list lightdm-configuration-lightdm))
           (service-extension polkit-service-type
                              (compose list lightdm-configuration-lightdm))
           (service-extension account-service-type
                              (const %lightdm-accounts))
           ;; Add 'lightdm' to the system profile, so that its
           ;; 'share/accountsservice' D-Bus service extension directory can be
           ;; found via the 'XDG_DATA_DIRS=/run/current-system/profile/share'
           ;; environment variable set in the wrapper of the
           ;; libexec/accounts-daemon binary of the accountsservice package.
           ;; This daemon is spawned by D-Bus, and there's little we can do to
           ;; affect its environment.  For more reading, see:
           ;; https://github.com/NixOS/nixpkgs/issues/45059.
           (service-extension profile-service-type
                              lightdm-configuration->packages)
           ;; This is needed for the greeter itself to find its configuration,
           ;; because XDG_CONF_DIRS gets overridden by /etc/profile.
           (service-extension
            etc-service-type
            (lambda (config)
              `(("lightdm"
                 ,(lightdm-configuration->greeters-config-dir config)))))))
    (description "Run @code{lightdm}, the LightDM graphical login manager."))))


;;;
;;; Generate documentation.
;;;
(define (generate-doc)
  (configuration->documentation 'lightdm-configuration)
  (configuration->documentation 'lightdm-gtk-greeter-configuration)
  (configuration->documentation 'lightdm-seat-configuration))
