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/default.nix16
-rw-r--r--modules/workstation/dict/default.nix6
-rw-r--r--modules/workstation/dino/default.nix7
-rw-r--r--modules/workstation/documentation/default.nix6
-rw-r--r--modules/workstation/emacs/default.el0
-rw-r--r--modules/workstation/emacs/default.nix14
-rw-r--r--modules/workstation/fonts/default.nix7
-rw-r--r--modules/workstation/gnupg/default.nix30
-rw-r--r--modules/workstation/gnupg/dirmngr.conf1
-rw-r--r--modules/workstation/gnupg/gpg.conf3
-rw-r--r--modules/workstation/hardware/default.nix9
-rw-r--r--modules/workstation/hardware/keyboard/MAPPINGS7
-rw-r--r--modules/workstation/hardware/keyboard/default.nix22
-rw-r--r--modules/workstation/hardware/keyboard/events.dyon14
-rw-r--r--modules/workstation/hardware/yubikey/default.nix13
-rw-r--r--modules/workstation/hardware/yubikey/u2f_keys1
-rw-r--r--modules/workstation/locale/default.nix6
-rw-r--r--modules/workstation/lorri/default.nix10
-rw-r--r--modules/workstation/mail/default.nix47
-rw-r--r--modules/workstation/mail/mbsyncrc.in22
-rw-r--r--modules/workstation/mail/mutt/default.nix9
-rw-r--r--modules/workstation/mail/mutt/muttrc56
-rw-r--r--modules/workstation/mail/notmuch/config15
-rw-r--r--modules/workstation/mail/notmuch/default.nix12
-rw-r--r--modules/workstation/mail/postfix/default.nix32
-rw-r--r--modules/workstation/mail/rss2email/default.nix16
-rw-r--r--modules/workstation/networking/default.nix46
-rw-r--r--modules/workstation/physical/default.nix7
-rw-r--r--modules/workstation/podman/default.nix47
-rw-r--r--modules/workstation/weechat/default.nix121
-rw-r--r--modules/workstation/windowing/alacritty/config.yml340
-rw-r--r--modules/workstation/windowing/alacritty/default.nix11
-rw-r--r--modules/workstation/windowing/default.nix7
-rw-r--r--modules/workstation/windowing/firefox/default.nix13
-rw-r--r--modules/workstation/windowing/firefox/profiles.ini8
-rw-r--r--modules/workstation/windowing/firefox/user.js1
-rw-r--r--modules/workstation/windowing/gnome-mines/default.nix7
-rw-r--r--modules/workstation/windowing/sway/choose_workspace.sh.in17
-rw-r--r--modules/workstation/windowing/sway/config.in110
-rw-r--r--modules/workstation/windowing/sway/default.nix52
-rw-r--r--modules/workstation/windowing/sway/status.cpp159
-rw-r--r--modules/workstation/windowing/sway/swayidle/default.nix23
42 files changed, 1350 insertions, 0 deletions
diff --git a/modules/workstation/default.nix b/modules/workstation/default.nix
new file mode 100644
index 000000000000..3f4e3f1c8ce7
--- /dev/null
+++ b/modules/workstation/default.nix
@@ -0,0 +1,16 @@
+{ lib, pkgs, ... }:
+
+{
+  imports = [
+    ../nix ../shell ../users ../ssh
+    ./documentation ./windowing ./fonts ./hardware ./locale
+    ./dict ./dino ./emacs ./gnupg ./lorri ./mail ./podman ./weechat
+  ];
+
+  environment.systemPackages = with pkgs; [ mosh mpv youtube-dl ];
+
+  services.mingetty.autologinUser = "qyliss";
+  services.mingetty.loginOptions = "-- \u";
+  services.locate.enable = true;
+
+}
diff --git a/modules/workstation/dict/default.nix b/modules/workstation/dict/default.nix
new file mode 100644
index 000000000000..c21e20e947a4
--- /dev/null
+++ b/modules/workstation/dict/default.nix
@@ -0,0 +1,6 @@
+{ pkgs, ... }:
+
+{
+  services.dictd.enable = true;
+  services.dictd.DBs = with pkgs.dictdDBs; [ epo2eng ];
+}
diff --git a/modules/workstation/dino/default.nix b/modules/workstation/dino/default.nix
new file mode 100644
index 000000000000..1c483d4fc473
--- /dev/null
+++ b/modules/workstation/dino/default.nix
@@ -0,0 +1,7 @@
+{ pkgs, ... }:
+
+{
+  environment.systemPackages = with pkgs; [ dino ];
+
+  home.qyliss.dirs."state/dino" = {};
+}
diff --git a/modules/workstation/documentation/default.nix b/modules/workstation/documentation/default.nix
new file mode 100644
index 000000000000..ecaad504dda4
--- /dev/null
+++ b/modules/workstation/documentation/default.nix
@@ -0,0 +1,6 @@
+{ pkgs, ... }:
+
+{
+  environment.systemPackages = with pkgs;
+    [ glibcInfo gnumake.info man-pages (lowPrio mandoc) ];
+}
diff --git a/modules/workstation/emacs/default.el b/modules/workstation/emacs/default.el
new file mode 100644
index 000000000000..e69de29bb2d1
--- /dev/null
+++ b/modules/workstation/emacs/default.el
diff --git a/modules/workstation/emacs/default.nix b/modules/workstation/emacs/default.nix
new file mode 100644
index 000000000000..a0432bd25ba5
--- /dev/null
+++ b/modules/workstation/emacs/default.nix
@@ -0,0 +1,14 @@
+{ pkgs, ... }:
+
+{
+  environment.systemPackages = with pkgs; [
+    (emacsWithPackages (epkgs: [
+      (runCommandNoCC "default.el" {} ''
+        mkdir -p $out/share/emacs/site-lisp
+        cp ${./default.el} $out/share/emacs/site-lisp/default.el
+      '')
+    ] ++ (with epkgs; [
+      magit
+    ])))
+  ];
+}
diff --git a/modules/workstation/fonts/default.nix b/modules/workstation/fonts/default.nix
new file mode 100644
index 000000000000..ae590859897f
--- /dev/null
+++ b/modules/workstation/fonts/default.nix
@@ -0,0 +1,7 @@
+{ pkgs, ... }:
+
+{
+  fonts.fonts = with pkgs; [ iosevka twemoji-color-font noto-fonts-cjk ];
+  fonts.fontconfig.allowBitmaps = false;
+  fonts.fontconfig.defaultFonts.monospace = [ "Iosevka" ];
+}
diff --git a/modules/workstation/gnupg/default.nix b/modules/workstation/gnupg/default.nix
new file mode 100644
index 000000000000..a93a31411d08
--- /dev/null
+++ b/modules/workstation/gnupg/default.nix
@@ -0,0 +1,30 @@
+{ pkgs, ... }:
+
+{
+  home.qyliss.dirs."state/gnupg".activationScripts.config =
+    let
+      pinentry = 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 ${pinentry}
+      '';
+    in ''
+      ln -sf ${./dirmngr.conf} dirmngr.conf
+      ln -sf ${./gpg.conf} gpg.conf
+      ln -sf ${gpg-agent-conf} gpg-agent.conf
+    '';
+
+  environment.systemPackages = with pkgs; [ gnupg ];
+
+  environment.extraInit = ''
+    export GNUPGHOME="$HOME/state/gnupg"
+    export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
+  '';
+
+  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/default.nix b/modules/workstation/hardware/default.nix
new file mode 100644
index 000000000000..3c33ee2b4a1b
--- /dev/null
+++ b/modules/workstation/hardware/default.nix
@@ -0,0 +1,9 @@
+{ pkgs, ... }:
+
+{
+  imports = [ ./keyboard ./yubikey ];
+
+  environment.systemPackages = with pkgs; [ ddrescue ];
+
+  sound.enable = true;
+}
diff --git a/modules/workstation/hardware/keyboard/MAPPINGS b/modules/workstation/hardware/keyboard/MAPPINGS
new file mode 100644
index 000000000000..60ded39c8cf1
--- /dev/null
+++ b/modules/workstation/hardware/keyboard/MAPPINGS
@@ -0,0 +1,7 @@
+Key remappings are spread across several different places, because they
+have to be done differently depending on the remapping.
+
+Here is an overview of remapped keys:
+
+Caps Lock: Escape if pressed, Ctrl if held
+Tab: Super-L if pressed, Tab if held
diff --git a/modules/workstation/hardware/keyboard/default.nix b/modules/workstation/hardware/keyboard/default.nix
new file mode 100644
index 000000000000..092854d535b4
--- /dev/null
+++ b/modules/workstation/hardware/keyboard/default.nix
@@ -0,0 +1,22 @@
+{ pkgs, config, ... }:
+
+let
+  xcfg = config.services.xserver;
+
+in
+{
+  console.useXkbConfig = true;
+  services.xserver.layout = "dvorak";
+  services.xserver.xkbOptions = "ctrl:nocaps,compose:menu";
+
+  environment.variables.XKB_DEFAULT_LAYOUT = xcfg.layout;
+  environment.variables.XKB_DEFAULT_OPTIONS = xcfg.xkbOptions;
+
+  services.evscript.enable = true;
+  services.evscript.script = ./events.dyon;
+
+  boot.postBootCommands = ''
+    # Remap tab to left super
+    /run/current-system/sw/bin/setkeycodes 0f 125
+  '';
+}
diff --git a/modules/workstation/hardware/keyboard/events.dyon b/modules/workstation/hardware/keyboard/events.dyon
new file mode 100644
index 000000000000..96cc15450e46
--- /dev/null
+++ b/modules/workstation/hardware/keyboard/events.dyon
@@ -0,0 +1,14 @@
+//! [events]
+//! keys = ['ESC', 'TAB']
+fn main() ~ evdevs, uinput {
+    should_esc := false
+    should_tab := false
+    loop {
+        evts := next_events(evdevs)
+        for i {
+            evt := evts[i]
+            xcape(mut should_esc, evt, KEY_CAPSLOCK(), [KEY_ESC()])
+            xcape(mut should_tab, evt, KEY_LEFTMETA(), [KEY_TAB()])
+        }
+    }
+}
diff --git a/modules/workstation/hardware/yubikey/default.nix b/modules/workstation/hardware/yubikey/default.nix
new file mode 100644
index 000000000000..d047246bb20d
--- /dev/null
+++ b/modules/workstation/hardware/yubikey/default.nix
@@ -0,0 +1,13 @@
+{ pkgs, ... }:
+
+{
+  services.udev.packages = with pkgs; [ yubikey-personalization ];
+
+  security.pam.services.sudo.u2fAuth = true;
+  security.sudo.extraConfig = ''
+    Defaults timestamp_timeout=0
+  '';
+
+  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..c5601f5f0703
--- /dev/null
+++ b/modules/workstation/hardware/yubikey/u2f_keys
@@ -0,0 +1 @@
+qyliss:HIWnn91xwo-f14WjdDSdiX2Rs9NdJr2QF4_5_gYUpTkennbpR8AOXHmfEzj5llyLyb-_WEaVUQU59ieCamq9SA,044196971044cff2724ea9ab4624ef860fde32337acc6c3a323899f36a50bf87d06f535c146111f96925455f8c07addff769dfd502c216a9683c70898bb521ada5
diff --git a/modules/workstation/locale/default.nix b/modules/workstation/locale/default.nix
new file mode 100644
index 000000000000..8028bd36e271
--- /dev/null
+++ b/modules/workstation/locale/default.nix
@@ -0,0 +1,6 @@
+{ ... }:
+
+{
+  i18n.defaultLocale = "eo.utf8";
+  environment.sessionVariables.LC_CTYPE = "en_GB.utf8";
+}
diff --git a/modules/workstation/lorri/default.nix b/modules/workstation/lorri/default.nix
new file mode 100644
index 000000000000..4008934dab01
--- /dev/null
+++ b/modules/workstation/lorri/default.nix
@@ -0,0 +1,10 @@
+{ ... }:
+
+{
+  home.qyliss.dirs."state/lorri" = {};
+
+  services.lorri.enable = true;
+
+  # FIXME: systemd should have this set globally.
+  systemd.user.services.lorri.environment.XDG_CACHE_HOME = "/home/state/cache";
+}
diff --git a/modules/workstation/mail/default.nix b/modules/workstation/mail/default.nix
new file mode 100644
index 000000000000..c53128520812
--- /dev/null
+++ b/modules/workstation/mail/default.nix
@@ -0,0 +1,47 @@
+{ pkgs, config, ... }:
+
+let
+  maildir = "${config.users.users.qyliss.home}/mail";
+  mbsyncrc = pkgs.substituteAll { inherit maildir; src = ./mbsyncrc.in; };
+
+in
+
+{
+  imports = [ ./mutt ./notmuch ./postfix ./rss2email ];
+
+  environment.systemPackages = with pkgs; [ isync ];
+
+  systemd.services.mail = {
+    path = with pkgs; [ coreutils findutils isync notmuch sudo ];
+    serviceConfig.Type = "oneshot";
+    after = [ "network-online.target" ];
+    script = "sudo -u qyliss-mail mbsync -a -V -c ${mbsyncrc}";
+    postStart = ''
+      find "${maildir}" \! -name .mbsyncstate* \
+                        \( \( \! -user qyliss -o \! -group qyliss \) , \
+                           -type f \! -perm 660 -exec chmod 0660 '{}' \; , \
+                           -type d \! -perm 770 -exec chmod 0770 '{}' \; \)
+      sudo -u qyliss \
+          env NOTMUCH_CONFIG=/etc/xdg/nixos/per-user/qyliss/notmuch/config \
+          notmuch new
+    '';
+  };
+
+  systemd.timers.mail = {
+    timerConfig.OnCalendar = "*:0/5";
+    timerConfig.Persistent = true;
+    after = [ "network-online.target" ];
+    wantedBy = [ "timers.target" ];
+  };
+
+  users.users.qyliss-mail = {
+    home = "/var/home/qyliss-mail";
+    group = "qyliss";
+    createHome = true;
+  };
+
+  home.qyliss.dirs.mail = {
+    group = "qyliss";
+    permissions = "0770";
+  };
+}
diff --git a/modules/workstation/mail/mbsyncrc.in b/modules/workstation/mail/mbsyncrc.in
new file mode 100644
index 000000000000..987646dd9e66
--- /dev/null
+++ b/modules/workstation/mail/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 ~/imappass"
+  SSLType IMAPS
+  SSLVersions TLSv1.2
+
+IMAPStore fastmail-remote
+  Account fastmail
+
+Channel fastmail
+  Master :fastmail-remote:
+  Slave :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..00ca6e86f4fc
--- /dev/null
+++ b/modules/workstation/mail/mutt/default.nix
@@ -0,0 +1,9 @@
+{ pkgs, ... }:
+
+{
+  environment.systemPackages = with pkgs; [ neomutt ];
+
+  users.users.qyliss.xdg.config.paths."mutt/muttrc" = pkgs.copyPathToStore ./muttrc;
+
+  home.qyliss.dirs."state/mutt/header_cache" = {};
+}
diff --git a/modules/workstation/mail/mutt/muttrc b/modules/workstation/mail/mutt/muttrc
new file mode 100644
index 000000000000..ed10e60507f8
--- /dev/null
+++ b/modules/workstation/mail/mutt/muttrc
@@ -0,0 +1,56 @@
+color index red default ~P
+
+unignore List-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 pager = "less -+S"
+set quit = ask-yes
+set sort = threads
+set sort_browser = new
+set user_agent = no
+
+set newsrc = $XDG_DATA_HOME/mutt/newsrc
+
+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 = "msmtp"
+
+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..64c056fa3528
--- /dev/null
+++ b/modules/workstation/mail/notmuch/config
@@ -0,0 +1,15 @@
+[database]
+path=/home/mail
+
+[user]
+other_email=alyssa.ross@freeagent.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..46aa17374a0e
--- /dev/null
+++ b/modules/workstation/mail/notmuch/default.nix
@@ -0,0 +1,12 @@
+{ 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;
+}
diff --git a/modules/workstation/mail/postfix/default.nix b/modules/workstation/mail/postfix/default.nix
new file mode 100644
index 000000000000..0da8936cf1b3
--- /dev/null
+++ b/modules/workstation/mail/postfix/default.nix
@@ -0,0 +1,32 @@
+{ 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.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.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;
+}
diff --git a/modules/workstation/mail/rss2email/default.nix b/modules/workstation/mail/rss2email/default.nix
new file mode 100644
index 000000000000..2e754679fdb2
--- /dev/null
+++ b/modules/workstation/mail/rss2email/default.nix
@@ -0,0 +1,16 @@
+{ 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"; };
+  };
+}
diff --git a/modules/workstation/networking/default.nix b/modules/workstation/networking/default.nix
new file mode 100644
index 000000000000..3176e8d061c0
--- /dev/null
+++ b/modules/workstation/networking/default.nix
@@ -0,0 +1,46 @@
+{ pkgs, config, ... }:
+
+{
+  services.resolved.enable = true;
+
+  networking.domain = "qyliss.net";
+  networking.hosts = with config.networking;
+    { "127.0.1.1" = [ "${hostName}.${domain}" ]; };
+
+  networking.networkmanager.enable = true;
+
+  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" ];
+
+  networking.networkmanager.dispatcherScripts = [
+    {
+      source = pkgs.writeText "doh-stub" ''
+        if [ "$2" = up ]
+        then systemctl restart doh-stub.service
+        fi
+      '';
+      type = "basic";
+    }
+  ];
+
+  systemd.services.doh-stub = {
+    script = ''
+      exec ${pkgs.doh-proxy}/bin/doh-stub \
+          --level INFO \
+          --domain qyliss.net \
+          --remote-address 85.119.82.108
+    '';
+  };
+
+  programs.mtr.enable = true;
+}
diff --git a/modules/workstation/physical/default.nix b/modules/workstation/physical/default.nix
new file mode 100644
index 000000000000..173dbbbb517f
--- /dev/null
+++ b/modules/workstation/physical/default.nix
@@ -0,0 +1,7 @@
+{ ... }:
+
+{
+  imports = [ ../. ../networking ];
+
+  programs.swayidle.enable = true;
+}
diff --git a/modules/workstation/podman/default.nix b/modules/workstation/podman/default.nix
new file mode 100644
index 000000000000..aefad1f9d97f
--- /dev/null
+++ b/modules/workstation/podman/default.nix
@@ -0,0 +1,47 @@
+{ pkgs, ... }:
+
+{
+  environment.etc."containers/libpod.conf".text = ''
+    runtime_path = ["${pkgs.runc}/bin/runc"]
+    conmon_path = ["${pkgs.conmon}/bin/conmon"]
+  '';
+
+  environment.etc."containers/policy.json".text = builtins.toJSON {
+    # Not insecure when I'm manually pulling images on a workstation.
+    default = [ { type = "insecureAcceptAnything"; } ];
+  };
+
+  environment.etc."containers/registries.conf".text = ''
+    [registries.search]
+    registries = ['docker.io']
+  '';
+
+  environment.systemPackages = with pkgs;
+    let
+      podman-bin = writeShellScriptBin "podman" ''
+        HOME="$XDG_CONFIG_HOME/podman"
+        exec ${podman}/bin/podman "$@"
+      '';
+    in
+      [ podman-bin podman.man runc conmon slirp4netns ];
+
+  users.users.qyliss.xdg.config.paths."podman/.config/containers/libpod.conf" =
+    pkgs.writeText "libpod.conf" ''
+      runtime_path = ["${pkgs.runc}/bin/runc"]
+      conmon_path = ["${pkgs.conmon}/bin/conmon"]
+    '';
+
+  users.users.qyliss.xdg.config.paths."podman/.config/containers/storage.conf" =
+    pkgs.writeText "storage.conf" ''
+      [storage]
+      driver = "overlay"
+      runroot = "/tmp/1000"
+      graphroot = "/home/state/podman/containers/storage"
+
+      [storage.options]
+      mount_program = "${pkgs.fuse-overlayfs}/bin/fuse-overlayfs"
+    '';
+
+  home.qyliss.dirs."state/containers" = {};
+  home.qyliss.dirs."state/podman" = {};
+}
diff --git a/modules/workstation/weechat/default.nix b/modules/workstation/weechat/default.nix
new file mode 100644
index 000000000000..4298122a4e0b
--- /dev/null
+++ b/modules/workstation/weechat/default.nix
@@ -0,0 +1,121 @@
+{ pkgs, lib, ... }:
+
+with lib;
+
+let
+  networks = [ "freenode" "hackint" "indymedia" "oftc" ];
+
+  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" "slack.api_tokens" ];
+
+  cfgin = {
+    alias.cmd.B = "buffer";
+    irc.look.buffer_switch_autojoin = false;
+    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;
+
+    plugins.var.python.slack.slack_api_token = "\\\${sec.data.slack.api_tokens}";
+    plugins.var.python.slack.auto_open_threads = true;
+    plugins.var.python.slack.background_load_all_history = true;
+    plugins.var.python.slack.short_buffer_names = true;
+    plugins.var.python.slack.show_reaction_nicks = true;
+    plugins.var.python.vimode.no_warn = true;
+
+    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,[vi_buffer]";
+    weechat.bar.status.color_bg = 53;
+    weechat.bar.status.items = "[otr],[buffer_plugin],buffer_name+(buffer_modes)+{buffer_nicklist_count}+buffer_zoom+buffer_filter,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" "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 (d: ''/set sec.data.${d} test'') sec ++ [ "/save" ] ++
+    map (n: "/server add ${n} ${cfgin.irc.server_default.addresses}") networks ++
+    [ "/ignore add osmbot-test oftc #osm-gb" ] ++
+    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' ".";
+
+  cfg = pkgs.runCommand "weechat-config" {} ''
+    ${pkgs.weechat}/bin/weechat-headless -d $out \
+        ${concatMapStringsSep " " (cmd: "-r ${escapeShellArg cmd}") commands} \
+        -r /save \
+        -r /exit
+  '';
+
+in
+
+{
+  environment.extraInit = ''
+    export WEECHAT_HOME="$HOME/state/weechat"
+  '';
+
+  environment.systemPackages = with pkgs; [
+    (weechat.override {
+      configure = { availablePlugins, ... }: {
+        scripts = with weechatScripts;
+          [ colorize_nicks go wee-slack zncplayback ];
+      };
+    })
+  ];
+
+  home.qyliss.dirs."state/weechat".activationScripts.config = ''
+    for file in ${cfg}/*.conf
+    do
+        if [ "$file" != ${cfg}/sec.conf ]
+        then ln -sf $file $(basename $file)
+        fi
+    done
+  '';
+}
diff --git a/modules/workstation/windowing/alacritty/config.yml b/modules/workstation/windowing/alacritty/config.yml
new file mode 100644
index 000000000000..86aa7ed5622c
--- /dev/null
+++ b/modules/workstation/windowing/alacritty/config.yml
@@ -0,0 +1,340 @@
+# Any items in the `env` entry below will be added as
+# environment variables. Some entries may override variables
+# set by alacritty itself.
+#env:
+  # TERM variable
+  #
+  # This value is used to set the `$TERM` environment variable for
+  # each instance of Alacritty. If it is not present, alacritty will
+  # check the local terminfo database and use 'alacritty' if it is
+  # available, otherwise 'xterm-256color' is used.
+  #TERM: xterm-256color
+
+window:
+  # If both are `0`, this setting is ignored.
+  dimensions:
+    columns: 80
+    lines: 24
+
+  padding:
+    x: 2
+    y: 2
+
+  dynamic_padding: true
+  decorations: none
+  startup_mode: Windowed
+
+scrolling:
+  history: 0 # disabled
+
+font:
+  normal:
+    family: monospace
+    #style: Regular
+
+  bold:
+    family: monospace
+    #style: Bold
+
+  italic:
+    family: monospace
+    #style: Italic
+
+  offset:
+    x: 0
+    y: 0
+
+  glyph_offset:
+    x: 0
+    y: 0
+
+draw_bold_text_with_bright_colors: false
+
+colors:
+  primary:
+    background: '0x000000'
+    foreground: '0xffffff'
+    # dim_foreground: auto
+    # bright_foreground: normal foreground color
+
+  cursor:
+    text:   '0x161616'
+    cursor: '0xfcdc07'
+
+  normal:
+    black:   '0x2e3436'
+    red:     '0xb40000'
+    green:   '0x307000'
+    yellow:  '0xce5c00'
+    blue:    '0x3465a4'
+    magenta: '0x75507b'
+    cyan:    '0x06989a'
+    white:   '0xd3d7cf'
+
+  bright:
+    black:   '0x555753'
+    red:     '0xcc0000'
+    green:   '0x4e9a06'
+    yellow:  '0xedd400'
+    blue:    '0x729fcf'
+    magenta: '0xad7fa8'
+    cyan:    '0x34e2e2'
+    white:   '0xeeeeec'
+
+  # dim: default
+  # indexed_colors: default
+
+# Values for `animation`:
+#   - Ease
+#   - EaseOut
+#   - EaseOutSine
+#   - EaseOutQuad
+#   - EaseOutCubic
+#   - EaseOutQuart
+#   - EaseOutQuint
+#   - EaseOutExpo
+#   - EaseOutCirc
+#   - Linear
+#
+# Specifying a `duration` of `0` will disable the visual bell.
+visual_bell:
+  animation: EaseOutExpo
+  duration: 1
+
+background_opacity: 0.8
+
+# Available fields:
+#   - mouse
+#   - action
+#   - mods (optional)
+#
+# Values for `mouse`:
+#   - Middle
+#   - Left
+#   - Right
+#   - Numeric identifier such as `5`
+#
+# All available `mods` and `action` values are documented in the key binding
+# section.
+mouse_bindings:
+  - { mouse: Middle, action: PasteSelection }
+
+mouse:
+  double_click: { threshold: 300 }
+  triple_click: { threshold: 300 }
+  hide_when_typing: false
+
+  url:
+    launcher: None
+
+selection:
+  semantic_escape_chars: ",│`|:\"' ()[]{}<>"
+
+  # When set to `true`, selected text will be copied to both the primary and
+  # the selection clipboard. Otherwise, it will only be copied to the selection
+  # clipboard.
+  save_to_clipboard: false
+
+dynamic_title: true
+
+cursor:
+  style: Block
+  unfocused_hollow: true
+
+live_config_reload: true
+
+# Shell
+#
+# You can set `shell.program` to the path of your favorite shell, e.g. `/bin/fish`.
+# Entries in `shell.args` are passed unmodified as arguments to the shell.
+#shell:
+#  program: /bin/bash
+#  args:
+#    - --login
+
+# Key bindings
+#
+# Key bindings are specified as a list of objects. Each binding will specify
+# a key and modifiers required to trigger it, terminal modes where the binding
+# is applicable, and what should be done when the key binding fires. It can
+# either send a byte sequnce to the running application (`chars`), execute
+# a predefined action (`action`) or fork and execute a specified command plus
+# arguments (`command`).
+#
+# Example:
+#   `- { key: V, mods: Command, action: Paste }`
+#
+# Available fields:
+#   - key
+#   - mods (optional)
+#   - chars | action | command (exactly one required)
+#   - mode (optional)
+#
+# Values for `key`:
+#   - `A` -> `Z`
+#   - `F1` -> `F12`
+#   - `Key1` -> `Key0`
+#
+#   A full list with available key codes can be found here:
+#   https://docs.rs/glutin/*/glutin/enum.VirtualKeyCode.html#variants
+#
+#   Instead of using the name of the keys, the `key` field also supports using
+#   the scancode of the desired key. Scancodes have to be specified as a
+#   decimal number.
+#   This command will allow you to display the hex scancodes for certain keys:
+#     `showkey --scancodes`
+#
+# Values for `mods`:
+#   - Command
+#   - Control
+#   - Shift
+#   - Alt
+#
+#   Multiple `mods` can be combined using `|` like this: `mods: Control|Shift`.
+#   Whitespace and capitalization is relevant and must match the example.
+#
+# Values for `chars`:
+#   The `chars` field writes the specified string to the terminal. This makes
+#   it possible to pass escape sequences.
+#   To find escape codes for bindings like `PageUp` ("\x1b[5~"), you can run
+#   the command `showkey -a` outside of tmux.
+#   Note that applications use terminfo to map escape sequences back to
+#   keys. It is therefore required to update the terminfo when
+#   changing an escape sequence.
+#
+# Values for `action`:
+#   - Paste
+#   - PasteSelection
+#   - Copy
+#   - IncreaseFontSize
+#   - DecreaseFontSize
+#   - ResetFontSize
+#   - ScrollPageUp
+#   - ScrollPageDown
+#   - ScrollToTop
+#   - ScrollToBottom
+#   - ClearHistory
+#   - Hide
+#   - Quit
+#   - ClearLogNotice
+#
+# Values for `command`:
+#   The `command` field must be a map containing a `program` string and
+#   an `args` array of command line parameter strings.
+#
+#   Example:
+#       `command: { program: "alacritty", args: ["-e", "vttest"] }`
+#
+# Values for `mode`:
+#   - ~AppCursor
+#   - AppCursor
+#   - ~AppKeypad
+#   - AppKeypad
+key_bindings:
+  - { key: V,        mods: Control|Shift,    action: Paste               }
+  - { key: C,        mods: Control|Shift,    action: Copy                }
+  - { key: Paste,                   action: Paste                        }
+  - { key: Copy,                    action: Copy                         }
+  - { key: Q,        mods: Command, action: Quit                         }
+  - { key: W,        mods: Command, action: Quit                         }
+  - { key: Insert,   mods: Shift,   action: PasteSelection               }
+  - { key: Key0,     mods: Control, action: ResetFontSize                }
+  - { key: Equals,   mods: Control, action: IncreaseFontSize             }
+  - { key: Subtract, mods: Control, action: DecreaseFontSize             }
+  - { key: L,        mods: Control, action: ClearLogNotice               }
+  - { key: L,        mods: Control, chars: "\x0c"                        }
+  - { key: Home,                    chars: "\x1bOH",   mode: AppCursor   }
+  - { key: Home,                    chars: "\x1b[H",   mode: ~AppCursor  }
+  - { key: End,                     chars: "\x1bOF",   mode: AppCursor   }
+  - { key: End,                     chars: "\x1b[F",   mode: ~AppCursor  }
+  - { key: PageUp,   mods: Shift,   chars: "\x1b[5;2~"                   }
+  - { key: PageUp,   mods: Control, chars: "\x1b[5;5~"                   }
+  - { key: PageUp,                  chars: "\x1b[5~"                     }
+  - { key: PageDown, mods: Shift,   chars: "\x1b[6;2~"                   }
+  - { key: PageDown, mods: Control, chars: "\x1b[6;5~"                   }
+  - { key: PageDown,                chars: "\x1b[6~"                     }
+  - { key: Tab,      mods: Shift,   chars: "\x1b[Z"                      }
+  - { key: Back,                    chars: "\x7f"                        }
+  - { key: Back,     mods: Alt,     chars: "\x1b\x7f"                    }
+  - { key: Insert,                  chars: "\x1b[2~"                     }
+  - { key: Delete,                  chars: "\x1b[3~"                     }
+  - { key: Left,     mods: Shift,   chars: "\x1b[1;2D"                   }
+  - { key: Left,     mods: Control, chars: "\x1b[1;5D"                   }
+  - { key: Left,     mods: Alt,     chars: "\x1b[1;3D"                   }
+  - { key: Left,                    chars: "\x1b[D",   mode: ~AppCursor  }
+  - { key: Left,                    chars: "\x1bOD",   mode: AppCursor   }
+  - { key: Right,    mods: Shift,   chars: "\x1b[1;2C"                   }
+  - { key: Right,    mods: Control, chars: "\x1b[1;5C"                   }
+  - { key: Right,    mods: Alt,     chars: "\x1b[1;3C"                   }
+  - { key: Right,                   chars: "\x1b[C",   mode: ~AppCursor  }
+  - { key: Right,                   chars: "\x1bOC",   mode: AppCursor   }
+  - { key: Up,       mods: Shift,   chars: "\x1b[1;2A"                   }
+  - { key: Up,       mods: Control, chars: "\x1b[1;5A"                   }
+  - { key: Up,       mods: Alt,     chars: "\x1b[1;3A"                   }
+  - { key: Up,                      chars: "\x1b[A",   mode: ~AppCursor  }
+  - { key: Up,                      chars: "\x1bOA",   mode: AppCursor   }
+  - { key: Down,     mods: Shift,   chars: "\x1b[1;2B"                   }
+  - { key: Down,     mods: Control, chars: "\x1b[1;5B"                   }
+  - { key: Down,     mods: Alt,     chars: "\x1b[1;3B"                   }
+  - { key: Down,                    chars: "\x1b[B",   mode: ~AppCursor  }
+  - { key: Down,                    chars: "\x1bOB",   mode: AppCursor   }
+  - { key: F1,                      chars: "\x1bOP"                      }
+  - { key: F2,                      chars: "\x1bOQ"                      }
+  - { key: F3,                      chars: "\x1bOR"                      }
+  - { key: F4,                      chars: "\x1bOS"                      }
+  - { key: F5,                      chars: "\x1b[15~"                    }
+  - { key: F6,                      chars: "\x1b[17~"                    }
+  - { key: F7,                      chars: "\x1b[18~"                    }
+  - { key: F8,                      chars: "\x1b[19~"                    }
+  - { key: F9,                      chars: "\x1b[20~"                    }
+  - { key: F10,                     chars: "\x1b[21~"                    }
+  - { key: F11,                     chars: "\x1b[23~"                    }
+  - { key: F12,                     chars: "\x1b[24~"                    }
+  - { key: F1,       mods: Shift,   chars: "\x1b[1;2P"                   }
+  - { key: F2,       mods: Shift,   chars: "\x1b[1;2Q"                   }
+  - { key: F3,       mods: Shift,   chars: "\x1b[1;2R"                   }
+  - { key: F4,       mods: Shift,   chars: "\x1b[1;2S"                   }
+  - { key: F5,       mods: Shift,   chars: "\x1b[15;2~"                  }
+  - { key: F6,       mods: Shift,   chars: "\x1b[17;2~"                  }
+  - { key: F7,       mods: Shift,   chars: "\x1b[18;2~"                  }
+  - { key: F8,       mods: Shift,   chars: "\x1b[19;2~"                  }
+  - { key: F9,       mods: Shift,   chars: "\x1b[20;2~"                  }
+  - { key: F10,      mods: Shift,   chars: "\x1b[21;2~"                  }
+  - { key: F11,      mods: Shift,   chars: "\x1b[23;2~"                  }
+  - { key: F12,      mods: Shift,   chars: "\x1b[24;2~"                  }
+  - { key: F1,       mods: Control, chars: "\x1b[1;5P"                   }
+  - { key: F2,       mods: Control, chars: "\x1b[1;5Q"                   }
+  - { key: F3,       mods: Control, chars: "\x1b[1;5R"                   }
+  - { key: F4,       mods: Control, chars: "\x1b[1;5S"                   }
+  - { key: F5,       mods: Control, chars: "\x1b[15;5~"                  }
+  - { key: F6,       mods: Control, chars: "\x1b[17;5~"                  }
+  - { key: F7,       mods: Control, chars: "\x1b[18;5~"                  }
+  - { key: F8,       mods: Control, chars: "\x1b[19;5~"                  }
+  - { key: F9,       mods: Control, chars: "\x1b[20;5~"                  }
+  - { key: F10,      mods: Control, chars: "\x1b[21;5~"                  }
+  - { key: F11,      mods: Control, chars: "\x1b[23;5~"                  }
+  - { key: F12,      mods: Control, chars: "\x1b[24;5~"                  }
+  - { key: F1,       mods: Alt,     chars: "\x1b[1;6P"                   }
+  - { key: F2,       mods: Alt,     chars: "\x1b[1;6Q"                   }
+  - { key: F3,       mods: Alt,     chars: "\x1b[1;6R"                   }
+  - { key: F4,       mods: Alt,     chars: "\x1b[1;6S"                   }
+  - { key: F5,       mods: Alt,     chars: "\x1b[15;6~"                  }
+  - { key: F6,       mods: Alt,     chars: "\x1b[17;6~"                  }
+  - { key: F7,       mods: Alt,     chars: "\x1b[18;6~"                  }
+  - { key: F8,       mods: Alt,     chars: "\x1b[19;6~"                  }
+  - { key: F9,       mods: Alt,     chars: "\x1b[20;6~"                  }
+  - { key: F10,      mods: Alt,     chars: "\x1b[21;6~"                  }
+  - { key: F11,      mods: Alt,     chars: "\x1b[23;6~"                  }
+  - { key: F12,      mods: Alt,     chars: "\x1b[24;6~"                  }
+  - { key: F1,       mods: Super,   chars: "\x1b[1;3P"                   }
+  - { key: F2,       mods: Super,   chars: "\x1b[1;3Q"                   }
+  - { key: F3,       mods: Super,   chars: "\x1b[1;3R"                   }
+  - { key: F4,       mods: Super,   chars: "\x1b[1;3S"                   }
+  - { key: F5,       mods: Super,   chars: "\x1b[15;3~"                  }
+  - { key: F6,       mods: Super,   chars: "\x1b[17;3~"                  }
+  - { key: F7,       mods: Super,   chars: "\x1b[18;3~"                  }
+  - { key: F8,       mods: Super,   chars: "\x1b[19;3~"                  }
+  - { key: F9,       mods: Super,   chars: "\x1b[20;3~"                  }
+  - { key: F10,      mods: Super,   chars: "\x1b[21;3~"                  }
+  - { key: F11,      mods: Super,   chars: "\x1b[23;3~"                  }
+  - { key: F12,      mods: Super,   chars: "\x1b[24;3~"                  }
diff --git a/modules/workstation/windowing/alacritty/default.nix b/modules/workstation/windowing/alacritty/default.nix
new file mode 100644
index 000000000000..e8d086f54d8c
--- /dev/null
+++ b/modules/workstation/windowing/alacritty/default.nix
@@ -0,0 +1,11 @@
+{ pkgs, ... }:
+
+{
+  imports = [ ../../../xdg ];
+
+  environment.systemPackages = with pkgs;
+    lib.optional (!stdenv.isDarwin) alacritty;
+
+  users.users.qyliss.xdg.config.paths."alacritty/alacritty.yml" =
+    pkgs.copyPathToStore ./config.yml;
+}
diff --git a/modules/workstation/windowing/default.nix b/modules/workstation/windowing/default.nix
new file mode 100644
index 000000000000..8387f1981ac4
--- /dev/null
+++ b/modules/workstation/windowing/default.nix
@@ -0,0 +1,7 @@
+{ pkgs, ... }:
+
+{
+  imports = [ ./alacritty ./firefox ./gnome-mines ./sway ];
+
+  environment.systemPackages = with pkgs; [ imv wf-recorder ];
+}
diff --git a/modules/workstation/windowing/firefox/default.nix b/modules/workstation/windowing/firefox/default.nix
new file mode 100644
index 000000000000..877f0ef0892c
--- /dev/null
+++ b/modules/workstation/windowing/firefox/default.nix
@@ -0,0 +1,13 @@
+{ pkgs, ... }:
+
+{
+  home.qyliss.dirs.state.activationScripts.profile = ''
+    install -o qyliss -g users -d mozilla{,/firefox{,/default}}
+    ln -sf ${./profiles.ini} mozilla/firefox/profiles.ini
+    ln -sf ${./user.js} mozilla/firefox/default/user.js
+  '';
+
+  environment.systemPackages = with pkgs; [ firefox-wayland ];
+
+  environment.variables.BROWSER = "firefox";
+}
diff --git a/modules/workstation/windowing/firefox/profiles.ini b/modules/workstation/windowing/firefox/profiles.ini
new file mode 100644
index 000000000000..becf53354e76
--- /dev/null
+++ b/modules/workstation/windowing/firefox/profiles.ini
@@ -0,0 +1,8 @@
+[General]
+StartWithLastProfile=1
+
+[Profile0]
+Name=default
+IsRelative=1
+Path=default
+Default=1
diff --git a/modules/workstation/windowing/firefox/user.js b/modules/workstation/windowing/firefox/user.js
new file mode 100644
index 000000000000..8b137891791f
--- /dev/null
+++ b/modules/workstation/windowing/firefox/user.js
@@ -0,0 +1 @@
+
diff --git a/modules/workstation/windowing/gnome-mines/default.nix b/modules/workstation/windowing/gnome-mines/default.nix
new file mode 100644
index 000000000000..a2376676d007
--- /dev/null
+++ b/modules/workstation/windowing/gnome-mines/default.nix
@@ -0,0 +1,7 @@
+{ pkgs, ... }:
+
+{
+  environment.systemPackages = with pkgs; [ gnome3.gnome-mines ];
+
+  home.qyliss.dirs."state/gnome-mines" = {};
+}
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..6770080462af
--- /dev/null
+++ b/modules/workstation/windowing/sway/config.in
@@ -0,0 +1,110 @@
+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 [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
+
+# 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 - Sharing Indicator"]
+for_window [title="Firefox - 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
+output * bg @wallpaper@ fill
+
+bindsym $mod+Return exec alacritty
+bindsym $mod+backslash exec firefox
+bindsym $mod+Shift+q kill
+bindsym $mod+d exec swaymsg exec "$(choosebin --tiebreak=begin,length,index)"
+
+# 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..5901639d1baa
--- /dev/null
+++ b/modules/workstation/windowing/sway/default.nix
@@ -0,0 +1,52 @@
+{ pkgs, lib, config, ... }:
+
+{
+  imports = [ ./swayidle ];
+
+  options = {
+    programs.sway.extraConfig = with lib; mkOption {
+      type = types.lines;
+      description = "Lines to append to sway's config file";
+      default = "";
+    };
+  };
+
+  config = let
+    cfg = config.programs.sway;
+
+    wallpaper = pkgs.fetchurl {
+      url = https://mir-s3-cdn-cf.behance.net/project_modules/2800_opt_1/36731876964505.5c793fa788b5d.jpg;
+      sha256 = "1c6camdipng8ws41sgpcxzrxb96crgip3wirqjgf2ajn60qg3v64";
+
+      meta = with lib; {
+        homepage = https://www.behance.net/gallery/76964505/IQOO-style-frame-and-scene-design;
+      };
+    };
+
+    configFile = pkgs.substituteAll {
+      src = ./config.in;
+      inherit choose_workspace status_command wallpaper;
+      inherit (cfg) extraConfig;
+    };
+
+    status_command = pkgs.runCommandCC "status" {} ''
+      c++ -std=c++17 -o $out ${./status.cpp}
+    '';
+
+    choose_workspace = pkgs.substituteAll {
+      src = ./choose_workspace.sh.in;
+      isExecutable = true;
+      inherit (pkgs) bemenu jq;
+    };
+
+  in
+
+  {
+    environment.systemPackages = with pkgs; [ bemenu choose ];
+
+    programs.sway.enable = true;
+    programs.swayidle.enable = true;
+
+    users.users.qyliss.xdg.config.paths."sway/config" = configFile;
+  };
+}
diff --git a/modules/workstation/windowing/sway/status.cpp b/modules/workstation/windowing/sway/status.cpp
new file mode 100644
index 000000000000..71acb38eeb70
--- /dev/null
+++ b/modules/workstation/windowing/sway/status.cpp
@@ -0,0 +1,159 @@
+// 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();
+
+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"));
+}
+
+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 capacity (buf, len);
+	if (capacity.back() == '\n')
+		capacity.pop_back();
+	return capacity;
+}
+
+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();
+			if (name.find("BAT") != 0)
+				continue;
+
+			Battery battery (name);
+			BatteryStatus status;
+			int capacity;
+
+			try {
+				status = battery.status();
+				capacity = battery.capacity();
+			} catch (const system_error& ex) {
+				if (ex.code().value() == ENOENT)
+					continue;
+
+				throw ex;
+			}
+
+			switch (status) {
+			case Charging:
+				out << "↑";
+				break;
+			case Discharging:
+				out << "↓";
+				break;
+			default:
+				out << " ";
+			}
+
+			out << capacity << "%  ";
+		}
+
+		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/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'
+    '';
+  };
+}