about summary refs log tree commit diff
path: root/modules/workstation
diff options
context:
space:
mode:
Diffstat (limited to 'modules/workstation')
-rw-r--r--modules/workstation/audio/default.nix9
-rw-r--r--modules/workstation/default.nix27
-rw-r--r--modules/workstation/dict/default.nix6
-rw-r--r--modules/workstation/documentation/default.nix14
-rw-r--r--modules/workstation/emacs/default.nix82
-rw-r--r--modules/workstation/emacs/early-init.el4
-rw-r--r--modules/workstation/emacs/init.el284
-rw-r--r--modules/workstation/fonts/default.nix9
-rw-r--r--modules/workstation/gh/default.nix21
-rw-r--r--modules/workstation/gnupg/default.nix38
-rw-r--r--modules/workstation/gnupg/dirmngr.conf1
-rw-r--r--modules/workstation/gnupg/gpg.conf3
-rw-r--r--modules/workstation/hardware/bluetooth/default.nix5
-rw-r--r--modules/workstation/hardware/default.nix11
-rw-r--r--modules/workstation/hardware/keyboard/default.nix15
-rw-r--r--modules/workstation/hardware/libinput/default.nix13
-rw-r--r--modules/workstation/hardware/pixelbook/default.nix17
-rw-r--r--modules/workstation/hardware/yubikey/default.nix14
-rw-r--r--modules/workstation/hardware/yubikey/u2f_keys1
-rw-r--r--modules/workstation/locale/default.nix12
-rw-r--r--modules/workstation/lorri/default.nix9
-rw-r--r--modules/workstation/mail/default.nix11
-rw-r--r--modules/workstation/mail/isync/default.nix27
-rw-r--r--modules/workstation/mail/isync/mbsyncrc.in22
-rw-r--r--modules/workstation/mail/mutt/default.nix16
-rw-r--r--modules/workstation/mail/mutt/muttrc64
-rw-r--r--modules/workstation/mail/notmuch/config12
-rw-r--r--modules/workstation/mail/notmuch/default.nix21
-rw-r--r--modules/workstation/mail/postfix/default.nix43
-rw-r--r--modules/workstation/mail/rss2email/default.nix17
-rw-r--r--modules/workstation/mpv/default.nix15
-rw-r--r--modules/workstation/networking/castnow/default.nix8
-rw-r--r--modules/workstation/networking/default.nix30
-rw-r--r--modules/workstation/physical/default.nix9
-rw-r--r--modules/workstation/weechat/default.nix152
-rw-r--r--modules/workstation/windowing/default.nix13
-rw-r--r--modules/workstation/windowing/firefox/default.nix19
-rw-r--r--modules/workstation/windowing/foot/default.nix7
-rw-r--r--modules/workstation/windowing/foot/foot.ini2
-rw-r--r--modules/workstation/windowing/gtk/default.nix9
-rw-r--r--modules/workstation/windowing/gtk/settings.ini2
-rw-r--r--modules/workstation/windowing/streaming/default.nix30
-rw-r--r--modules/workstation/windowing/sway/choose_workspace.nix9
-rw-r--r--modules/workstation/windowing/sway/choose_workspace.sh.in17
-rw-r--r--modules/workstation/windowing/sway/config.in125
-rw-r--r--modules/workstation/windowing/sway/default.nix49
-rw-r--r--modules/workstation/windowing/sway/status.cpp201
-rw-r--r--modules/workstation/windowing/sway/status.nix6
-rw-r--r--modules/workstation/windowing/sway/swayidle/default.nix23
-rw-r--r--modules/workstation/windowing/sway/swaylock/config.in3
-rw-r--r--modules/workstation/windowing/sway/swaylock/default.nix12
-rw-r--r--modules/workstation/windowing/sway/wallpaper.nix10
-rw-r--r--modules/workstation/windowing/sway/wlsunset/default.nix9
-rw-r--r--modules/workstation/windowing/sway/xdg-desktop-portal-wlr/default.nix13
54 files changed, 1601 insertions, 0 deletions
diff --git a/modules/workstation/audio/default.nix b/modules/workstation/audio/default.nix
new file mode 100644
index 000000000000..46b22655f50b
--- /dev/null
+++ b/modules/workstation/audio/default.nix
@@ -0,0 +1,9 @@
+{ pkgs, ... }:
+
+{
+  environment.systemPackages = with pkgs; [ pavucontrol ];
+
+  services.pipewire.enable = true;
+  services.pipewire.jack.enable = true;
+  services.pipewire.pulse.enable = true;
+}
diff --git a/modules/workstation/default.nix b/modules/workstation/default.nix
new file mode 100644
index 000000000000..d1b4ae4da0da
--- /dev/null
+++ b/modules/workstation/default.nix
@@ -0,0 +1,27 @@
+{ lib, pkgs, ... }:
+
+{
+  imports = [
+    ../nix ../shell ../users ../ssh
+    ./documentation ./windowing ./fonts ./gh ./hardware ./locale
+    ./dict ./emacs ./gnupg ./lorri ./mail ./mpv ./weechat
+  ];
+
+  boot.kernelParams = [ "preempt=full" ];
+
+  environment.systemPackages = with pkgs; [
+    dino ffmpeg mosh mpv nix-index qemu youtube-dl
+  ];
+
+  services.getty.autologinUser = "qyliss";
+  services.getty.loginOptions = "-- \\u";
+  services.locate.enable = true;
+
+  time.timeZone = "Europe/Berlin";
+
+  virtualisation.podman.enable = true;
+
+  programs.system-config-printer.enable = true;
+  services.printing.enable = true;
+  services.printing.drivers = with pkgs; [ foo2zjs ];
+}
diff --git a/modules/workstation/dict/default.nix b/modules/workstation/dict/default.nix
new file mode 100644
index 000000000000..92a619e75d23
--- /dev/null
+++ b/modules/workstation/dict/default.nix
@@ -0,0 +1,6 @@
+{ pkgs, ... }:
+
+{
+  services.dictd.enable = true;
+  services.dictd.DBs = with pkgs.dictdDBs; [ wiktionary ];
+}
diff --git a/modules/workstation/documentation/default.nix b/modules/workstation/documentation/default.nix
new file mode 100644
index 000000000000..590da0e7329b
--- /dev/null
+++ b/modules/workstation/documentation/default.nix
@@ -0,0 +1,14 @@
+{ pkgs, ... }:
+
+{
+  environment.systemPackages = with pkgs; [
+    autoconf.doc gcc.info glibcInfo gnumake.info gnused.info groff.doc
+    groff.man libbsd man-pages posix_man_pages (lowPrio mandoc)
+    wayland.man
+  ];
+
+  documentation.man.generateCaches = true;
+  documentation.man.extraConfig = ''
+    SECTION 1 n l 8 3 0 2 3p 3am 5 4 9 6 7
+  '';
+}
diff --git a/modules/workstation/emacs/default.nix b/modules/workstation/emacs/default.nix
new file mode 100644
index 000000000000..fa26e98ce64e
--- /dev/null
+++ b/modules/workstation/emacs/default.nix
@@ -0,0 +1,82 @@
+{ config, pkgs, ... }:
+
+let
+  emacs = ((with pkgs; emacsPackagesFor (emacs29-pgtk.overrideAttrs (
+    { patches ? [], ... }:
+    {
+      patches = patches ++ [
+        (fetchpatch {
+          url = "https://git.savannah.gnu.org/cgit/emacs.git/patch/?id=e4e89e2cb663c730fd563d89228fe3a9a34e63e5";
+          hash = "sha256-/7vWMFXjmmmTg6nNvEPOM3LkZ+j3IVV9W127anx5deI=";
+        })
+      ];
+    }
+  ))).emacsWithPackages (epkgs: with epkgs; [
+    adoc-mode
+    cmake-mode
+    code-review
+    csharp-mode
+    d-mode
+    direnv
+    dockerfile-mode
+    dts-mode
+    ebuild-mode
+    editorconfig
+    eglot
+    elpher
+    execline
+    forge
+    gn-mode
+    gnuplot-mode
+    go-mode
+    graphql-mode
+    graphviz-dot-mode
+    groovy-mode
+    haskell-mode
+    jam-mode
+    just-mode
+    kotlin-mode
+    lua-mode
+    magit
+    markdown-mode
+    meson-mode
+    monokai-theme
+    mutt-mode
+    ninja-mode
+    nix-mode
+    notmuch
+    org-roam
+    pass
+    pod-mode
+    protobuf-mode
+    rainbow-delimiters
+    rust-mode
+    sdlang-mode
+    sort-words
+    toml-mode
+    tuareg
+    typescript-mode
+    udev-mode
+    vala-mode
+    yaml-mode
+    zig-mode
+  ]));
+in
+
+{
+  environment.systemPackages = [ emacs ];
+
+  systemd.tmpfiles.rules = [
+    "d ${config.users.users.qyliss.home}/notes 0700 qyliss qyliss"
+  ];
+
+  users.users.qyliss.xdg.config.paths."emacs" = pkgs.runCommand ".emacs.d" {
+    nativeBuildInputs = [ emacs ];
+  } ''
+    cp ${./early-init.el} early-init.el
+    cp ${./init.el} init.el
+    emacs -L . --batch -f batch-byte-compile *.el
+    install -d $out
+    install *.el *.elc $out
+  '';
+}
diff --git a/modules/workstation/emacs/early-init.el b/modules/workstation/emacs/early-init.el
new file mode 100644
index 000000000000..8cac0647dd4e
--- /dev/null
+++ b/modules/workstation/emacs/early-init.el
@@ -0,0 +1,4 @@
+;; This defaults to XDG_CONFIG_HOME, which is bad because lots of
+;; state gets written to it.
+(setq user-emacs-directory
+      (concat (file-name-as-directory (getenv "XDG_DATA_HOME")) "emacs"))
diff --git a/modules/workstation/emacs/init.el b/modules/workstation/emacs/init.el
new file mode 100644
index 000000000000..8370fe153fd7
--- /dev/null
+++ b/modules/workstation/emacs/init.el
@@ -0,0 +1,284 @@
+;; -*- lexical-binding: t -*-
+
+;;; UI customization ;;;
+(column-number-mode)
+(electric-pair-mode)
+(global-so-long-mode)
+(menu-bar-mode -1)
+(scroll-bar-mode -1)
+(show-paren-mode)
+(tool-bar-mode -1)
+
+(setq frame-title-format "%b — Emacs")
+(setq uniquify-buffer-name-style 'forward)
+(setq-default truncate-lines t)
+
+(load-theme 'monokai t)
+
+;; <102 or >109 will make Ioveska look squished rather than tall,
+;; narrow, and beautiful.
+(set-face-attribute 'default nil :height 104)
+
+;; Disabling bidirectional text will make Emacs less slow with very
+;; long lines.
+(setq bidi-paragraph-direction 'left-to-right)
+(setq bidi-inhibit-bpa t)
+
+;;; Utility functions ;;;
+
+;; Set an environment variable value for a single buffer.
+(defun setenv-local (variable &optional value substitute-env-vars)
+  ;; If process-environment is the global one, make a copy of it.
+  (when (eq process-environment (default-value 'process-environment))
+    (make-local-variable 'process-environment)
+    (setq process-environment (mapcar 'concat process-environment)))
+  (setenv variable value substitute-env-vars))
+
+(defun enable-ansi-color ()
+  (setenv-local "TERM" "dumb-emacs-ansi"))
+
+;;; Major modes ;;;
+(add-to-list 'auto-mode-alist '("\\.adoc\\'" . adoc-mode))
+(add-to-list 'auto-mode-alist '("\\.quirks\\'" . conf-unix-mode))
+(add-to-list 'auto-mode-alist '("\\.gni?\\'" . gn-mode))
+(add-to-list 'auto-mode-alist '("/muttrc\\'" . mutt-mode))
+(add-to-list 'auto-mode-alist '("\\.tmac\\'" . nroff-mode))
+(add-to-list 'auto-mode-alist '("/Cargo\\.lock\\'" . toml-mode))
+(add-to-list 'auto-mode-alist '("/\\.clang-format\\'" . yaml-mode))
+(add-to-list 'auto-mode-alist '("/up\\'" . execline-mode))
+(add-to-list 'auto-mode-alist '("/down\\'" . execline-mode))
+
+;;; auth-source ;;;
+(auth-source-pass-enable)
+
+;;; Backups ;;;
+(let ((backup-dir
+       (concat (file-name-as-directory user-emacs-directory) "backups")))
+  (make-directory backup-dir t)
+  (setq backup-directory-alist `(("." . ,backup-dir)))
+  (setq message-auto-save-directory backup-dir))
+
+;;; CC Mode ;;;
+(with-eval-after-load 'cc-styles
+  ;; Default to kernel style.
+  (add-to-list 'c-default-style '(other . "linux")))
+
+;;; Comint ;;;
+;; This relies on an Emacs patch to apply to async-shell-command.
+(setq comint-terminfo-terminal "dumb-emacs-ansi")
+
+;;; Diff Mode ;;;
+;; Unbind M-DEL from scroll-down-command, so it does the same thing as
+;; in the rest of Emacs (backward-kill-word).
+(with-eval-after-load 'diff-mode
+  (define-key diff-mode-map (kbd "M-DEL") nil))
+
+;;; Dired ;;;
+(with-eval-after-load 'dired
+  ;; Use human-readable sizes.
+  (setq dired-listing-switches
+	(combine-and-quote-strings `(,dired-listing-switches "-h")))
+
+  ;; Don't open new windows when clicking directory entries.
+  (define-key dired-mode-map [mouse-2] 'dired-find-file))
+
+;;; Direnv ;;;
+(direnv-mode)
+(global-set-key (kbd "C-c d") #'direnv-update-environment)
+
+;;; Ediff ;;;
+;; Don't make a new frame for Ediff controls.
+(setq ediff-window-setup-function 'ediff-setup-windows-plain)
+
+;;; EditorConfig ;;;
+(editorconfig-mode)
+
+;;; Eglot ;;;
+(with-eval-after-load 'eglot
+  ;; Override default LSPs for languages.
+  (add-to-list 'eglot-server-programs '((c++-mode c-mode) "clangd"))
+  (add-to-list 'eglot-server-programs '(rust-mode "rust-analyzer"))
+  (add-to-list 'eglot-server-programs '(nix-mode "rnix-lsp"))
+
+  ;; Underline all occurrences of the symbol at point in the current
+  ;; buffer (default is to bold instead).
+  (set-face-attribute 'eglot-highlight-symbol-face nil :inherit 'underline)
+
+  ;; Add keybindings for eglot actions.
+  (define-key eglot-mode-map (kbd "C-c e a") #'eglot-code-actions)
+  (define-key eglot-mode-map (kbd "C-c e r") #'eglot-rename))
+
+;; Tell rust-analyzer not to allow colons inside Rust import.
+(setq-default eglot-workspace-configuration
+	      '((:rust-analyzer . (:assist (:importMergeBehaviour "last")))))
+
+;;; Environment ;;;
+
+;; Provide a way to turn paging back on for modes like terminal emulators.
+(let ((pager (getenv "PAGER")))
+  (defun enable-pager ()
+    (setenv-local "PAGER" pager)))
+
+;; Set PAGER to the empty string, which Git and journalctl will
+;; interpret as an explicit opt-out of paging.
+(setenv "PAGER" "")
+
+;;; Eshell ;;;
+(add-hook 'eshell-mode-hook #'enable-ansi-color)
+
+;;; gdb-mi ;;;
+(with-eval-after-load "gdb-mi"
+  ;; Hide all the copyright and documentation messages at gdb startup.
+  (setq gud-gdb-command-name (concat gud-gdb-command-name " -q")))
+
+;;; Ibuffer ;;;
+;; Open ibuffer in other window, to match behaviour of the default
+;; list-buffers.
+(global-set-key (kbd "C-x C-b") (lambda () (interactive) (ibuffer t)))
+
+;;; ispell ;;;
+;; System locale is Esperanto, but I write in English much more, and
+;; spelling in Esperanto is easy anyway. ;)
+(setq ispell-dictionary "english")
+
+;;; Magit ;;;
+(setq magit-delete-by-moving-to-trash delete-by-moving-to-trash)
+(setq magit-repository-directories
+      `((,(expand-file-name "~/src") . 1)))
+(global-set-key (kbd "C-x g") #'magit-status)
+(global-set-key (kbd "C-x M-g") #'magit-dispatch)
+(with-eval-after-load "git-commit"
+  (add-to-list 'git-commit-trailers "Change-Id")
+  (add-to-list 'git-commit-trailers "Fixes")
+  (add-to-list 'git-commit-trailers "Message-Id"))
+
+;; Don't open a second window with a diff when committing.  I have
+;; commit.verbose set in git, so the diff is shown below the commit
+;; anyway, and it's annoying to have magit take over the whole frame
+;; because it means I can't refer to something else, like an email
+;; thread discussing the commit.  And sometimes the diff shown by
+;; magit doesn't agree with the diff shown by git (and git is always
+;; in the right when this happens).
+(setq magit-commit-show-diff nil)
+
+(defun git-commit-message-pretty-ref (commit)
+  "Generate a reference to git COMMIT in the format used in the
+Linux kernel (`SHORT-HASH (\"SHORT-MESSAGE\")') and insert it at
+point."
+  (interactive
+   (list (or (nreverse (magit-region-values 'commit))
+	     (magit-read-other-branch-or-commit "Reference commit"))))
+  (call-process (magit-git-executable) nil t nil
+		"show" "--no-patch" "--pretty=format:%h (\"%s\")" commit))
+
+(with-eval-after-load "magit"
+  (define-key magit-process-mode-map (kbd "k") #'magit-process-kill)
+
+  ;; Add a --no-gpg-sign option to Magit.  This is only useful with
+  ;; commit.gpgsign=true.  It would be nice if commit.gpgsign=true just
+  ;; meant that Magit showed the --gpg-sign option as enabled by
+  ;; default, and disabling it would make Magit pass --no-gpg-sign to
+  ;; git, but that's not currently the case, so we need a seperate
+  ;; option for --no-gpg-sign.  See
+  ;; <https://github.com/magit/magit/issues/3832>.
+  (dolist (command '(magit-commit magit-merge magit-cherry-pick
+		     magit-revert magit-am magit-rebase))
+    (transient-append-suffix command ["-S"]
+      '("=S" "Don't sign using gpg" "--no-gpg-sign"))))
+
+;;; Markdown ;;;
+(setq-default markdown-hide-markup t)
+
+;;; Man ;;;
+;; Open man pages in the current window.  I don't really care what
+;; happens when I do M-x man, but I don't want my whole frame to be
+;; taken over with man buffers if I'm following a bunch of
+;; cross-references.
+(setq Man-notify-method 'pushy)
+
+;;; MML ;;;
+(setq mml-secure-openpgp-encrypt-to-self t)
+(setq mml-secure-openpgp-signers '("757356D779BBB888773E415E736CCDF9EF51BD97"))
+(add-hook 'message-setup-hook 'mml-secure-message-sign-pgpmime)
+
+;;; Nix support ;;;
+(defun browse-url-nixpkgs (attr)
+  "Open a browser to the homepage for the Nixpkgs attribute ATTR."
+  (interactive "sAttribute: ")
+  (let* ((full-attr-quoted (shell-quote-argument (concat attr ".meta.homepage")))
+	 (command (concat "nix --extra-experimental-features nix-command eval --raw -f '<nixpkgs>' " full-attr-quoted))
+	 (homepage (shell-command-to-string command)))
+    (browse-url homepage)))
+
+;;; notmuch ;;;
+(setq notmuch-search-oldest-first nil)
+(setq notmuch-fcc-dirs "Sent")
+(setq notmuch-draft-folder "Drafts")
+(setq notmuch-saved-searches
+      `((:name "direct" :query ,(concat "-tag:done -folder:Spam -from:discourse@discourse.nixos.org to:" (getenv "EMAIL")) :key "d")
+	(:name "github" :query "-tag:done -to:your_activity@noreply.github.com from:notifications@github.com" :key "g")
+	(:name "lists" :query "-tag:done to:afra@afra-berlin.de OR to:@list.skarnet.org OR wayland-devel@lists.freedesktop.org OR to:distributions@lists.linux.dev OR to:virglrenderer-devel@lists.freedesktop.org OR to:config-patches@gnu.org OR to:linux-kernel-announce@vger.kernel.org" :key "l")))
+(setq notmuch-tagging-keys '(("d" ("+done") "done")))
+
+;;; mail ;;;
+; This has to go after we set the notmuch settings, for some reason,
+; or they are not applied.
+(require 'notmuch-mua)
+(setq mail-user-agent 'notmuch-user-agent)
+
+;;; Org-mode ;;;
+(setq org-id-locations-file (concat user-emacs-directory "/org-id-locations"))
+
+;;; Org-roam ;;;
+(setq org-roam-directory "~/notes")
+(setq org-roam-v2-ack t)
+(org-roam-db-autosync-mode)
+(global-set-key (kbd "C-c o c") #'org-roam-capture)
+(global-set-key (kbd "C-c o f") #'org-roam-node-find)
+(define-key org-mode-map (kbd "C-c o i") #'org-roam-node-insert)
+(define-key org-mode-map (kbd "C-c o r") #'org-roam-ref-add)
+
+;;; Rainbow Delimiters ;;;
+(add-hook 'prog-mode-hook #'rainbow-delimiters-mode)
+
+;;; Revert buffers ;;;
+(global-set-key (kbd "C-c r") #'revert-buffer)
+
+;;; Ruby ;;;
+(setq ruby-align-to-stmt-keywords t)
+
+;;; Rust ;;;
+;; Indent with spaces in Rust code.
+(add-hook 'rust-mode-hook (lambda () (setq indent-tabs-mode nil)))
+
+(with-eval-after-load 'rust-mode
+  ;; Cargo keybindings
+  (define-key rust-mode-map (kbd "C-c c b") 'rust-compile)
+  (define-key rust-mode-map (kbd "C-c c c") 'rust-check)
+  (define-key rust-mode-map (kbd "C-c c r") 'rust-run)
+  (define-key rust-mode-map (kbd "C-c c t") 'rust-test))
+
+;;; save-some-buffers ;;;
+;; Allow reverting buffers directly from the "Save file ...?" message.
+(add-to-list 'save-some-buffers-action-alist
+  `(?r ,(lambda (buf) (with-current-buffer buf (revert-buffer t t)))
+       ,(purecopy "revert this buffer")))
+
+;;; sendmail ;;;
+;; Use the system "sendmail" program to send mail.
+(setq mail-envelope-from 'header)
+(setq mail-specify-envelope-from t)
+(setq send-mail-function 'sendmail-send-it)
+
+;;; Term ;;;
+(add-hook 'term-mode-hook #'enable-pager)
+
+;;; Transient ;;;
+;; Make all Magit options available, even those that are disabled by
+;; default because they're too obscure.
+(setq transient-default-level 7)
+
+;;; with-editor ;;;
+(add-hook 'eshell-mode-hook 'with-editor-export-editor)
+(add-hook 'shell-mode-hook 'with-editor-export-editor)
+(add-hook 'term-exec-hook 'with-editor-export-editor)
diff --git a/modules/workstation/fonts/default.nix b/modules/workstation/fonts/default.nix
new file mode 100644
index 000000000000..309a863cd826
--- /dev/null
+++ b/modules/workstation/fonts/default.nix
@@ -0,0 +1,9 @@
+{ pkgs, ... }:
+
+{
+  fonts.packages = with pkgs; [
+    ccsymbols fira iosevka twemoji-color-font noto-fonts-cjk
+  ];
+  fonts.fontconfig.allowBitmaps = false;
+  fonts.fontconfig.defaultFonts.monospace = [ "Iosevka" ];
+}
diff --git a/modules/workstation/gh/default.nix b/modules/workstation/gh/default.nix
new file mode 100644
index 000000000000..b3dc60ed9407
--- /dev/null
+++ b/modules/workstation/gh/default.nix
@@ -0,0 +1,21 @@
+{ lib, pkgs, ... }:
+
+let
+  wrapper = with pkgs; writeScriptBin "gh" ''
+    #! ${execline}/bin/execlineb -s0
+
+    fdmove 3 0
+    pipeline { ${pass}/bin/pass show api.github.com }
+    fdswap 0 3
+    export GH_TOKEN_FD 3
+
+    importas -i config_home XDG_CONFIG_HOME
+    export GH_CONFIG_DIR ''${config_home}/gh
+
+    ${pkgs.gh}/bin/gh $@
+  '';
+in
+
+{
+  environment.systemPackages = [ pkgs.gh (lib.hiPrio wrapper) ];
+}
diff --git a/modules/workstation/gnupg/default.nix b/modules/workstation/gnupg/default.nix
new file mode 100644
index 000000000000..eda898314258
--- /dev/null
+++ b/modules/workstation/gnupg/default.nix
@@ -0,0 +1,38 @@
+{ config, pkgs, ... }:
+
+let
+  gnupgHome = "${config.users.users.qyliss.home}/state/gnupg";
+
+  pinentryProgram =
+    if pkgs.stdenv.isDarwin then
+      "/Applications/pinentry-mac.app/Contents/MacOS/pinentry-mac"
+    else
+      "${pkgs.pinentry.qt}/bin/pinentry";
+
+  gpg-agent-conf = pkgs.writeText "gpg-agent.conf" ''
+    pinentry-program ${pinentryProgram}
+  '';
+in
+
+{
+  systemd.tmpfiles.rules = [
+    "d ${gnupgHome} 0700 qyliss qyliss"
+    "L+ ${gnupgHome}/dirmngr.conf   - - - - ${./dirmngr.conf}"
+    "L+ ${gnupgHome}/gpg.conf       - - - - ${./gpg.conf}"
+    "L+ ${gnupgHome}/gpg-agent.conf - - - - ${gpg-agent-conf}"
+  ];
+
+  environment.systemPackages = with pkgs; [ gnupg pinentry ];
+
+  environment.extraInit = ''
+    export GNUPGHOME="$HOME/state/gnupg"
+
+    if [ -z "$SSH_AUTH_SOCK" ]; then
+        export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
+    fi
+  '';
+
+  programs.sway.extraConfig = ''
+    exec gpg-connect-agent /bye
+  '';
+}
diff --git a/modules/workstation/gnupg/dirmngr.conf b/modules/workstation/gnupg/dirmngr.conf
new file mode 100644
index 000000000000..14114144cc9d
--- /dev/null
+++ b/modules/workstation/gnupg/dirmngr.conf
@@ -0,0 +1 @@
+keyserver hkps://keys.openpgp.org
diff --git a/modules/workstation/gnupg/gpg.conf b/modules/workstation/gnupg/gpg.conf
new file mode 100644
index 000000000000..ab999bfbc32a
--- /dev/null
+++ b/modules/workstation/gnupg/gpg.conf
@@ -0,0 +1,3 @@
+ask-cert-level
+auto-key-retrieve
+trust-model tofu+pgp
diff --git a/modules/workstation/hardware/bluetooth/default.nix b/modules/workstation/hardware/bluetooth/default.nix
new file mode 100644
index 000000000000..fb6a06a03e5f
--- /dev/null
+++ b/modules/workstation/hardware/bluetooth/default.nix
@@ -0,0 +1,5 @@
+{ ... }:
+
+{
+  hardware.bluetooth.enable = true;
+}
diff --git a/modules/workstation/hardware/default.nix b/modules/workstation/hardware/default.nix
new file mode 100644
index 000000000000..64e06aecf363
--- /dev/null
+++ b/modules/workstation/hardware/default.nix
@@ -0,0 +1,11 @@
+{ pkgs, ... }:
+
+{
+  imports = [ ./keyboard ./libinput ./yubikey ];
+
+  environment.systemPackages = with pkgs; [ brightnessctl ddrescue usbutils ];
+
+  hardware.sane.enable = true;
+
+  sound.enable = true;
+}
diff --git a/modules/workstation/hardware/keyboard/default.nix b/modules/workstation/hardware/keyboard/default.nix
new file mode 100644
index 000000000000..3bd9dc4280db
--- /dev/null
+++ b/modules/workstation/hardware/keyboard/default.nix
@@ -0,0 +1,15 @@
+{ pkgs, config, ... }:
+
+let
+  xcfg = config.services.xserver;
+
+in
+{
+  console.useXkbConfig = true;
+  services.xserver.xkb.variant = "dvorak";
+  services.xserver.xkb.options = "caps:escape,compose:menu,compose:prsc";
+
+  environment.variables.XKB_DEFAULT_LAYOUT = xcfg.xkb.layout;
+  environment.variables.XKB_DEFAULT_VARIANT = xcfg.xkb.variant;
+  environment.variables.XKB_DEFAULT_OPTIONS = xcfg.xkb.options;
+}
diff --git a/modules/workstation/hardware/libinput/default.nix b/modules/workstation/hardware/libinput/default.nix
new file mode 100644
index 000000000000..c3a56e22feb7
--- /dev/null
+++ b/modules/workstation/hardware/libinput/default.nix
@@ -0,0 +1,13 @@
+{ ... }:
+
+{
+  environment.etc."libinput/local-overrides.quirks".text = ''
+    [Google Chromebook Eve]
+    MatchUdevType=touchpad
+    MatchName=ACPI0C50:00 18D1:5028
+    MatchDMIModalias=dmi:*svnGoogle:pnEve*
+    ModelChromebook=1
+    AttrPressureRange=6:4
+    AttrThumbPressureThreshold=45
+  '';
+}
diff --git a/modules/workstation/hardware/pixelbook/default.nix b/modules/workstation/hardware/pixelbook/default.nix
new file mode 100644
index 000000000000..b708abc85a3d
--- /dev/null
+++ b/modules/workstation/hardware/pixelbook/default.nix
@@ -0,0 +1,17 @@
+{ ... }:
+
+{
+  imports = [ ../../../nixos-hardware/google/pixelbook ];
+
+  boot.postBootCommands = ''
+    # Remap Google Assistant to left super.
+    /run/current-system/sw/bin/setkeycodes e058 125
+
+    # Remap menu to compose.
+    /run/current-system/sw/bin/setkeycodes 5d 127
+  '';
+
+  services.logind.extraConfig = ''
+    HandlePowerKey=suspend
+  '';
+}
diff --git a/modules/workstation/hardware/yubikey/default.nix b/modules/workstation/hardware/yubikey/default.nix
new file mode 100644
index 000000000000..0f2d63e742b9
--- /dev/null
+++ b/modules/workstation/hardware/yubikey/default.nix
@@ -0,0 +1,14 @@
+{ pkgs, ... }:
+
+{
+  services.udev.packages = with pkgs; [ yubikey-personalization ];
+
+  security.pam.services.sudo.u2fAuth = true;
+  security.sudo.extraConfig = ''
+    Defaults timestamp_timeout=0
+  '';
+
+  security.pam.u2f.appId = "pam://qyliss.net";
+  security.pam.u2f.cue = true;
+  security.pam.u2f.authFile = pkgs.copyPathToStore ./u2f_keys;
+}
diff --git a/modules/workstation/hardware/yubikey/u2f_keys b/modules/workstation/hardware/yubikey/u2f_keys
new file mode 100644
index 000000000000..1bb4f2f4c3a4
--- /dev/null
+++ b/modules/workstation/hardware/yubikey/u2f_keys
@@ -0,0 +1 @@
+qyliss:xEFvYinTZARyMSapx3wLN6U0w5oy7he66DM9Ww1EIUwr1eEsdEVX6JTayiol+XyGkThCMCRPJcEvTqR8cgXd8A==,0gl+uRidS5Dhx1S54aniQhJE+anN6hhTelZ8DncvpM2Z6MLfc/QUotFFE0cCBEA6KbQOIK9+WtgabFGOqBmljw==,es256,+presence
diff --git a/modules/workstation/locale/default.nix b/modules/workstation/locale/default.nix
new file mode 100644
index 000000000000..c079ab19bc88
--- /dev/null
+++ b/modules/workstation/locale/default.nix
@@ -0,0 +1,12 @@
+{ ... }:
+
+{
+  i18n.defaultLocale = "eo";
+  i18n.extraLocaleSettings.LC_ADDRESS = "de_DE.UTF-8";
+  i18n.extraLocaleSettings.LC_CTYPE = "de_DE.UTF-8";
+  i18n.extraLocaleSettings.LC_NAME = "en_GB.UTF-8";
+
+  # Like Germany, but uses thin spaces as thousands separators.
+  i18n.extraLocaleSettings.LC_MONETARY = "fr_FR.UTF-8";
+  i18n.extraLocaleSettings.LC_NUMERIC = "fr_FR.UTF-8";
+}
diff --git a/modules/workstation/lorri/default.nix b/modules/workstation/lorri/default.nix
new file mode 100644
index 000000000000..602fac6106f7
--- /dev/null
+++ b/modules/workstation/lorri/default.nix
@@ -0,0 +1,9 @@
+{ config, ... }:
+
+{
+  services.lorri.enable = true;
+
+  # FIXME: systemd should have this set globally.
+  systemd.user.services.lorri.environment.XDG_CACHE_HOME =
+    "${config.users.users.qyliss.home}/state/cache";
+}
diff --git a/modules/workstation/mail/default.nix b/modules/workstation/mail/default.nix
new file mode 100644
index 000000000000..ff34fab8b98e
--- /dev/null
+++ b/modules/workstation/mail/default.nix
@@ -0,0 +1,11 @@
+{ pkgs, config, ... }:
+
+{
+  imports = [ ./isync ./mutt ./notmuch ./postfix ./rss2email ];
+
+  environment.systemPackages = with pkgs; [ isync ];
+
+  systemd.tmpfiles.rules = [
+    "d ${config.users.users.qyliss.home}/mail 0770 qyliss qyliss"
+  ];
+}
diff --git a/modules/workstation/mail/isync/default.nix b/modules/workstation/mail/isync/default.nix
new file mode 100644
index 000000000000..22d64e45b1ba
--- /dev/null
+++ b/modules/workstation/mail/isync/default.nix
@@ -0,0 +1,27 @@
+{ config, pkgs, ... }:
+
+let
+  maildir = "${config.users.users.qyliss.home}/mail";
+  mbsyncrc = pkgs.substituteAll { inherit maildir; src = ./mbsyncrc.in; };
+in
+
+{
+  systemd.services.mbsync = {
+    serviceConfig.Type = "oneshot";
+    after = [ "network-online.target" ];
+    before = [ "notmuch.service" ];
+    requires = [ "network-online.target" ];
+    wants = [ "notmuch.service" ];
+    serviceConfig.ExecStart = "${pkgs.isync}/bin/mbsync -a -V -c ${mbsyncrc}";
+    serviceConfig.User = "qyliss";
+    unitConfig.ConditionPathExists = "${maildir}/imappass";
+  };
+
+  systemd.timers.mbsync = {
+    timerConfig.OnCalendar = "*:0/5";
+    timerConfig.Persistent = true;
+    after = [ "network-online.target" ];
+    requires = [ "network-online.target" ];
+    wantedBy = [ "timers.target" ];
+  };
+}
diff --git a/modules/workstation/mail/isync/mbsyncrc.in b/modules/workstation/mail/isync/mbsyncrc.in
new file mode 100644
index 000000000000..4c44891acb46
--- /dev/null
+++ b/modules/workstation/mail/isync/mbsyncrc.in
@@ -0,0 +1,22 @@
+Create Both
+
+MaildirStore local
+  Path @maildir@/
+  Inbox @maildir@/INBOX
+  Subfolders Verbatim
+
+IMAPAccount fastmail
+  Host imap.fastmail.com
+  User alyssa@fastmail.com
+  PassCmd "cat ~/mail/imappass"
+  SSLType IMAPS
+  SSLVersions TLSv1.3
+
+IMAPStore fastmail-remote
+  Account fastmail
+
+Channel fastmail
+  Far :fastmail-remote:
+  Near :local:
+  Patterns *
+  SyncState *
diff --git a/modules/workstation/mail/mutt/default.nix b/modules/workstation/mail/mutt/default.nix
new file mode 100644
index 000000000000..96c0e6867545
--- /dev/null
+++ b/modules/workstation/mail/mutt/default.nix
@@ -0,0 +1,16 @@
+{ config, pkgs, ... }:
+
+let
+  stateDir = "${config.users.users.qyliss.home}/state/mutt";
+in
+
+{
+  environment.systemPackages = with pkgs; [ neomutt ];
+
+  users.users.qyliss.xdg.config.paths."mutt/muttrc" = pkgs.copyPathToStore ./muttrc;
+
+  systemd.tmpfiles.rules = [
+    "d ${stateDir}              0700 qyliss qyliss"
+    "d ${stateDir}/header_cache 0700 qyliss qyliss"
+  ];
+}
diff --git a/modules/workstation/mail/mutt/muttrc b/modules/workstation/mail/mutt/muttrc
new file mode 100644
index 000000000000..6d80aced3bef
--- /dev/null
+++ b/modules/workstation/mail/mutt/muttrc
@@ -0,0 +1,64 @@
+color index red default ~P
+
+alternates alyssa\.ross@unikie\.com
+alternates @alyssa\.is @hi\.alyssa\.is
+
+unignore List-Id:
+unignore Message-Id:
+ignore User-Agent:
+
+set beep = no
+set beep_new = yes
+set edit_headers = yes
+set fast_reply = yes
+set folder = ~/mail
+set header_cache = ~/state/mutt/header_cache
+set help = no
+set mark_old = no
+set mime_forward = ask-no
+set quit = ask-yes
+set reverse_name = yes
+set sort = last-date-received
+set sort_browser = new
+set strict_threads = yes
+set use_envelope_from = yes
+set use_threads = yes
+set user_agent = no
+
+set newsrc = $XDG_DATA_HOME/mutt/newsrc
+set nntp_context = 32767
+
+unset prompt_after
+
+set spoolfile = +INBOX
+
+# set record = "=[Gmail]/Sent Mail"
+# set postponed = "=[Gmail]/Drafts"
+mailboxes `cd ~/mail; find . -name cur -print0 | sed -z -e 's|^\./||' -e 's|/cur$||' -e 's/\\/\\\\/' -e 's/"/\\"/g' -e 's/^/"=/' -e 's/$/"/' | xargs -0`
+set record = "=Sent"
+set trash = "=Archive"
+set postponed = "=Drafts"
+set sendmail = "sendmail"
+
+set pgp_use_gpg_agent = yes
+set crypt_autosign = yes
+set crypt_opportunistic_encrypt = yes
+set postpone_encrypt = yes
+
+# Required for postpone_encrypt to work
+set pgp_default_key = 757356D779BBB888773E415E736CCDF9EF51BD97
+
+set pgp_decode_command       = "gpg --status-fd=2 %?p?--pinentry-mode loopback --passphrase-fd 0? --no-verbose --quiet --batch --output - %f"
+set pgp_verify_command       = "gpg --status-fd=2 --no-verbose --quiet --batch --output - --verify %s %f"
+set pgp_decrypt_command      = "gpg --status-fd=2 %?p?--pinentry-mode loopback --passphrase-fd 0? --no-verbose --quiet --batch --output - --decrypt %f"
+set pgp_sign_command         = "gpg %?p?--pinentry-mode loopback --passphrase-fd 0? --no-verbose --batch --quiet --output - --armor --textmode %?a?--local-user %a? --detach-sign %f"
+set pgp_clearsign_command    = "gpg %?p?--pinentry-mode loopback --passphrase-fd 0? --no-verbose --batch --quiet --output - --armor --textmode %?a?--local-user %a? --clearsign %f"
+set pgp_encrypt_only_command = "pgpewrap gpg --trust-model always --batch --quiet --no-verbose --output - --textmode --armor --encrypt -- --recipient %r -- %f"
+set pgp_encrypt_sign_command = "pgpewrap gpg %?p?--pinentry-mode loopback --passphrase-fd 0? --trust-model always --batch --quiet --no-verbose --textmode --output - %?a?--local-user %a? --armor --sign --encrypt -- --recipient %r -- %f"
+set pgp_import_command       = "gpg --no-verbose --import %f"
+set pgp_export_command       = "gpg --no-verbose --armor --export %r"
+set pgp_verify_key_command   = "gpg --verbose --batch --fingerprint --check-sigs %r"
+set pgp_list_pubring_command = "gpg --no-verbose --batch --quiet --with-colons --with-fingerprint --with-fingerprint --list-keys %r"
+set pgp_list_secring_command = "gpg --no-verbose --batch --quiet --with-colons --with-fingerprint --with-fingerprint --list-secret-keys %r"
+set pgp_good_sign            = "^\\[GNUPG:\\] GOODSIG"
+set pgp_decryption_okay      = "^\\[GNUPG:\\] DECRYPTION_OKAY"
diff --git a/modules/workstation/mail/notmuch/config b/modules/workstation/mail/notmuch/config
new file mode 100644
index 000000000000..12b4435f10a2
--- /dev/null
+++ b/modules/workstation/mail/notmuch/config
@@ -0,0 +1,12 @@
+[user]
+other_email=alyssa.ross@freeagent.com;alyssa.ross@unikie.com;
+
+[new]
+tags=unread;inbox;
+ignore=.uidvalidity;.mbsyncstate;.mbsyncstate.new;.mbsyncstate.journal;
+
+[search]
+exclude_tags=
+
+[maildir]
+synchronize_flags=true
\ No newline at end of file
diff --git a/modules/workstation/mail/notmuch/default.nix b/modules/workstation/mail/notmuch/default.nix
new file mode 100644
index 000000000000..cb7ccd877950
--- /dev/null
+++ b/modules/workstation/mail/notmuch/default.nix
@@ -0,0 +1,21 @@
+{ pkgs, ... }:
+
+{
+  environment.extraInit = ''
+    export NOTMUCH_CONFIG="/etc/xdg/nixos/per-user/$USER/notmuch/config"
+  '';
+
+  environment.systemPackages = with pkgs; [ notmuch ];
+
+  users.users.qyliss.xdg.config.paths."notmuch/config" =
+    pkgs.copyPathToStore ./config;
+
+  systemd.services.notmuch = {
+    serviceConfig.Type = "oneshot";
+    environment.NOTMUCH_CONFIG = "/etc/xdg/nixos/per-user/qyliss/notmuch/config";
+    serviceConfig.ExecStart = "${pkgs.notmuch}/bin/notmuch new";
+    serviceConfig.IOSchedulingClass = "idle";
+    serviceConfig.Nice = 1;
+    serviceConfig.User = "qyliss";
+  };
+}
diff --git a/modules/workstation/mail/postfix/default.nix b/modules/workstation/mail/postfix/default.nix
new file mode 100644
index 000000000000..db16bced3a5e
--- /dev/null
+++ b/modules/workstation/mail/postfix/default.nix
@@ -0,0 +1,43 @@
+{ pkgs, lib, config, ... }:
+
+{
+  services.postfix.enable = true;
+
+  services.postfix.hostname = with lib; with config.networking;
+    concatStringsSep "." (filter (x: x != null) [ hostName domain ]);
+
+  services.postfix.relayHost = "smtp.fastmail.com";
+  services.postfix.relayPort = 465;
+
+  services.postfix.recipientDelimiter = "+";
+  services.postfix.config.home_mailbox = "mail/INBOX/";
+  services.postfix.canonical = ''
+    qyliss hi@alyssa.is
+  '';
+  services.postfix.virtual = ''
+    hi@alyssa.is qyliss
+  '';
+
+  # NixOS links /var/lib/postfix/conf to /etc/postfix, but
+  # postfix.service deletes /var/lib/postfix in an ExecStartPre, so we
+  # can't keep files there without adding them to the store.
+  #
+  # Work around this with a layer of symlink indirection.
+  services.postfix.mapFiles.sasl_passwd = pkgs.runCommand "sasl_passwd" {} ''
+    ln -s /var/lib/postfix/sasl_passwd $out
+  '';
+  services.postfix.config.smtp_sasl_password_maps = "hash:/etc/postfix/sasl_passwd";
+
+  services.postfix.config.sender_dependent_relayhost_maps = "hash:/etc/postfix/sender_dependent_relayhost";
+  services.postfix.mapFiles.sender_dependent_relayhost = pkgs.writeText "sender_dependent_relayhost" ''
+    @unikie.com [smtp.gmail.com]:465
+  '';
+
+  services.postfix.config.smtp_sasl_auth_enable = true;
+  services.postfix.config.smtp_sasl_tls_security_options = "noanonymous";
+  services.postfix.config.smtp_tls_security_level = "encrypt";
+  services.postfix.config.smtp_tls_wrappermode = true;
+
+  systemd.services.postfix-setup.unitConfig.ConditionPathExists = "/var/lib/postfix/sasl_passwd";
+  systemd.services.postfix.unitConfig.ConditionPathExists = "/var/lib/postfix/sasl_passwd";
+}
diff --git a/modules/workstation/mail/rss2email/default.nix b/modules/workstation/mail/rss2email/default.nix
new file mode 100644
index 000000000000..614b7f9c0cb0
--- /dev/null
+++ b/modules/workstation/mail/rss2email/default.nix
@@ -0,0 +1,17 @@
+{ config, ... }:
+
+{
+  services.rss2email.enable = true;
+  services.rss2email.to = "hi+rss2email@alyssa.is";
+  services.rss2email.config.date-header = true;
+  services.rss2email.config.from =
+    "rss2email@${config.services.postfix.hostname}";
+
+  services.rss2email.feeds = {
+    fading-memories = { url = "https://valdyas.org/fading/feed/"; };
+    flak = { url = "https://flak.tedunangst.com/rss"; };
+    wandering-thoughts =
+      { url = "https://utcc.utoronto.ca/~cks/space/blog/?atom"; };
+    repology = { url = "https://repology.org/maintainer/hi%40alyssa.is/feed-for-repo/nix_unstable/atom"; };
+  };
+}
diff --git a/modules/workstation/mpv/default.nix b/modules/workstation/mpv/default.nix
new file mode 100644
index 000000000000..4e41f6566ecf
--- /dev/null
+++ b/modules/workstation/mpv/default.nix
@@ -0,0 +1,15 @@
+{ pkgs, ... }:
+
+{
+  environment.systemPackages = with pkgs; [ mpv ];
+
+  users.users.qyliss.xdg.config.paths."mpv/input.conf" =
+    pkgs.writeText "input.conf" ''
+      F11 cycle fullscreen
+    '';
+
+  users.users.qyliss.xdg.config.paths."mpv/mpv.conf" =
+    pkgs.writeText "mpv.conf" ''
+      audio-display=no
+    '';
+}
diff --git a/modules/workstation/networking/castnow/default.nix b/modules/workstation/networking/castnow/default.nix
new file mode 100644
index 000000000000..19f09723ad7e
--- /dev/null
+++ b/modules/workstation/networking/castnow/default.nix
@@ -0,0 +1,8 @@
+{ pkgs, ... }:
+
+{
+  environment.systemPackages = with pkgs; [ castnow ];
+
+  networking.firewall.allowedUDPPorts = [ 5353 ];
+  networking.firewall.allowedTCPPortRanges = [ { from = 4100; to = 4105; } ];
+}
diff --git a/modules/workstation/networking/default.nix b/modules/workstation/networking/default.nix
new file mode 100644
index 000000000000..56703070ae64
--- /dev/null
+++ b/modules/workstation/networking/default.nix
@@ -0,0 +1,30 @@
+{ config, ... }:
+
+{
+  imports = [ ../../hosts ./castnow ];
+
+  services.avahi.enable = true;
+  services.resolved.enable = true;
+
+  networking.domain = "qyliss.net";
+  networking.hosts = with config.networking;
+    { "127.0.1.1" = [ "${hostName}.${domain}" ]; };
+
+  networking.networkmanager.enable = true;
+  networking.networkmanager.wifi.backend = "iwd";
+
+  users.users.qyliss.extraGroups = [ "networkmanager" ];
+
+  # Plausible MAC randomization
+  networking.networkmanager.ethernet.macAddress = "random";
+  networking.networkmanager.wifi.macAddress = "random";
+  networking.networkmanager.extraConfig = ''
+    [connection-extra]
+    ethernet.generate-mac-address-mask=FE:FF:FF:00:00:00
+    wifi.generate-mac-address-mask=FE:FF:FF:00:00:00
+  '';
+
+  networking.nameservers = [ "::1" ];
+
+  programs.mtr.enable = true;
+}
diff --git a/modules/workstation/physical/default.nix b/modules/workstation/physical/default.nix
new file mode 100644
index 000000000000..fc5cad3b2787
--- /dev/null
+++ b/modules/workstation/physical/default.nix
@@ -0,0 +1,9 @@
+{ ... }:
+
+{
+  imports = [ ../. ../networking ];
+
+  programs.swayidle.enable = true;
+
+  zramSwap.enable = true;
+}
diff --git a/modules/workstation/weechat/default.nix b/modules/workstation/weechat/default.nix
new file mode 100644
index 000000000000..95ba03c1ecdc
--- /dev/null
+++ b/modules/workstation/weechat/default.nix
@@ -0,0 +1,152 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  scripts = with pkgs.weechatScripts;
+    [ colorize_nicks weechat-go weechat-matrix zncplayback ];
+
+  networks = [
+    "blitzed" "gnome" "hackint" "indymedia" "ircnet" "libera"
+    "oftc" "pissnet" "tilde"
+  ];
+
+  matrixServers = { fairydust = "fairydust.space"; };
+
+  toWeeChat = value:
+    /**/ if value == true  then "on"
+    else if value == false then "off"
+    else if isList value   then concatStringsSep "," value
+    else toString value;
+
+  sec = [ "znc.username" "znc.password" ];
+
+  ignores = [
+    "osmbot-test oftc #osm-gb"
+  ];
+
+  cfgin = {
+    alias.cmd.B = "buffer";
+    alias.cmd.ZNC = "quote znc";
+    irc.look.buffer_switch_autojoin = false;
+    irc.look.color_nicks_in_nicklist = true;
+    irc.look.display_join_message = false;
+    irc.look.server_buffer = "independent";
+    irc.look.temporary_servers = true;
+
+    irc.server_default = {
+      addresses = "znc.qyliss.net/6697";
+      autoconnect = true;
+      capabilities = [ "account-notify" "away-notify" "cap-notify" "multi-prefix" "server-time" "znc.in/server-time-iso" "znc.in/self-message" "znc.in/playback" ];
+      nicks = "qyliss";
+      password = "\\\${sec.data.znc.password}";
+      ssl = true;
+      username = "\\\${sec.data.znc.username}";
+    };
+
+    irc.server = genAttrs networks (s:
+      { username = "'\\\${sec.data.znc.username}/${s}'"; });
+    plugins.var.python.zncplayback.servers = networks;
+
+    logger.color.backlog_end = "*default";
+    logger.look.backlog = 200;
+
+    matrix.look.human_buffer_names = true;
+    matrix.server = flip mapAttrs matrixServers (name: _: {
+      autoconnect = true;
+      device_name = "WeeChat (${config.networking.hostName})";
+      username = "qyliss";
+      password = "\\\${sec.data.fairydust.password}";
+    });
+
+    script.look.sort = "p,n";
+
+    weechat.bar.buflist.hidden = true;
+    weechat.bar.fset.items = "fset";
+    weechat.bar.input.items = "[mode_indicator]+ [input_prompt]+(away),[input_search],[input_paste],input_text";
+    weechat.bar.nicklist.size_max = 12;
+    weechat.bar.status.color_bg = 53;
+    weechat.bar.status.items = "[otr],[buffer_plugin],buffer_name+(buffer_modes)+{buffer_nicklist_count}+buffer_zoom+buffer_filter,[matrix_typing_notice],scroll,[lag],[hotlist],completion";
+    weechat.bar.title.color_bg = 53;
+
+    weechat.color.chat_nick_colors = [ "cyan" "magenta" "green" "brown" "lightblue" "lightcyan" "lightmagenta" "lightgreen" "blue" ];
+    weechat.color.chat_nick_self = "default";
+    weechat.color.status_count_other = "white";
+    weechat.color.status_data_other = "white";
+    weechat.color.status_nicklist_count = "white";
+    weechat.color.status_time = "white";
+
+    weechat.completion.default_template = "%(nicks)|%(irc_channels)|%(emoji)";
+    weechat.completion.nick_completer = ":";
+
+    weechat.look.buffer_notify_default = "message";
+    weechat.look.highlight = [
+      "spectrum" "spectrumos" "pr-tracker" "crosvm"
+      "qyliss" "alyssa*" "*dntmissher" "*@alyssa"
+    ];
+    weechat.look.hotlist_names_count = 10;
+    weechat.look.hotlist_names_level = 14;
+    weechat.look.mouse = true;
+    weechat.look.prefix_align_max = 12;
+    weechat.look.save_config_on_exit = false;
+    weechat.look.window_title = "WeeChat \\\${info:version}";
+  };
+
+  commands =
+    map (n: "/server add ${n} ${cfgin.irc.server_default.addresses}") networks ++
+    [ "/matrix server delete matrix_org" ] ++
+    mapAttrsToList (n: d: "/matrix server add ${n} ${d}") matrixServers ++
+    map (i: "/ignore add ${i}") ignores ++
+    mapAttrsToList (name: value: "/set ${name} ${toWeeChat value}")
+                   (flattenAttrs cfgin);
+
+  flattenAttrs' = sep: attrs:
+    listToAttrs (concatLists (flip mapAttrsToList attrs (k: v:
+      if isAttrs v then mapAttrsToList (k': nameValuePair "${k}${sep}${k'}") (flattenAttrs' sep v)
+                 else [ (nameValuePair k v) ])));
+
+  flattenAttrs = flattenAttrs' ".";
+
+  # If we were to pass --run-command ourselves, it would get forwarded
+  # to WeeChat before the /script loads, so scripts wouldn't be
+  # available and we wouldn't be able to do stuff like set up Matrix.
+  cfg = pkgs.runCommand "weechat-config" {} ''
+    LC_ALL=C.UTF-8 ${pkgs.weechat.override {
+      configure = { ... }: {
+        inherit scripts;
+        init = concatStringsSep ";" (commands ++ [ "/save" "/exit" ]);
+      };
+    }}/bin/weechat-headless -d $out
+  '';
+
+  weechatHome = "${config.users.users.qyliss.home}/state/weechat";
+
+in
+
+{
+  environment.extraInit = ''
+    export WEECHAT_HOME="$HOME/state/weechat"
+  '';
+
+  environment.systemPackages = with pkgs; [
+    (weechat.override {
+      configure = { ...}: {
+        inherit scripts;
+      };
+    })
+
+    weechatScripts.weechat-matrix # for helper scripts
+  ];
+
+  systemd.tmpfiles.packages = [ (pkgs.runCommand "weechat-tmpfiles" {} ''
+    conf=$out/lib/tmpfiles.d/weechat.conf
+    mkdir -p $(dirname $conf)
+    echo "d ${weechatHome} 0700 qyliss qyliss" > $conf
+    for file in ${cfg}/*.conf
+    do
+        if [ "$file" != ${cfg}/sec.conf ]
+        then echo "L+ ${weechatHome}/$(basename "$file") - - - - $file" >> $conf
+        fi
+    done
+  '') ];
+}
diff --git a/modules/workstation/windowing/default.nix b/modules/workstation/windowing/default.nix
new file mode 100644
index 000000000000..f7ff2b8a4272
--- /dev/null
+++ b/modules/workstation/windowing/default.nix
@@ -0,0 +1,13 @@
+{ pkgs, ... }:
+
+{
+  imports = [
+    ./firefox ./foot ./gtk ./sway
+  ];
+
+  environment.systemPackages = with pkgs; [
+    breeze-icons gnome3.adwaita-icon-theme gnome3.gnome-mines
+    gnome-podcasts hicolor-icon-theme imv libreoffice okular pinball
+    playerctl wf-recorder
+  ];
+}
diff --git a/modules/workstation/windowing/firefox/default.nix b/modules/workstation/windowing/firefox/default.nix
new file mode 100644
index 000000000000..ccd9afd87843
--- /dev/null
+++ b/modules/workstation/windowing/firefox/default.nix
@@ -0,0 +1,19 @@
+{ pkgs, ... }:
+
+{
+  environment.systemPackages = with pkgs; [
+    (wrapFirefox firefox-unwrapped {
+      extraPolicies = {
+        DefaultDownloadDirectory = "/tmp";
+        DisablePocket = true;
+        FirefoxHome = {
+          TopSites = false;
+          SponsoredTopSites = false;
+          SponsoredPocket = false;
+        };
+      };
+    })
+  ];
+
+  environment.variables.BROWSER = "firefox";
+}
diff --git a/modules/workstation/windowing/foot/default.nix b/modules/workstation/windowing/foot/default.nix
new file mode 100644
index 000000000000..5c66f18eb03f
--- /dev/null
+++ b/modules/workstation/windowing/foot/default.nix
@@ -0,0 +1,7 @@
+{ pkgs, ... }:
+
+{
+  users.users.qyliss.xdg.config.paths."foot/foot.ini" = ./foot.ini;
+
+  environment.systemPackages = with pkgs; [ foot ];
+}
diff --git a/modules/workstation/windowing/foot/foot.ini b/modules/workstation/windowing/foot/foot.ini
new file mode 100644
index 000000000000..d041f42bceac
--- /dev/null
+++ b/modules/workstation/windowing/foot/foot.ini
@@ -0,0 +1,2 @@
+[main]
+font = monospace:size=12
diff --git a/modules/workstation/windowing/gtk/default.nix b/modules/workstation/windowing/gtk/default.nix
new file mode 100644
index 000000000000..df66f8fbb72d
--- /dev/null
+++ b/modules/workstation/windowing/gtk/default.nix
@@ -0,0 +1,9 @@
+{ pkgs, ... }:
+
+{
+  users.users.qyliss.xdg.config.paths."gtk-3.0/settings.ini" =
+    pkgs.copyPathToStore ./settings.ini;
+
+  # Needed for Dino to not draw its title bar.
+  environment.variables.GTK_CSD = "0";
+}
diff --git a/modules/workstation/windowing/gtk/settings.ini b/modules/workstation/windowing/gtk/settings.ini
new file mode 100644
index 000000000000..29322c1b3a0d
--- /dev/null
+++ b/modules/workstation/windowing/gtk/settings.ini
@@ -0,0 +1,2 @@
+[Settings]
+gtk-application-prefer-dark-theme=1
diff --git a/modules/workstation/windowing/streaming/default.nix b/modules/workstation/windowing/streaming/default.nix
new file mode 100644
index 000000000000..d6d5451ca6ff
--- /dev/null
+++ b/modules/workstation/windowing/streaming/default.nix
@@ -0,0 +1,30 @@
+{ config, pkgs, ... }:
+
+let
+  obsStateDir = "${config.users.users.qyliss.home}/state/obs-studio";
+in
+
+{
+  environment.systemPackages = with pkgs; [ carla obs-studio qpwgraph ];
+
+  environment.variables.LV2_PATH = with pkgs; lib.makeSearchPathOutput "out" "lib/lv2" [
+    calf
+    dragonfly-reverb
+    lsp-plugins
+    x42-plugins
+
+    # Workaround for https://github.com/werman/noise-suppression-for-voice/issues/158
+    (runCommand "rrnoise-plugin-mono-lv2" {} ''
+      mkdir $out
+      cd ${rnnoise-plugin}
+      cp -R --parents lib/lv2/rnnoise_mono.lv2 $out
+    '')
+  ];
+
+  systemd.tmpfiles.rules = [
+    "d ${obsStateDir} 0700 qyliss qyliss"
+  ];
+
+  users.users.qyliss.xdg.config.paths."obs-studio" =
+    pkgs.runCommand "obs-studio" {} "ln -s ${obsStateDir} $out";
+}
diff --git a/modules/workstation/windowing/sway/choose_workspace.nix b/modules/workstation/windowing/sway/choose_workspace.nix
new file mode 100644
index 000000000000..fc162d627b60
--- /dev/null
+++ b/modules/workstation/windowing/sway/choose_workspace.nix
@@ -0,0 +1,9 @@
+{ substituteAll, bemenu, jq }:
+
+substituteAll {
+  dir = "bin";
+  name = "choose_workspace";
+  src = ./choose_workspace.sh.in;
+  isExecutable = true;
+  inherit bemenu jq;
+}
diff --git a/modules/workstation/windowing/sway/choose_workspace.sh.in b/modules/workstation/windowing/sway/choose_workspace.sh.in
new file mode 100644
index 000000000000..963746e0c810
--- /dev/null
+++ b/modules/workstation/windowing/sway/choose_workspace.sh.in
@@ -0,0 +1,17 @@
+#! @shell@ -ue
+swaymsg -t get_workspaces |
+    @jq@/bin/jq -r \
+        '(to_entries | map(select(.value.focused)) | .[0].key), .[].name' |
+    (
+        read index
+        exec @bemenu@/bin/bemenu \
+            -p workspace \
+            -I "$index" \
+            -H 24 \
+            --fn 'monospace 10' \
+            --nf '#777777' \
+            --hb '#285577' \
+            --hf '#ffffff' \
+            --tf '#777777' \
+            --ff '#ffffff'
+    )
diff --git a/modules/workstation/windowing/sway/config.in b/modules/workstation/windowing/sway/config.in
new file mode 100644
index 000000000000..52efc1fa90e3
--- /dev/null
+++ b/modules/workstation/windowing/sway/config.in
@@ -0,0 +1,125 @@
+set $mod Mod4
+set $left h
+set $down j
+set $up k
+set $right l
+
+default_border pixel
+default_floating_border normal
+
+client.focused_inactive #333333 #5f676a #ffffff #484e50 #5f676a00
+client.unfocused        #333333 #222222 #888888 #292d2e #22222200
+
+for_window [app_id="float"] floating enable
+for_window [app_id="firefox" title="Picture-in-Picture"] floating enable
+for_window [class="Tor Browser"] floating enable
+for_window [class="XDvi"] floating enable
+for_window [instance="xdvi"] floating enable
+for_window [class="XDvi" instance="xdvi"] floating disable
+for_window [app_id="firefox" title="NoScript.*"] floating enable
+
+# Stop the Firefox sharing indicator (that appears when on an
+# audio/video call) tiling, or appearing in the center of the display,
+# or being focused.
+no_focus [title="(Firefox|Nightly) . Sharing Indicator"]
+for_window [title="(Firefox|Nightly) . Sharing Indicator"] {
+    floating enable
+    sticky enable
+
+    # I'd really like this to be at the top, horizontally centered.
+    # Or maybe at the bottom right.  But there's not really a way to
+    # do that in sway configuration, as far as I can tell.  I think
+    # I'd need to exec a program that would measure the display size
+    # and compute the coordinates or something.  That sounds horrible
+    # and fragile, so top left it is.
+    move position 0 0
+}
+
+input * natural_scroll enabled
+
+bindsym $mod+Return exec foot
+bindsym $mod+backslash exec firefox
+bindsym $mod+BackSpace kill
+bindsym $mod+d exec swaymsg exec "$(choosebin --tiebreak=begin,length,index)"
+
+# Brightness control
+bindsym $mod+F5 exec brightnessctl s 10%-
+bindsym $mod+F6 exec brightnessctl s 10%+
+
+# MPRIS
+bindsym $mod+F7 exec playerctl play-pause
+
+# PulseAudio
+bindsym $mod+F8 exec pactl set-sink-mute @DEFAULT_SINK@ toggle
+bindsym $mod+F9 exec pactl set-sink-volume @DEFAULT_SINK@ -5%
+bindsym $mod+F10 exec pactl set-sink-volume @DEFAULT_SINK@ +5%
+bindsym XF86AudioLowerVolume exec pactl set-sink-volume @DEFAULT_SINK@ -5%
+bindsym XF86AudioRaiseVolume exec pactl set-sink-volume @DEFAULT_SINK@ +5%
+
+# Drag floating windows by holding down $mod and left mouse button.
+# Resize them with right mouse button + $mod.
+# Despite the name, also works for non-floating windows.
+# Change normal to inverse to use left mouse button for resizing and right
+# mouse button for dragging.
+floating_modifier $mod normal
+
+# reload the configuration file
+bindsym $mod+Shift+c reload
+
+# exit sway (logs you out of your Wayland session)
+bindsym $mod+Shift+e exec swaynag -t warning -m 'You pressed the exit shortcut. Do you really want to exit sway? This will end your Wayland session.' -b 'Yes, exit sway' 'swaymsg exit'
+
+bindsym $mod+$left focus left
+bindsym $mod+$down focus down
+bindsym $mod+$up focus up
+bindsym $mod+$right focus right
+
+bindsym $mod+Shift+$left move left
+bindsym $mod+Shift+$down move down
+bindsym $mod+Shift+$up move up
+bindsym $mod+Shift+$right move right
+
+bindsym $mod+g exec swaymsg workspace "$(@choose_workspace@)"
+bindsym $mod+Shift+g exec swaymsg move container to workspace "$(@choose_workspace@)"
+
+bindsym $mod+b splith
+bindsym $mod+v splitv
+
+bindsym $mod+s layout stacking
+bindsym $mod+w layout tabbed
+bindsym $mod+e layout toggle split
+
+bindsym $mod+f fullscreen
+
+bindsym $mod+Shift+space floating toggle
+bindsym $mod+space focus mode_toggle
+
+bindsym $mod+a focus parent
+
+bindsym $mod+Shift+minus move scratchpad
+bindsym $mod+minus scratchpad show
+
+mode "resize" {
+    bindsym $left resize shrink width 10px
+    bindsym $down resize grow height 10px
+    bindsym $up resize shrink height 10px
+    bindsym $right resize grow width 10px
+
+    bindsym Return mode "default"
+    bindsym Escape mode "default"
+}
+bindsym $mod+r mode "resize"
+
+bar {
+    position top
+
+    status_command @status_command@
+
+    colors {
+        statusline #ffffff
+        background #00000077
+        inactive_workspace #33333377 #00000077 #FFFFFF77
+    }
+}
+
+@extraConfig@
diff --git a/modules/workstation/windowing/sway/default.nix b/modules/workstation/windowing/sway/default.nix
new file mode 100644
index 000000000000..675ef8dbc031
--- /dev/null
+++ b/modules/workstation/windowing/sway/default.nix
@@ -0,0 +1,49 @@
+{ pkgs, lib, config, ... }:
+
+let
+  inherit (lib) mdDoc mkOption optionalString;
+  inherit (lib.types) lines nullOr path;
+  inherit (pkgs) callPackage substituteAll;
+
+  cfg = config.programs.sway;
+in
+
+{
+  imports = [ ./swayidle ./swaylock ./wlsunset ./xdg-desktop-portal-wlr ];
+
+  options = {
+    programs.sway.extraConfig = mkOption {
+      type = lines;
+      description = mdDoc "Lines to append to sway's config file";
+      default = "";
+    };
+
+    programs.sway.wallpaper = mkOption {
+      type = nullOr path;
+      description = mdDoc "Path to wallpaper for sway and swaylock";
+      default = null;
+    };
+  };
+
+  config = {
+    environment.systemPackages = with pkgs; [ bemenu choose swayidle ];
+
+    programs.sway.enable = true;
+    programs.sway.wallpaper = callPackage ./wallpaper.nix { };
+    programs.sway.extraPackages = []; # extra packages can go in systemPackages.
+
+    programs.swayidle.enable = true;
+
+    users.users.qyliss.xdg.config.paths."sway/config" = substituteAll {
+      src = ./config.in;
+      choose_workspace =
+        "${callPackage ./choose_workspace.nix { }}/bin/choose_workspace";
+      status_command = "${callPackage ./status.nix { }}/bin/status";
+      extraConfig = cfg.extraConfig + optionalString (cfg.wallpaper != null) ''
+        output * bg ${cfg.wallpaper} fill
+      '';
+    };
+
+    xdg.portal.extraPortals = with pkgs; [ xdg-desktop-portal-gtk ];
+  };
+}
diff --git a/modules/workstation/windowing/sway/status.cpp b/modules/workstation/windowing/sway/status.cpp
new file mode 100644
index 000000000000..1f73458d86b2
--- /dev/null
+++ b/modules/workstation/windowing/sway/status.cpp
@@ -0,0 +1,201 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Copyright 2020 Alyssa Ross
+//
+// This program 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.
+//
+// This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.
+
+#include <chrono>
+#include <fcntl.h>
+#include <filesystem>
+#include <iostream>
+#include <thread>
+#include <unistd.h>
+
+namespace fs = std::filesystem;
+
+using fs::directory_iterator;
+using std::chrono::seconds;
+using std::generic_category;
+using std::put_time;
+using std::stoi;
+using std::string;
+using std::stringstream;
+using std::system_error;
+using std::this_thread::sleep_for;
+using std::time;
+
+enum BatteryStatus {
+	Unknown,
+	Charging,
+	Discharging,
+	NotCharging,
+	Full,
+};
+
+class Battery {
+public:
+	Battery(string name);
+
+	string name() { return m_name; }
+	fs::path path();
+	BatteryStatus status();
+	int capacity();
+	int charge_now();
+
+private:
+	string m_name;
+	string attr(string name);
+};
+
+Battery::Battery(string name)
+{
+	m_name = name;
+}
+
+fs::path Battery::path()
+{
+	static fs::path base = "/sys/class/power_supply";
+	return base / name();
+}
+
+BatteryStatus Battery::status()
+{
+	auto status = attr("status");
+
+	if (status == "Charging")
+		return Charging;
+	if (status == "Discharging")
+		return Discharging;
+	if (status == "Not charging")
+		return NotCharging;
+	if (status == "Full")
+		return Full;
+
+	return Unknown;
+}
+
+int Battery::capacity()
+{
+	return stoi(attr("capacity"));
+}
+
+int Battery::charge_now()
+{
+	return stoi(attr("charge_now"));
+}
+
+string Battery::attr(string name)
+{
+	// Use read() to make sure this is done in a single read syscall,
+	// because sysfs doesn't like multiple reads.
+	int fd = open((path() / name).c_str(), O_RDONLY);
+	if (fd == -1)
+		throw system_error(errno, generic_category());
+	char buf[13];
+	int len = read(fd, &buf, 13);
+	if (len == -1)
+		throw system_error(errno, generic_category());
+	close(fd);
+	string value (buf, len);
+	if (value.back() == '\n')
+		value.pop_back();
+	return value;
+}
+
+int main(void)
+{
+	while (true) {
+		// Buffer output so it can be done all at once in a single write(),
+		// because of <https://github.com/swaywm/sway/issues/3857>.
+		stringstream out;
+
+		for (const auto& entry : directory_iterator("/sys/class/power_supply")) {
+			auto name = entry.path().filename().string();
+
+			Battery battery (name);
+			auto batdisplay = false;
+
+			try {
+				switch (battery.status()) {
+				case Charging:
+					out << "↑";
+					break;
+				case Discharging:
+					out << "↓";
+					break;
+				default:
+					out << " ";
+				}
+				batdisplay = true;
+			} catch (const system_error& ex) {
+				switch (ex.code().value()) {
+				case ENOENT:
+					break;
+				case ENODEV:
+					out << "? ";
+					batdisplay = true;
+					break;
+				default:
+					throw ex;
+				}
+			}
+
+			try {
+				int capacity = battery.capacity();
+				out << capacity << "%";
+				batdisplay = true;
+			} catch (const system_error& ex) {
+				switch (ex.code().value()) {
+				case ENOENT:
+					break;
+				case ENODEV:
+					out << "??%";
+					batdisplay = true;
+					break;
+				default:
+					throw ex;
+				}
+
+				try {
+					int charge_now = battery.charge_now();
+					out << charge_now;
+					batdisplay = true;
+				} catch (const system_error& ex) {
+					switch (ex.code().value()) {
+					case ENOENT:
+						break;
+					case ENODEV:
+						out << "??????";
+						batdisplay = true;
+						break;
+					default:
+						throw ex;
+					}
+				}
+			}
+
+			if (batdisplay)
+				out << "  ";
+		}
+
+		auto t = time(nullptr);
+		out << put_time(localtime(&t), "%F %T") << "\n";
+
+		auto buf = out.str();
+		if (write(STDOUT_FILENO, buf.c_str(), buf.length()) == -1)
+			perror("write");
+
+		sleep_for(seconds(1));
+	}
+}
diff --git a/modules/workstation/windowing/sway/status.nix b/modules/workstation/windowing/sway/status.nix
new file mode 100644
index 000000000000..2697317d7611
--- /dev/null
+++ b/modules/workstation/windowing/sway/status.nix
@@ -0,0 +1,6 @@
+{ runCommandCC }:
+
+runCommandCC "status" {} ''
+  mkdir -p $out/bin
+  c++ -std=c++17 -o $out/bin/status ${./status.cpp}
+''
diff --git a/modules/workstation/windowing/sway/swayidle/default.nix b/modules/workstation/windowing/sway/swayidle/default.nix
new file mode 100644
index 000000000000..8e5f264b5038
--- /dev/null
+++ b/modules/workstation/windowing/sway/swayidle/default.nix
@@ -0,0 +1,23 @@
+{ lib, config, ... }:
+
+let
+  cfg = config.programs.swayidle;
+in
+
+with lib;
+
+{
+  options = {
+    programs.swayidle.enable = mkEnableOption "swayidle";
+  };
+
+  config = mkIf cfg.enable {
+    programs.sway.extraConfig = ''
+      exec swayidle \
+          timeout 300 'swaylock -c 000000' \
+          timeout 600 'swaymsg "output * dpms off"' \
+          resume 'swaymsg "output * dpms on"' \
+          before-sleep 'swaylock -c 000000'
+    '';
+  };
+}
diff --git a/modules/workstation/windowing/sway/swaylock/config.in b/modules/workstation/windowing/sway/swaylock/config.in
new file mode 100644
index 000000000000..c6f280aa8f5b
--- /dev/null
+++ b/modules/workstation/windowing/sway/swaylock/config.in
@@ -0,0 +1,3 @@
+image=@wallpaper@
+indicator-idle-visible
+show-failed-attempts
diff --git a/modules/workstation/windowing/sway/swaylock/default.nix b/modules/workstation/windowing/sway/swaylock/default.nix
new file mode 100644
index 000000000000..be15c87ade32
--- /dev/null
+++ b/modules/workstation/windowing/sway/swaylock/default.nix
@@ -0,0 +1,12 @@
+{ pkgs, config, ... }:
+
+{
+  imports = [ ../../../../xdg ];
+
+  environment.systemPackages = with pkgs; [ swaylock ];
+
+  users.users.qyliss.xdg.config.paths."swaylock/config" = pkgs.substituteAll {
+    src = ./config.in;
+    wallpaper = config.programs.sway.wallpaper;
+  };
+}
diff --git a/modules/workstation/windowing/sway/wallpaper.nix b/modules/workstation/windowing/sway/wallpaper.nix
new file mode 100644
index 000000000000..bed6bbe33a6b
--- /dev/null
+++ b/modules/workstation/windowing/sway/wallpaper.nix
@@ -0,0 +1,10 @@
+{ fetchurl }:
+
+fetchurl {
+  url = "https://mir-s3-cdn-cf.behance.net/project_modules/2800_opt_1/36731876964505.5c793fa788b5d.jpg";
+  sha256 = "1c6camdipng8ws41sgpcxzrxb96crgip3wirqjgf2ajn60qg3v64";
+
+  meta = {
+    homepage = "https://www.behance.net/gallery/76964505/IQOO-style-frame-and-scene-design";
+  };
+}
diff --git a/modules/workstation/windowing/sway/wlsunset/default.nix b/modules/workstation/windowing/sway/wlsunset/default.nix
new file mode 100644
index 000000000000..ba954f3cd3bd
--- /dev/null
+++ b/modules/workstation/windowing/sway/wlsunset/default.nix
@@ -0,0 +1,9 @@
+{ pkgs, ... }:
+
+{
+  environment.systemPackages = with pkgs; [ wlsunset ];
+  
+  programs.sway.extraConfig = ''
+    exec wlsunset -l 51.5 -L 13.6
+  '';
+}
diff --git a/modules/workstation/windowing/sway/xdg-desktop-portal-wlr/default.nix b/modules/workstation/windowing/sway/xdg-desktop-portal-wlr/default.nix
new file mode 100644
index 000000000000..892e6a280d4c
--- /dev/null
+++ b/modules/workstation/windowing/sway/xdg-desktop-portal-wlr/default.nix
@@ -0,0 +1,13 @@
+{ pkgs, ... }:
+
+{
+  xdg.portal.wlr.enable = true;
+
+  programs.sway.extraConfig = ''
+    exec ${pkgs.writeShellScript "sway-portal-environment" ''
+      set -e
+      dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP
+      systemctl --user stop pipewire xdg-desktop-portal xdg-desktop-portal-wlr
+    ''}
+  '';
+}