Skip to content

feat(i3): add opt-in presentation mode for screen sharing #141

@Bad3r

Description

@Bad3r

1. Problem statement

When the user shares the screen (browser getDisplayMedia, OBS, Zoom, Teams,
etc.) the audience sees everything that crosses the X server, including:

  • Toast notifications from dunst — Slack/Element/Signal/Telegram/Discord
    message previews, Thunderbird mail digests, notify-send from random user
    scripts, USB/udiskie mount popups, network-manager-applet state changes,
    Duplicati/nh backup notices, notify-send calls inside our own
    i3-presentation-mode-adjacent scripts (power-profile-rofi,
    dim-warning, toggle-logseq, …).
  • The full i3status-rust block row defined in
    modules/apps/i3wm/status.nix:22-76net SSID, IP address, public
    battery percentage, disk free, CPU temperature, time. Most of this is
    effectively a privacy leak the moment a stranger sees the recording.
  • Workspace urgency flashes from chat clients pinging while presenting,
    driven by _NET_WM_STATE_DEMANDS_ATTENTION on the i3 bar.

The session also actively interferes with sharing:

  • xset is configured with a 3600s screensaver/DPMS in
    modules/apps/i3wm/startup.nix:65-75. After an hour the audience sees
    a black screen.
  • services.screen-locker (modules/apps/i3wm/services.nix:127-147) wires
    xss-lock + xautolock with inactiveInterval = 15 minutes plus a
    60-second dim-warning that drops the backlight to 80 nits before
    locking. During a long screen share both fire — the screen dims, then
    the lock screen appears mid-call.
  • On tpnix, services.logind.HandleLidSwitch = "suspend"
    (modules/tpnix/services.nix:18) — closing the lid mid-share suspends
    the host, which is fine policy outside a presentation but breaks
    hand-offs between docked and undocked.

There is currently no toggle that addresses these as a coherent
unit. The user manually pauses dunst, manually xsets, and forgets one
of them every time.

2. Goal / intended behavior

Provide an opt-in, runtime-toggleable "presentation mode" that the
user enters before sharing the screen and exits afterwards.

Toggling presentation mode ON must, in a single keystroke, suppress
every notification surface that is allowed to draw on top of shared
content, and disable every idle/lock/sleep path that would interrupt a
live share. Toggling OFF must restore each setting to exactly the
value it had before, with no orphaned background processes.

The design must be:

  • Transparent — the user can see whether the mode is on at any time
    (initial: i3-presentation-mode status; future: i3status-rust block).
  • Crash-safe — if the script dies mid-toggle the system never ends
    up with notifications silently dropped forever or xautolock
    permanently disabled.
  • i3-restart-safemod+Shift+r reloads i3 without breaking the
    state. If presentation mode was ON before restart it stays ON.
  • Reboot-safe (default OFF) — a fresh login starts with mode OFF
    even if the user crashed/power-cycled while presenting.

3. Functional requirements

  • FR-1. A new Home Manager module
    modules/apps/i3wm/presentation.nix exposes options under
    gui.i3.presentation.* and adds a single binary,
    i3-presentation-mode, to home.packages.

  • FR-2. Subcommands:

    • i3-presentation-mode on — enter mode (idempotent).
    • i3-presentation-mode off — leave mode (idempotent).
    • i3-presentation-mode toggle — flip based on state file.
    • i3-presentation-mode status — print on/off, exit 0 if ON,
      1 if OFF (suitable as an i3status-rust custom block).
    • i3-presentation-mode resume — internal; re-applies live knobs if
      the state file says ON. Wired into i3 startup (FR-9).
  • FR-3. State is a single file at
    ${XDG_RUNTIME_DIR}/i3-presentation-mode/state containing a JSON
    blob with:

    { "active": true,
      "started_at": "2026-04-30T18:31:00Z",
      "saved": { "power_profile": "balanced", "bar_mode": "dock" } }

    XDG_RUNTIME_DIR is tmpfs and clears at logout/reboot — gives the
    reboot-safe default for free.

  • FR-4. Actions on enter (each toggle-able via gui.i3.presentation.actions.*):

    Key Default Mechanism
    pauseDunst true dunstctl set-paused true. Notification before pause via notify-send -u low -t 1500 "Presentation" "ON", then 0.3s sleep, then pause. Dunst queues messages received while paused; they are released on set-paused false.
    hideBar true i3-msg 'bar mode invisible' (no bar_id — applies to all bars; tray hides with bar).
    disableIdle true xset s off -dpms s noblank plus xautolock -disable plus systemd-inhibit --what=idle:sleep --who=presentation --why='screen sharing' --mode=block sleep infinity & (PID stored in state). The systemd inhibitor covers logind IdleAction paths that xset cannot reach.
    inhibitLidSuspend false tpnix only — adds handle-lid-switch:handle-lid-switch-ep:handle-lid-switch-docked to the systemd-inhibit --what list. Off by default to preserve the safety property "closing the lid suspends, period".
    setPowerProfile false Off by default because both hosts already pin Performance (modules/tpnix/services.nix:89-99, modules/system76/.*). Exposed for future hosts; when on, switches via system76-power profile performance or powerprofilesctl set performance and restores prior value on exit.
    hideCursor false Spawns unclutter --timeout 1 --jitter 5 (PID stored in state). Off by default; opt-in.
    notify true Pre/post notify-send so the user sees the transition on screen even though dunst gets paused immediately after.
  • FR-5. Actions on exit: every action above is reversed in the
    reverse order it was applied. Specifically:

    • dunstctl set-paused false.
    • i3-msg 'bar mode dock'.
    • xset s default +dpms (re-applies the i3-startup defaults of
      3600s; presentation.nix recomputes the values from
      gui.i3.idle.{screensaverSeconds,dpmsSeconds} rather than
      hard-coding them).
    • xautolock -enable.
    • kill <inhibit-pid> for each saved PID.
    • kill <unclutter-pid> if hideCursor was on.
    • Restore power_profile from state if setPowerProfile was on.
    • State file removed.
  • FR-6. Idempotence. Re-running on while ON: notify-send "already
    on" at urgency=low, refresh started_at, no toggling of subsystems.
    Re-running off while OFF: silent no-op.

  • FR-7. Crash safety. Each enter step is gated on "is the desired
    end state already true?" — dunstctl is-paused, i3-msg -t get_bar_config | jq '.[0].mode', xset q | grep DPMS. The script
    records each successful step into the state file before moving to
    the next; off reads the state file and only reverses steps that
    were actually applied. Partial-fail recovery: i3-presentation-mode off
    while in a half-applied state still produces a coherent OFF.

  • FR-8. Default keybinding bound under
    xsession.windowManager.i3.config.keybindings:
    ${mod}+Shift+F12 → exec --no-startup-id i3-presentation-mode toggle.
    Configurable via gui.i3.presentation.keybinding (string, nullable —
    setting null skips the binding entirely so users can wire their
    own).

  • FR-9. i3 restart hook. A non-always = true startup entry:
    i3-presentation-mode resume. Reads the state file; if active=true
    re-asserts bar mode invisible, xset s off -dpms s noblank,
    xautolock -disable, the systemd-inhibit lock, etc. Does not
    re-pause dunst because dunst lives in a separate user service that
    i3 restart doesn't touch — dunst stays paused across an i3 restart.

  • FR-10. Per-host enablement. New options live under
    gui.i3.presentation. gui.i3.presentation.enable = true is set in
    whichever module already defines gui.i3.* for the i3 hosts (today:
    every host that imports flake.homeManagerModules.apps.i3-config).
    Hosts that disable i3 will also disable this — gated by the existing
    lib.mkIf i3Enabled pattern in modules/apps/i3wm/services.nix:18.

4. Non-functional requirements

  • NFR-1. Implementation language: bash via pkgs.writeShellApplication.
    No new flake inputs. Runtime tools already in nixpkgs:
    coreutils, dunst, i3, libnotify, xorg.xset, xautolock, systemd, jq (state file parsing). Optional: unclutter-xfixes, system76-power | power-profiles-daemon.
  • NFR-2. No new system-level (environment.systemPackages) entries
    unless absolutely required — the script ships via Home Manager
    (home.packages) like power-profile-rofi does
    (modules/apps/i3wm/config.nix:417-423).
  • NFR-3. No lib.mkOverride 1100 or mkForce on existing options
    — additive only.
  • NFR-4. State file is 0600 and lives under $XDG_RUNTIME_DIR,
    which is per-user tmpfs. No secrets are written.
  • NFR-5. Honors the existing repo invariant: nix flake check --accept-flake-config must pass after the change. The new module
    declares its options, so any host that doesn't import the i3 module
    is unaffected.
  • NFR-6. No race with xss-lock's sleep transfer (-l flag,
    modules/apps/i3wm/services.nix:135). Sleep-transfer fires on
    systemd PrepareForSleep and is separate from the idle path. The
    presentation-mode systemd-inhibit uses --what=idle:sleep which
    blocks the suspend path during a screen share — when the user
    manually triggers suspend (mod+Shift+e
    systemctl suspend) the inhibit is released by kill during off,
    and the lock-on-suspend safety property is preserved.

5. System interactions / data flow

                   ┌─────────────────────────┐
       keybind ───▶│ i3-presentation-mode    │
   (mod+Shift+F12) │  on | off | toggle |    │
                   │  status | resume        │
                   └────┬────────┬────────┬──┘
                        │        │        │
                        ▼        ▼        ▼
        ┌─────────────────┐  ┌────────┐  ┌─────────────────────┐
        │ dunstctl        │  │ i3-msg │  │ xset / xautolock /  │
        │ set-paused      │  │ bar    │  │ systemd-inhibit /   │
        │ true|false      │  │ mode   │  │ unclutter / pp-ctl  │
        └─────────────────┘  └────────┘  └──────────┬──────────┘
                                                    │
                          state file ◀──────────────┘
              $XDG_RUNTIME_DIR/i3-presentation-mode/state

External services touched: none. All effects are local user-session
state. No D-Bus calls beyond what dunstctl and systemd-inhibit
already do.

6. Module design (proposed file layout)

modules/apps/i3wm/
├── config.nix           # unchanged (defines gui.i3.* base options)
├── keybindings.nix      # unchanged
├── nixos.nix            # unchanged
├── presentation.nix     # NEW — script, options, keybinding, startup hook
├── scratchpad.nix       # unchanged
├── services.nix         # unchanged
├── startup.nix          # unchanged (presentation hooks live in presentation.nix)
├── status.nix           # later: optional custom block (out of scope for v1)
└── window-rules.nix     # unchanged

Skeleton:

# modules/apps/i3wm/presentation.nix
{
  flake.homeManagerModules.apps.i3-config =
    {
      config,
      lib,
      pkgs,
      osConfig ? { },
      ...
    }:
    let
      cfg = config.gui.i3.presentation;
      mod = lib.attrByPath [ "xsession" "windowManager" "i3" "config" "modifier" ] "Mod4" config;
      powerBackend = lib.attrByPath
        [ "gui" "i3" "powerProfiles" "backend" ] "powerprofilesctl" osConfig;

      presentationScript = pkgs.writeShellApplication {
        name = "i3-presentation-mode";
        runtimeInputs = [
          pkgs.coreutils pkgs.dunst pkgs.i3 pkgs.libnotify
          pkgs.xorg.xset pkgs.xautolock pkgs.systemd pkgs.jq
        ]
        ++ lib.optionals cfg.actions.hideCursor [ pkgs.unclutter-xfixes ]
        ++ lib.optionals cfg.actions.setPowerProfile (
             if powerBackend == "system76-power"
             then [ pkgs.system76-power pkgs.gawk pkgs.gnugrep ]
             else [ pkgs.power-profiles-daemon ]);
        text = ''…'';   # see FR-2..FR-7
      };
    in {
      options.gui.i3.presentation = {
        enable = lib.mkOption {
          type = lib.types.bool;
          default = config.xsession.windowManager.i3.enable or false;
          description = "Install the i3 presentation-mode toggle.";
        };
        keybinding = lib.mkOption {
          type = lib.types.nullOr lib.types.str;
          default = "${mod}+Shift+F12";
          description = "i3 keybinding that toggles presentation mode. null disables.";
        };
        actions = {
          pauseDunst       = lib.mkEnableOption "dunst pause" // { default = true; };
          hideBar          = lib.mkEnableOption "i3 bar hide" // { default = true; };
          disableIdle      = lib.mkEnableOption "idle/DPMS/xautolock/systemd-inhibit" // { default = true; };
          inhibitLidSuspend = lib.mkEnableOption "logind lid-switch inhibit";
          setPowerProfile  = lib.mkEnableOption "force performance power profile";
          hideCursor       = lib.mkEnableOption "unclutter cursor hide";
          notify           = lib.mkEnableOption "transition notifications" // { default = true; };
        };
      };

      config = lib.mkIf cfg.enable {
        home.packages = [ presentationScript ];
        xsession.windowManager.i3.config = {
          keybindings = lib.mkIf (cfg.keybinding != null) (lib.mkOptionDefault {
            "${cfg.keybinding}" =
              "exec --no-startup-id ${lib.getExe presentationScript} toggle";
          });
          startup = lib.mkAfter [{
            command = "${lib.getExe presentationScript} resume";
            always = false;
            notification = false;
          }];
        };
      };
    };
}

7. Alternatives considered

  • Per-host wrapper script under scripts/ — rejected. Other i3
    helpers (toggle-logseq, power-profile-rofi,
    i3-scratchpad-show-or-create) are all packaged via Home Manager
    derivations beside modules/apps/i3wm/. Symmetry wins.

  • Replace dunst with mako / a Wayland flow — out of scope. This
    repo is committed to X11 + dunst (modules/apps/dunst.nix:53,
    modules/apps/i3wm/services.nix:111).

  • Auto-detect screen sharing via xdg-desktop-portal ScreenCast
    signals
    — does not work for X11 in this setup. Browsers running
    on i3 use the X11 getDisplayMedia path directly, not the portal.
    xdg-desktop-portal-gtk is wired in
    modules/system76/gsettings.nix / modules/tpnix/gsettings.nix only
    for screenshot fallback. Auto-detect would require either OBS-only
    process polling or X11 window-property scrapes, both fragile. Manual
    toggle is the honest design.

  • Use a dedicated i3 mode (mode "presentation" like resize/gaps)
    — rejected. Modes hijack the global keymap; the user wants to keep
    using normal keybinds while presenting.

  • Use the caffeine/caffeine-ng package — overlapping but not
    identical: caffeine only handles xset + screensaver, not dunst,
    not i3 bar, not the bundled state we need.

  • Force force_display_urgency_hint 0 to silence workspace
    flashes
    force_display_urgency_hint is a config-time directive,
    not a runtime command (verified in i3 user guide). Rewriting i3
    config and reloading mid-presentation is too invasive. Pausing
    dunst already silences the noise; the bar is hidden so the flash is
    invisible regardless.

8. Test plan

  • nix flake check --accept-flake-config --no-build --offline
    passes.
  • nix build .#nixosConfigurations.tpnix.config.system.build.toplevel
    builds.
  • nix build .#nixosConfigurations.system76.config.system.build.toplevel
    builds.
  • i3-presentation-mode status prints off after a fresh login.
  • mod+Shift+F12 (toggle on):
    • dunstctl is-pausedtrue.
    • i3-msg -t get_bar_config bar-0 | jq -r .modeinvisible.
    • xset q | grep DPMSDPMS is Disabled.
    • xautolock -toggle (the introspection variant) confirms disabled.
    • systemctl --user list-units 'session-*' --type=scope shows the
      inhibit lock under Inhibited:.
  • mod+Shift+F12 (toggle off): every check above reverses.
  • Send notify-send -u normal "test" "queued" while ON. Confirm
    nothing pops. Toggle off. Confirm dunst replays the queued one
    from history (dunstctl history-pop).
  • mod+Shift+r (i3 restart) while ON: bar comes back hidden,
    xset knobs re-applied — verified by xset q and
    i3-msg -t get_bar_config.
  • Suspend → resume while ON: lock screen still appears at resume
    (xss-lock sleep-transfer path is intact). After unlocking, mode
    is still ON.
  • Reboot while ON: after fresh login, mode is OFF (state file
    cleared with tmpfs).
  • Run i3-presentation-mode on twice in a row: second invocation
    logs "already on", state file started_at updates.
  • Run i3-presentation-mode off while never having toggled on:
    silent no-op, exit 0.
  • Kill the script mid-on (pkill -9 i3-presentation-mode) and
    then run off: every applied step is reversed; the partial
    state is detectable via the per-step state file flags.

9. Open questions / future work

  • Q1. Should there be a rofi entry (next to power-profile-rofi)
    with explicit On / Off / Status choices? Proposed: yes, in v2.
    Cheap to add once the script is in place.
  • Q2. Should programs.i3status-rust.bars.default.blocks grow a
    custom block driven by i3-presentation-mode status so the bar
    itself shows a red "PRES" marker when active (visible only when
    hideBar = false)? Proposed: yes, in v2.
  • Q3. Should we hook OBS / SimpleScreenRecorder lifecycle to
    auto-toggle? Out of scope for v1; tracked separately.
  • Q4. Should we expose a gui.i3.presentation.profiles attrset
    (e.g. talk, pair-programming, demo) where each profile
    pre-configures different action sets? Probably premature; revisit
    after a few months of v1 use.
  • Q5. Should webcam / microphone state be displayed (e.g.
    pactl list source-outputs polled into a status block)? Separate
    RFC.

Labels (suggested)

  • type(enhancement)
  • area(home-manager)
  • host(system76)
  • host(tpnix)
  • priority(p3)
  • focus(hardening) — privacy/notification-leak surface

Metadata

Metadata

Assignees

No one assigned

    Labels

    area(home-manager)Home Manager modules, activation, or user-environment config.focus(hardening)Proactive attack-surface reduction or tighter defaults.host(system76)Specific to the System76 host or its runtime contract.host(tpnix)Specific to the tpnix host or its runtime contract.priority(p3)Normal priority.status(backlog)Accepted work that is intentionally unscheduled.type(enhancement)Net-new capability or intentional improvement.

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions