diff --git a/bin/omarchy b/bin/omarchy index 0f50ccc7..57910d51 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -45,6 +45,7 @@ GROUP_DESCRIPTIONS[dev]="Omarchy development tools" GROUP_DESCRIPTIONS[display]="Display and text scaling" GROUP_DESCRIPTIONS[dns]="DNS resolver configuration" GROUP_DESCRIPTIONS[drive]="Drive selection and encryption" +GROUP_DESCRIPTIONS[file]="File selection helpers" GROUP_DESCRIPTIONS[font]="Font management" GROUP_DESCRIPTIONS[games]="Game launchers and helpers" GROUP_DESCRIPTIONS[hibernation]="Hibernation setup and removal" @@ -78,6 +79,7 @@ GROUP_DESCRIPTIONS[snapshot]="System snapshots" GROUP_DESCRIPTIONS[style]="Global UI style controls" GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers" GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock" +GROUP_DESCRIPTIONS[tailscale]="Tailscale helpers" GROUP_DESCRIPTIONS[theme]="Theme management" GROUP_DESCRIPTIONS[tmux]="Tmux session helpers" GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features" diff --git a/bin/omarchy-bar-plugin b/bin/omarchy-bar-plugin index c7442f10..58c89692 100755 --- a/bin/omarchy-bar-plugin +++ b/bin/omarchy-bar-plugin @@ -288,6 +288,15 @@ cmd_move() { fi local default_section="${PLACEMENT_SECTION:-}" + + # A bare section names the section, not the slot, so let it fall through to + # the same anchor placement 'add' uses. Passing it as an explicit target + # instead drops the widget on the far end of the row. + local target_section="$PLACEMENT_SECTION" + if [[ -z $PLACEMENT_INDEX && -z $PLACEMENT_BEFORE && -z $PLACEMENT_AFTER ]]; then + target_section="" + fi + local prog prog=$(cat <] [--multiple] +# omarchy:examples=omarchy file select --title "Send with Tailscale" --multiple + +# Python rather than bash, alone among the commands here, because the portal +# answers a request with a Response signal addressed to the connection that +# asked, and D-Bus delivers a directed signal only to that connection. Every +# shell-callable client — gdbus call, busctl call, dbus-send — opens its own +# connection and exits before the answer arrives, and gdbus monitor registers +# with AddMatch rather than BecomeMonitor, so it never sees one either. Holding +# a single connection across both the call and the wait is the whole job, and +# bash has no way to hold one. + +import argparse +import os +import sys + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib + +# A dialog nobody ever answers would otherwise keep this process, and whatever +# waits on its output, alive forever. +ANSWER_TIMEOUT_SEC = 600 + +# Callers act on these: nothing picked is a decision, a chooser that never ran +# is a fault, and the two want different handling. +EXIT_NOTHING_PICKED = 1 +EXIT_CHOOSER_FAILED = 2 + + +def main(): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--title", default="Select file") + parser.add_argument("--multiple", action="store_true") + args, unknown = parser.parse_known_args() + + if unknown: + print("omarchy-file-select: unknown option %s" % unknown[0], file=sys.stderr) + return EXIT_CHOOSER_FAILED + + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + loop = GLib.MainLoop() + uris = [] + + def on_response(connection, sender, path, interface, signal, params): + code, results = params.unpack() + if code == 0: + uris.extend(results.get("uris", [])) + loop.quit() + + def subscribe(path): + bus.signal_subscribe( + "org.freedesktop.portal.Desktop", + "org.freedesktop.portal.Request", + "Response", + path, + None, + Gio.DBusSignalFlags.NONE, + on_response, + ) + + # The request path is derived from our bus name and the token we pass, so it + # can be subscribed to up front. Asking first would race a dialog that gets + # answered immediately. + token = "omarchy%d" % os.getpid() + sender = bus.get_unique_name()[1:].replace(".", "_") + predicted = "/org/freedesktop/portal/desktop/request/%s/%s" % (sender, token) + subscribe(predicted) + + handle = bus.call_sync( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.FileChooser", + "OpenFile", + GLib.Variant("(ssa{sv})", ("", args.title, { + "handle_token": GLib.Variant("s", token), + "multiple": GLib.Variant("b", args.multiple), + })), + None, + Gio.DBusCallFlags.NONE, + -1, + None, + ).unpack()[0] + + # Portals predating the token convention answer on a path of their choosing. + if handle != predicted: + subscribe(handle) + + GLib.timeout_add_seconds(ANSWER_TIMEOUT_SEC, loop.quit) + loop.run() + + for uri in uris: + print(GLib.filename_from_uri(uri)[0]) + + return 0 if uris else EXIT_NOTHING_PICKED + + +if __name__ == "__main__": + try: + sys.exit(main()) + except GLib.Error as error: + print("omarchy-file-select: %s" % error.message, file=sys.stderr) + sys.exit(EXIT_CHOOSER_FAILED) diff --git a/bin/omarchy-install-service-tailscale b/bin/omarchy-install-service-tailscale index 8adffc53..58dd28b5 100755 --- a/bin/omarchy-install-service-tailscale +++ b/bin/omarchy-install-service-tailscale @@ -13,6 +13,9 @@ sudo tailscale up --accept-routes echo -e "\nAllowing $USER to manage Tailscale..." sudo tailscale set --operator="$USER" +echo -e "\nReceiving Taildrop files in $HOME/Downloads..." +systemctl --user enable --now omarchy-tailscale-receive.service + echo -e "\nAdding Tailscale to the bar..." omarchy-bar-plugin add omarchy.tailscale diff --git a/bin/omarchy-remove-service-tailscale b/bin/omarchy-remove-service-tailscale index acda106e..adbe2103 100644 --- a/bin/omarchy-remove-service-tailscale +++ b/bin/omarchy-remove-service-tailscale @@ -4,6 +4,7 @@ # omarchy:requires-sudo=true tailscale down 2>/dev/null || true +systemctl --user disable --now omarchy-tailscale-receive.service 2>/dev/null || true sudo systemctl disable --now tailscaled.service 2>/dev/null || true omarchy-bar-plugin remove omarchy.tailscale omarchy-webapp-remove "Tailscale" 2>/dev/null || true diff --git a/bin/omarchy-tailscale-receive b/bin/omarchy-tailscale-receive new file mode 100755 index 00000000..6232646e --- /dev/null +++ b/bin/omarchy-tailscale-receive @@ -0,0 +1,101 @@ +#!/bin/bash + +# omarchy:summary=Save incoming Taildrop files and announce them +# omarchy:args=[--once] [directory] +# omarchy:examples=omarchy tailscale receive | omarchy tailscale receive --once ~/Desktop + +set -euo pipefail + +once=false +if [[ ${1:-} == "--once" ]]; then + once=true + shift +fi + +dir="${1:-${XDG_DOWNLOAD_DIR:-$HOME/Downloads}}" + +# Taildrop lands in a staging directory next door rather than straight in the +# downloads directory: waiting for a delivery can take hours, and everything +# else that shows up meanwhile is somebody else's file. Same filesystem, so +# handing the finished file over is a rename. +staging="$dir/.omarchy-taildrop" +mkdir -p "$staging" + +# Take the name by linking to it rather than by looking and then renaming. +# link(2) refuses an existing name, so nothing can land on the chosen one in +# the gap between the two. Staging shares the filesystem with the downloads +# directory, so the link always resolves and unlinking the staged name +# finishes the move. Prints the name it took. +claim_path() { + local staged="$1" name="${staged##*/}" base ext candidate index=0 + + base="${name%.*}" + ext="${name#"$base"}" + [[ -z $base ]] && { base="$name"; ext=""; } + + while (( index < 1000 )); do + if (( index == 0 )); then + candidate="$dir/$name" + else + candidate="$dir/$base-$index$ext" + fi + + if ln -- "$staged" "$candidate" 2>/dev/null; then + rm -f -- "$staged" + printf '%s\n' "$candidate" + return 0 + fi + + # Only a taken name is worth another spin. Anything else failed the link + # itself, and the file keeps its place in staging for the next run. + [[ -e $candidate ]] || return 1 + + ((index++)) + done + + return 1 +} + +announce() { + local path="$1" + local name="${path##*/}" + local args=("Received $name" "Saved to ${dir/#$HOME/~}") + + case "${name,,}" in + *.png | *.jpg | *.jpeg | *.gif | *.webp | *.avif | *.bmp | *.tif | *.tiff) + args+=(--image "$path") + ;; + *) + args+=(-g 󰒊) + ;; + esac + + # Clicking the notification opens the file, so this waits for the toast to + # go away. Callers background it to keep receiving in the meantime. + if [[ -n $(omarchy-notification-send "${args[@]}" -a) ]]; then + xdg-open "$path" + fi +} + +deliver() { + local staged target + + while IFS= read -r staged; do + target=$(claim_path "$staged") || continue + announce "$target" & + done < <(find "$staging" -mindepth 1 -maxdepth 1) +} + +# Anything left staged by an interrupted run still deserves delivering. +deliver + +while true; do + if ! tailscale file get --wait --conflict=rename "$staging"; then + $once && exit 1 + sleep 10 + continue + fi + + deliver + $once && exit 0 +done diff --git a/bin/omarchy-tailscale-send b/bin/omarchy-tailscale-send new file mode 100755 index 00000000..84093cc8 --- /dev/null +++ b/bin/omarchy-tailscale-send @@ -0,0 +1,50 @@ +#!/bin/bash + +# omarchy:summary=Send files to a machine on your tailnet with Taildrop +# omarchy:args= [file...] +# omarchy:examples=omarchy tailscale send dhh-fd | omarchy tailscale send dhh-fd ~/Downloads/notes.pdf + +set -euo pipefail + +if (($# < 1)); then + echo "Usage: omarchy-tailscale-send [file...]" >&2 + exit 1 +fi + +machine="$1" +shift + +# Address the machine by whatever name we were handed, but talk about it by +# its short name, so a MagicDNS name does not spill into every message. +name="${machine%%.*}" + +files=("$@") + +if ((${#files[@]} == 0)); then + # Command substitution so the chooser's exit status survives: reading it + # through a process substitution reports success for a chooser that never + # opened, which is indistinguishable here from someone deciding not to send. + picked=$(omarchy-file-select --title "Send to $name" --multiple) || status=$? + + if ((${status:-0} > 1)); then + omarchy-notification-send -g "󰒊" -u critical "Could not send to $name" \ + "The file chooser did not open" + exit 1 + fi + + readarray -t files <<<"$picked" + [[ -n $picked ]] || exit 0 +fi + +if ((${#files[@]} == 1)); then + what=$(basename "${files[0]}") +else + what="${#files[@]} files" +fi + +if error=$(tailscale file cp --update-interval=0 -- "${files[@]}" "$machine:" 2>&1); then + omarchy-notification-send -g "󰒊" "Sent to $name" "$what" +else + omarchy-notification-send -g "󰒊" -u critical "Could not send to $name" "${error:-Taildrop transfer failed}" + exit 1 +fi diff --git a/default/hypr/apps/system.lua b/default/hypr/apps/system.lua index a7206550..a8c9b250 100644 --- a/default/hypr/apps/system.lua +++ b/default/hypr/apps/system.lua @@ -9,8 +9,12 @@ o.window( tag = "+floating-window", } ) +-- The portal only ever shows dialogs — file pickers, screen shares, permission +-- prompts — so every one of its windows belongs in the floating treatment, +-- whatever the app that asked for it titled it. +o.window("xdg-desktop-portal-gtk", { tag = "+floating-window" }) o.window({ - class = "(xdg-desktop-portal-gtk|sublime_text|DesktopEditors|org.gnome.Nautilus)", + class = "(sublime_text|DesktopEditors|org.gnome.Nautilus)", title = "^(Open.*Files?|Open [F|f]older.*|Save.*Files?|Save.*As|Save|All Files|.*wants to [open|save].*|[C|c]hoose.*)", }, { tag = "+floating-window" }) o.window("dev.tensaku.Tensaku", { float = true }) diff --git a/default/systemd/user/omarchy-tailscale-receive.service b/default/systemd/user/omarchy-tailscale-receive.service new file mode 100644 index 00000000..040af0b7 --- /dev/null +++ b/default/systemd/user/omarchy-tailscale-receive.service @@ -0,0 +1,12 @@ +[Unit] +Description=Save incoming Taildrop files to the downloads directory +ConditionPathExists=/usr/bin/tailscale + +[Service] +Type=simple +ExecStart=/usr/bin/omarchy-tailscale-receive +Restart=always +RestartSec=5 + +[Install] +WantedBy=graphical-session.target diff --git a/migrations/1785101000.sh b/migrations/1785101000.sh new file mode 100644 index 00000000..2b5aef76 --- /dev/null +++ b/migrations/1785101000.sh @@ -0,0 +1,11 @@ +echo "Save incoming Taildrop files to ~/Downloads" + +if omarchy-cmd-present tailscale; then + systemctl --user daemon-reload >/dev/null 2>&1 || true + + # Report what systemctl actually said; "could not enable" on its own gives + # nothing to act on. + if ! error=$(systemctl --user enable --now omarchy-tailscale-receive.service 2>&1); then + echo "Could not enable omarchy-tailscale-receive.service: $error" + fi +fi diff --git a/shell/plugins/panels/tailscale/Model.js b/shell/plugins/panels/tailscale/Model.js index 4153067c..bddf7153 100644 --- a/shell/plugins/panels/tailscale/Model.js +++ b/shell/plugins/panels/tailscale/Model.js @@ -73,10 +73,35 @@ function loginPlan(needsLogin, authUrl) { return { authUrl: "", command: ["tailscale", "up"] } } +// Taildrop is a tailnet feature the admin can turn off, so the button for it +// only makes sense when this profile actually carries the capability. +function hasFileSharing(self) { + var capability = "https://tailscale.com/cap/file-sharing" + var capMap = (self && self.CapMap) || null + if (capMap && capMap[capability] !== undefined) return true + var capabilities = (self && self.Capabilities) || [] + for (var i = 0; i < capabilities.length; i++) { + if (String(capabilities[i]) === capability) return true + } + return false +} + +// Tailscale grades every peer itself — offline, wrong owner, an OS without +// Taildrop, no peer API — so take its word when the status carries one, and +// fall back to same-owner for daemons too old to say. +function isTaildropTarget(peer, selfUserId) { + var target = peer && peer.TaildropTarget + if (typeof target === "number" && target !== 0) return target === 1 + var owner = String((peer && peer.UserID) || "") + return owner !== "" && owner === String(selfUserId || "") +} + function peerFromStatus(id, peer) { return { id: id, HostName: displayHostName(peer.HostName, peer.DNSName), + UserID: String(peer.UserID || ""), + TaildropTarget: typeof peer.TaildropTarget === "number" ? peer.TaildropTarget : 0, DNSName: cleanDnsName(peer.DNSName), DisplayName: displayHostName(peer.HostName, peer.DNSName), TailscaleIPs: filterIPv4(peer.TailscaleIPs || []), @@ -235,6 +260,8 @@ function parseStatus(raw) { selfName: displayHostName(self.HostName, self.DNSName), selfDnsName: cleanDnsName(self.DNSName), selfIp: selfIps.length > 0 ? selfIps[0] : "", + selfUserId: String(self.UserID || ""), + fileSharing: hasFileSharing(self), peers: peers, exitNodes: exitNodes } @@ -285,6 +312,8 @@ if (typeof module !== "undefined") { osIcon: osIcon, accountLabel: accountLabel, loginPlan: loginPlan, + hasFileSharing: hasFileSharing, + isTaildropTarget: isTaildropTarget, isMullvadPeer: isMullvadPeer, peerFromStatus: peerFromStatus, parseExitNodeList: parseExitNodeList, diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index f188ca76..3202a686 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -295,6 +295,13 @@ Panel { scrollCursorIntoView() } + // The file picker takes over from here, so get the panel out of the way. + function sendPeerFile(peer) { + if (!tailscale.canSendFiles(peer)) return + tailscale.sendFile(peer) + close() + } + function openSelectedPeerCopyMenu() { if (!peerColumn || peerIndex < 0 || peerIndex >= peerColumn.children.length) return var item = peerColumn.children[peerIndex] @@ -416,6 +423,7 @@ Panel { else if (t === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer()) else if (t === "n" || t === "N") tailscale.copyPeerName(root.selectedPeer()) else if (t === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer()) + else if (t === "s" || t === "S") root.sendPeerFile(root.selectedPeer()) } Flickable { @@ -973,6 +981,17 @@ Panel { } } + PanelActionButton { + id: sendButton + visible: tailscale.canSendFiles(peerRow.peer) + iconText: "󰒊" + tooltipText: "Send files" + foreground: root.foreground + fontFamily: root.fontFamily + Layout.alignment: Qt.AlignVCenter + onClicked: root.sendPeerFile(peerRow.peer) + } + PanelActionButton { id: copyButton iconText: "󰆏" diff --git a/shell/plugins/panels/tailscale/README.md b/shell/plugins/panels/tailscale/README.md index cb54f50a..5d542d4f 100644 --- a/shell/plugins/panels/tailscale/README.md +++ b/shell/plugins/panels/tailscale/README.md @@ -10,6 +10,7 @@ Native Omarchy bar widget for Tailscale. - Switch between available Tailscale connections when multiple are available - Browse machines from `tailscale status --json` - Copy a machine's Tailscale IP, host name, or DNS name +- Send files to a machine with Taildrop, when the tailnet allows file sharing ## Keyboard shortcuts @@ -20,6 +21,7 @@ Inside the panel: - `c`: copy selected peer IP - `n`: copy selected peer name - `d`: copy selected peer DNS name +- `s`: send files to selected peer - `t`: toggle Tailscale - `r`: refresh status - `esc`: close @@ -28,6 +30,15 @@ Inside the panel: - `tailscale` CLI on `PATH` - `wl-copy` for clipboard copy actions +- Taildrop enabled for the tailnet, to send files + +## Receiving files + +Incoming Taildrop files are saved to `~/Downloads` by the +`omarchy-tailscale-receive` service, which announces each one with a +notification (an image preview when the file is an image, and a click to open +it). The Tailscale service install enables it; `omarchy tailscale receive` +runs the same loop by hand. ## Icon diff --git a/shell/plugins/panels/tailscale/Service.qml b/shell/plugins/panels/tailscale/Service.qml index ffb5eb17..13fbfbc5 100644 --- a/shell/plugins/panels/tailscale/Service.qml +++ b/shell/plugins/panels/tailscale/Service.qml @@ -24,6 +24,8 @@ Item { property string selfName: "" property string selfDnsName: "" property string selfIp: "" + property string selfUserId: "" + property bool fileSharing: false property string authUrl: "" property var peers: [] property var exitNodes: [] @@ -123,6 +125,26 @@ Item { copyToClipboard(cleanDnsName(peer.DNSName), displayHostName(peer.HostName, peer.DNSName) + " DNS name") } + function peerAddress(peer) { + if (!peer) return "" + if (peer.DNSName) return cleanDnsName(peer.DNSName) + if (peer.HostName) return String(peer.HostName) + var ips = filterIPv4(peer.TailscaleIPs || []) + return ips.length > 0 ? ips[0] : "" + } + + function canSendFiles(peer) { + if (!fileSharing || !running || !peer) return false + return Model.isTaildropTarget(peer, selfUserId) + } + + function sendFile(peer) { + if (!canSendFiles(peer)) return + var target = peerAddress(peer) + if (target === "") return + Quickshell.execDetached(["omarchy-tailscale-send", target]) + } + function refresh(forceAccounts) { if (installed) { refreshStatusAndAccounts(forceAccounts === true) @@ -137,18 +159,21 @@ Item { function refreshStatusAndAccounts(forceAccounts) { if (!installed) return + var launched = false if (!statusProcess.running) { _statusOutput = "" _statusError = "" refreshing = true statusProcess.command = ["tailscale", "status", "--json"] statusProcess.running = true + launched = true } if (!mullvadExitNodesProcess.running) { _mullvadExitNodesOutput = "" _mullvadExitNodesError = "" mullvadExitNodesProcess.command = ["tailscale", "exit-node", "list"] mullvadExitNodesProcess.running = true + launched = true } var now = Date.now() var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000 @@ -158,7 +183,13 @@ Item { _lastAccountsRefreshMs = now accountsProcess.command = ["tailscale", "switch", "--list", "--json"] accountsProcess.running = true + launched = true } + // Arm on the launch that needs watching and leave it alone after that. + // Restarting it every refresh pushes the deadline out ahead of a hung + // process forever once the refresh interval is shorter than the timeout, + // and refreshIntervalSec goes down to five seconds. + if (launched && !pollWatchdog.running) pollWatchdog.start() } function elideStatus(text) { @@ -175,6 +206,8 @@ Item { selfName = "" selfDnsName = "" selfIp = "" + selfUserId = "" + fileSharing = false authUrl = "" peers = [] exitNodes = [] @@ -212,6 +245,8 @@ Item { selfName = parsed.selfName selfDnsName = parsed.selfDnsName selfIp = parsed.selfIp + selfUserId = parsed.selfUserId + fileSharing = parsed.fileSharing peers = parsed.running ? parsed.peers : [] tailnetExitNodes = parsed.running ? parsed.exitNodes : [] exitNodes = parsed.running ? tailnetExitNodes.concat(mullvadRegions) : [] @@ -295,10 +330,7 @@ Item { var mullvadIps = filterIPv4(peer.TailscaleIPs || []) if (mullvadIps.length > 0) return mullvadIps[0] } - if (peer.DNSName) return cleanDnsName(peer.DNSName) - if (peer.HostName) return String(peer.HostName) - var ips = filterIPv4(peer.TailscaleIPs || []) - return ips.length > 0 ? ips[0] : "" + return peerAddress(peer) } function setExitNode(peer) { @@ -386,6 +418,22 @@ Item { onTriggered: root.refresh() } + Timer { + // Every poll is skipped while its own process is still running, so one that + // never exits — tailscale can hang on a network that is coming and going — + // silently stops the panel refreshing at all, and it stays stopped. Reap + // anything still running well inside the refresh interval so the next tick + // starts clean. + id: pollWatchdog + interval: 15000 + repeat: false + onTriggered: { + if (statusProcess.running) statusProcess.running = false + if (mullvadExitNodesProcess.running) mullvadExitNodesProcess.running = false + if (accountsProcess.running) accountsProcess.running = false + } + } + Timer { id: actionStatusTimer interval: 2200 diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 65968e90..14ab4b30 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -16,14 +16,14 @@ pass "default shell.json is valid JSON" jq -e '.version == 1 and (.bar.layout.left | type == "array") and (.bar.layout.center | type == "array") and (.bar.layout.right | type == "array")' "$ROOT/config/omarchy/shell.json" >/dev/null pass "default shell.json has versioned bar layout" +# Pinning the whole row made this fail every time an unrelated widget moved, +# so assert the adjacency the name is about and let the rest of the row change. jq -e ' def ids: map(.id // .); - .bar.layout.center | ids == [ - "omarchy.clock", - "omarchy.weather", - "omarchy.system-update", - "omarchy.indicators" - ] + (.bar.layout.center | ids) as $ids | + ($ids | index("omarchy.weather")) as $weather | + ($ids | index("omarchy.system-update")) as $update | + $weather != null and $update == $weather + 1 ' "$ROOT/config/omarchy/shell.json" >/dev/null pass "default center layout keeps update next to weather" @@ -138,6 +138,7 @@ package_defaults = [ ("default/systemd/user/omarchy-sleep-lock.service", "/usr/lib/systemd/user/omarchy-sleep-lock.service", "systemd/user/omarchy-sleep-lock.service"), ("default/systemd/user/omarchy-recover-internal-monitor.service", "/usr/lib/systemd/user/omarchy-recover-internal-monitor.service", "systemd/user/omarchy-recover-internal-monitor.service"), ("default/systemd/user/omarchy-migrate-notify.service", "/usr/lib/systemd/user/omarchy-migrate-notify.service", "systemd/user/omarchy-migrate-notify.service"), + ("default/systemd/user/omarchy-tailscale-receive.service", "/usr/lib/systemd/user/omarchy-tailscale-receive.service", "systemd/user/omarchy-tailscale-receive.service"), ("default/systemd/zram-generator.conf.d/90-omarchy.conf", "/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf", "systemd/zram-generator.conf.d/90-omarchy.conf"), ("default/fonts/omarchy/omarchy.ttf", "/usr/share/fonts/omarchy/omarchy.ttf", "omarchy.ttf"), ("default/snapper/root", "/etc/snapper/config-templates/omarchy", "snapper/root"), diff --git a/test/shell.d/tailscale-receive-test.sh b/test/shell.d/tailscale-receive-test.sh new file mode 100644 index 00000000..4b59afed --- /dev/null +++ b/test/shell.d/tailscale-receive-test.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +WORKDIR=$(mktemp -d) +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +downloads="$WORKDIR/downloads" +mkdir -p "$WORKDIR/bin" "$downloads" "$WORKDIR/outbox" +printf 'mine' >"$downloads/unrelated.txt" + +# Stands in for the daemon handing over whatever is waiting in the inbox. A +# decoy is whatever else drops into the downloads directory while Taildrop is +# still blocking on the next delivery. +cat >"$WORKDIR/bin/tailscale" <"$downloads/\$DECOY" +mv "$WORKDIR/outbox/"* "\$target/" +SH + +cat >"$WORKDIR/bin/omarchy-notification-send" <>"$WORKDIR/notifications" +# Only the photo notification gets clicked. +[[ \$* == *photo.png* ]] && echo default +exit 0 +SH + +cat >"$WORKDIR/bin/xdg-open" <>"$WORKDIR/opened" +SH + +chmod +x "$WORKDIR/bin/"* + +receive() { + local expected="$1" + shift + + : >"$WORKDIR/notifications" + PATH="$WORKDIR/bin:$PATH" "$@" "$ROOT/bin/omarchy-tailscale-receive" --once "$downloads" + + for _ in {1..50}; do + (($(wc -l <"$WORKDIR/notifications") >= expected)) && break + sleep 0.1 + done +} + +printf 'png' >"$WORKDIR/outbox/photo.png" +printf 'pdf' >"$WORKDIR/outbox/notes with space.pdf" +receive 2 env + +notifications=$(<"$WORKDIR/notifications") + +[[ -f $downloads/photo.png && -f "$downloads/notes with space.pdf" ]] || + fail "taildrop receive saves incoming files" "$(ls "$downloads")" +pass "taildrop receive saves incoming files" + +grep -qF -- "Received photo.png Saved to $downloads --image $downloads/photo.png" <<<"$notifications" || + fail "taildrop receive previews received images" "$notifications" +pass "taildrop receive previews received images" + +grep -q "^Received notes with space.pdf .* -g " <<<"$notifications" || + fail "taildrop receive announces other files with a glyph" "$notifications" +pass "taildrop receive announces other files with a glyph" + +grep -qxF "$downloads/photo.png" "$WORKDIR/opened" || + fail "taildrop receive opens a clicked file" "$(cat "$WORKDIR/opened" 2>/dev/null)" +pass "taildrop receive opens a clicked file" + +grep -q "unrelated.txt" <<<"$notifications" && + fail "taildrop receive leaves the rest of the downloads directory alone" "$notifications" +pass "taildrop receive leaves the rest of the downloads directory alone" + +# A second delivery of the same name, alongside a download that arrives while +# Taildrop is waiting. +printf 'png' >"$WORKDIR/outbox/photo.png" +receive 1 env DECOY=browser-download.iso + +notifications=$(<"$WORKDIR/notifications") + +[[ -f $downloads/photo-1.png ]] || fail "taildrop receive keeps both files on a name clash" "$(ls "$downloads")" +grep -q "^Received photo-1.png " <<<"$notifications" || + fail "taildrop receive keeps both files on a name clash" "$notifications" +pass "taildrop receive keeps both files on a name clash" + +grep -q "browser-download.iso" <<<"$notifications" && + fail "taildrop receive ignores downloads that arrive while it waits" "$notifications" +pass "taildrop receive ignores downloads that arrive while it waits" + +[[ -z $(ls -A "$downloads/.omarchy-taildrop") ]] || + fail "taildrop receive empties its staging directory" "$(ls -A "$downloads/.omarchy-taildrop")" +pass "taildrop receive empties its staging directory" diff --git a/test/shell.d/tailscale-test.sh b/test/shell.d/tailscale-test.sh index dfb21de2..d46925a0 100644 --- a/test/shell.d/tailscale-test.sh +++ b/test/shell.d/tailscale-test.sh @@ -28,7 +28,9 @@ const status = tailscale.parseStatus(JSON.stringify({ Self: { HostName: 'dhh-fd', DNSName: 'dhh-fd.tail32f559.ts.net.', - TailscaleIPs: ['100.74.97.73'] + TailscaleIPs: ['100.74.97.73'], + UserID: 1001, + CapMap: { 'https://tailscale.com/cap/file-sharing': null } }, Peer: { onlineB: { @@ -38,7 +40,9 @@ const status = tailscale.parseStatus(JSON.stringify({ Online: true, OS: 'linux', ExitNodeOption: true, - ExitNode: true + ExitNode: true, + UserID: 1002, + TaildropTarget: 5 }, offline: { HostName: 'offline', @@ -61,7 +65,9 @@ const status = tailscale.parseStatus(JSON.stringify({ DNSName: 'alpha.tail32f559.ts.net.', TailscaleIPs: ['100.1.1.1', 'fd7a:115c:a1e0::1901:334b'], Online: true, - OS: 'macos' + OS: 'macos', + UserID: 1001, + TaildropTarget: 1 }, mullvadExit: { HostName: 'al-tia-wg-003', @@ -83,6 +89,20 @@ assert(status.peers[1].ExitNodeOption && status.peers[1].ExitNode, 'tailscale pr assertDeepEqual(status.exitNodes.map(peer => peer.HostName), ['zed'], 'tailscale lists only online tailnet exit nodes') assert(tailscale.isMullvadPeer({ HostName: 'al-tia-wg-003', DNSName: 'al-tia-wg-003.mullvad.ts.net.' }), 'tailscale detects Mullvad status peers') +assert(status.fileSharing, 'tailscale reads Taildrop capability from the status capability map') +assertEqual(status.selfUserId, '1001', 'tailscale records the owning user of this machine') +assertDeepEqual(status.peers.map(peer => peer.UserID), ['1001', '1002'], 'tailscale records the owning user of each peer') +assert( + tailscale.hasFileSharing({ Capabilities: ['https://tailscale.com/cap/file-sharing'] }), + 'tailscale reads Taildrop capability from the legacy capability list' +) +assert(!tailscale.hasFileSharing({ CapMap: { funnel: null } }), 'tailscale reports no Taildrop without the capability') +assertDeepEqual(status.peers.map(peer => peer.TaildropTarget), [1, 5], 'tailscale records how Tailscale grades each Taildrop target') +assert(tailscale.isTaildropTarget({ TaildropTarget: 1, UserID: '1001' }, '2002'), 'tailscale trusts an available Taildrop target') +assert(!tailscale.isTaildropTarget({ TaildropTarget: 7, UserID: '1001' }, '1001'), 'tailscale skips peers Tailscale rules out') +assert(tailscale.isTaildropTarget({ UserID: '1001' }, '1001'), 'tailscale falls back to same-owner peers without a grade') +assert(!tailscale.isTaildropTarget({ UserID: '1002' }, '1001'), 'tailscale skips other owners without a grade') + const mullvadNodes = tailscale.parseExitNodeList(` IP HOSTNAME COUNTRY CITY STATUS 100.65.216.13 au-adl-wg-301.mullvad.ts.net Australia Any -