diff --git a/flake.nix b/flake.nix index 1abd136..38e730f 100644 --- a/flake.nix +++ b/flake.nix @@ -56,7 +56,7 @@ hostname = "x"; hostModule = ./hosts/x.nix; hardwareModule = ./hardware/x.nix; - homeModules = [ ./hosts/x-home.nix ./hosts/x-doubletake.nix ]; + homeModules = [ ./hosts/x-home.nix ]; }; }; } diff --git a/hosts/x-doubletake.nix b/hosts/x-doubletake.nix deleted file mode 100644 index 29df84a..0000000 --- a/hosts/x-doubletake.nix +++ /dev/null @@ -1,253 +0,0 @@ -{ config, lib, pkgs, ... }: - -let - doubletake = pkgs.callPackage ../pkgs/doubletake.nix { }; - - trayScript = pkgs.writeText "doubletake-tray.py" '' - #!/usr/bin/env python3 - """ - Minimal StatusNotifierItem applet for doubletake on niri + noctalia. - Spawns the daemon on demand, kills it on disconnect. - Output parsing for `doubletake-ctl devices` is best-effort — adjust the - parse_devices() function once you confirm the real output format. - """ - import gi, os, signal, subprocess, threading, time - gi.require_version("Gtk", "3.0") - gi.require_version("AyatanaAppIndicator3", "0.1") - from gi.repository import Gtk, GLib, AyatanaAppIndicator3 as AppIndicator - - CTL = "doubletake-ctl" - DT = "doubletake" - - def run(*args, timeout=10): - try: - r = subprocess.run(args, capture_output=True, text=True, timeout=timeout) - return r.returncode, r.stdout, r.stderr - except Exception as e: - return 1, "", str(e) - - def daemon_running(): - return run("pgrep", "-x", "doubletake")[0] == 0 - - def ensure_daemon(): - if daemon_running(): - return - subprocess.Popen([DT, "-daemonize"], start_new_session=True, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - # Crude readiness wait — replace if doubletake exposes a socket-ready check. - for _ in range(20): - time.sleep(0.1) - if daemon_running(): - break - - def parse_devices(text): - """Best-effort parse of `doubletake-ctl devices` output. - Returns list[(ip, label)]. Update once you see the real format.""" - out = [] - for line in text.splitlines(): - line = line.strip() - if not line or line.lower().startswith(("no ", "error", "device")): - continue - parts = line.split(None, 1) - ip = parts[0] - label = parts[1] if len(parts) > 1 else ip - out.append((ip, label)) - return out - - def parse_status(text): - """Return list of currently connected target IPs, best-effort.""" - connected = [] - for line in text.splitlines(): - line = line.strip().lower() - if "connected" in line or "streaming" in line or "active" in line: - # crude — pull first dotted-IP-looking token - for tok in line.replace(",", " ").split(): - if tok.count(".") == 3 and all(p.isdigit() for p in tok.split(".")): - connected.append(tok) - break - return connected - - class TrayApp: - def __init__(self): - self.ind = AppIndicator.Indicator.new( - "doubletake-tray", - "video-display-symbolic", - AppIndicator.IndicatorCategory.HARDWARE, - ) - self.ind.set_status(AppIndicator.IndicatorStatus.ACTIVE) - self.ind.set_title("Doubletake") - self.menu = Gtk.Menu() - self.ind.set_menu(self.menu) - self._connected = [] - self.rebuild() - GLib.timeout_add_seconds(5, self.tick) - - def refresh_state(self): - if daemon_running(): - _, out, _ = run(CTL, "status") - self._connected = parse_status(out) - else: - self._connected = [] - - def tick(self): - self.refresh_state() - self._set_icon() - return True - - def _set_icon(self): - icon = "video-display-symbolic" if not self._connected else "media-record-symbolic" - self.ind.set_icon_full(icon, "Doubletake") - - def rebuild(self): - self.refresh_state() - for c in self.menu.get_children(): - self.menu.remove(c) - - running = daemon_running() - - if self._connected: - hdr = Gtk.MenuItem(label=f"Casting → {', '.join(self._connected)}") - hdr.set_sensitive(False) - self.menu.append(hdr) - disc = Gtk.MenuItem(label="Disconnect") - disc.connect("activate", self.on_disconnect) - self.menu.append(disc) - self.menu.append(Gtk.SeparatorMenuItem()) - - scan = Gtk.MenuItem(label="Scan for devices") - scan.connect("activate", self.on_scan) - self.menu.append(scan) - - if running: - _, devs_out, _ = run(CTL, "devices") - for ip, name in parse_devices(devs_out): - if ip in self._connected: - continue - it = Gtk.MenuItem(label=f"Cast to {name} ({ip})") - it.connect("activate", self.on_connect, ip) - self.menu.append(it) - - self.menu.append(Gtk.SeparatorMenuItem()) - if running: - stop = Gtk.MenuItem(label="Stop daemon") - stop.connect("activate", self.on_stop_daemon) - self.menu.append(stop) - else: - start = Gtk.MenuItem(label="Start daemon") - start.connect("activate", self.on_start_daemon) - self.menu.append(start) - qi = Gtk.MenuItem(label="Quit") - qi.connect("activate", lambda _: Gtk.main_quit()) - self.menu.append(qi) - - self.menu.show_all() - self._set_icon() - - # --- actions --- - - def _async(self, fn): - threading.Thread(target=fn, daemon=True).start() - - def on_scan(self, _): - def go(): - ensure_daemon() - run(CTL, "discover", timeout=15) - GLib.idle_add(self.rebuild) - self._async(go) - - def on_connect(self, _, ip): - def go(): - ensure_daemon() - rc, out, err = run(CTL, "connect", ip, timeout=20) - text = (out + "\n" + err).lower() - if rc != 0 and ("pin" in text or "pair" in text): - GLib.idle_add(self.prompt_pin, ip) - GLib.idle_add(self.rebuild) - self._async(go) - - def prompt_pin(self, ip): - try: - r = subprocess.run( - ["zenity", "--entry", "--title=Doubletake pairing", - f"--text=Enter PIN shown on {ip}"], - capture_output=True, text=True, timeout=120, - ) - pin = r.stdout.strip() - except Exception: - pin = "" - if pin: - self._async(lambda: (run(CTL, "connect", ip, pin), GLib.idle_add(self.rebuild))) - - def on_disconnect(self, _): - self._async(lambda: (run(CTL, "disconnect"), GLib.idle_add(self.rebuild))) - - def on_start_daemon(self, _): - self._async(lambda: (ensure_daemon(), GLib.idle_add(self.rebuild))) - - def on_stop_daemon(self, _): - def go(): - run(CTL, "disconnect") - run("pkill", "-x", "doubletake") - time.sleep(0.3) - GLib.idle_add(self.rebuild) - self._async(go) - - if __name__ == "__main__": - TrayApp() - Gtk.main() - ''; - - pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ]); - - giDeps = with pkgs; [ - gtk3 - libayatana-appindicator - pango.out - glib.out - gdk-pixbuf.out - atk - harfbuzz.out - gobject-introspection - ]; - - trayApp = pkgs.writeShellApplication { - name = "doubletake-tray"; - runtimeInputs = [ - doubletake - pkgs.procps - pkgs.zenity - ]; - text = '' - export GI_TYPELIB_PATH="${lib.makeSearchPath "lib/girepository-1.0" giDeps}:''${GI_TYPELIB_PATH:-}" - export LD_LIBRARY_PATH="${lib.makeLibraryPath [ pkgs.libayatana-appindicator pkgs.gtk3 ]}:''${LD_LIBRARY_PATH:-}" - exec ${pythonEnv}/bin/python3 ${trayScript} "$@" - ''; - }; -in -{ - home.packages = [ doubletake trayApp ]; - - xdg.desktopEntries.doubletake-tray = { - name = "Doubletake Tray"; - comment = "AirPlay screen mirroring applet"; - exec = "${trayApp}/bin/doubletake-tray"; - icon = "video-display"; - categories = [ "AudioVideo" "Network" ]; - terminal = false; - }; - - systemd.user.services.doubletake-tray = { - Unit = { - Description = "Doubletake AirPlay tray applet"; - After = [ "graphical-session.target" ]; - PartOf = [ "graphical-session.target" ]; - }; - Service = { - Type = "simple"; - ExecStart = "${trayApp}/bin/doubletake-tray"; - Restart = "on-failure"; - RestartSec = 3; - }; - Install.WantedBy = [ "graphical-session.target" ]; - }; -} diff --git a/hosts/x.nix b/hosts/x.nix index 3bcb685..6115541 100644 --- a/hosts/x.nix +++ b/hosts/x.nix @@ -4,8 +4,8 @@ services.displayManager.defaultSession = "niri"; programs.niri.enable = true; # SHM-fallback patch for PipeWire screencast (niri PR #1791, fixes #455). - # Lets gst-pipewire consumers (doubletake, Kooha, vokoscreenNG, etc.) negotiate - # with niri's screencast stream instead of failing on dmabuf-only formats. + # Lets gst-pipewire consumers (gpu-screen-recorder, Kooha, vokoscreenNG, etc.) + # negotiate with niri's screencast stream instead of failing on dmabuf-only formats. programs.niri.package = unstable.niri.overrideAttrs (old: { patches = (old.patches or []) ++ [ ../patches/niri-shm-1791.patch ]; }); diff --git a/pkgs/doubletake.nix b/pkgs/doubletake.nix deleted file mode 100644 index fff1db5..0000000 --- a/pkgs/doubletake.nix +++ /dev/null @@ -1,46 +0,0 @@ -{ lib -, buildGoModule -, fetchFromGitHub -, makeWrapper -, gst_all_1 -}: - -buildGoModule rec { - pname = "doubletake"; - version = "unstable-2026-05-26"; - - src = fetchFromGitHub { - owner = "omarroth"; - repo = "doubletake"; - rev = "f19c022c1f508996439792347b16836287a84bbc"; - hash = "sha256-gWaEBngF+6vs5wK5Y0/S4Lbs9CfQeOBmGsU4yv/IFWM="; - }; - - vendorHash = "sha256-cgvY9MVGe8I3g3Ni2sGucTY6YCyPJ2YnoxxUaYfl1E4="; - - subPackages = [ "cmd/doubletake" "cmd/doubletake-ctl" ]; - - nativeBuildInputs = [ makeWrapper ]; - - # Pure Go (no cgo). GStreamer + plugins are runtime deps — the daemon - # shells out to gst-launch-1.0 and loads plugins via GST_PLUGIN_SYSTEM_PATH_1_0. - postFixup = '' - gstPath="${gst_all_1.gstreamer.out}/lib/gstreamer-1.0:${gst_all_1.gst-plugins-base}/lib/gstreamer-1.0:${gst_all_1.gst-plugins-good}/lib/gstreamer-1.0:${gst_all_1.gst-plugins-bad}/lib/gstreamer-1.0:${gst_all_1.gst-plugins-ugly}/lib/gstreamer-1.0:${gst_all_1.gst-libav}/lib/gstreamer-1.0" - wrapProgram $out/bin/doubletake \ - --prefix GST_PLUGIN_SYSTEM_PATH_1_0 : "$gstPath" \ - --prefix PATH : "${gst_all_1.gstreamer}/bin" - ''; - - postInstall = '' - install -Dm644 $src/man/man1/doubletake.1 $out/share/man/man1/doubletake.1 - install -Dm644 $src/man/man1/doubletake-ctl.1 $out/share/man/man1/doubletake-ctl.1 - ''; - - meta = with lib; { - description = "AirPlay 2 screen-mirroring sender for Linux"; - homepage = "https://github.com/omarroth/doubletake"; - license = licenses.gpl3Only; - platforms = platforms.linux; - mainProgram = "doubletake"; - }; -}