Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ doubletake-ctl status
doubletake-ctl connect 192.168.1.77
doubletake-ctl connect 192.168.1.133
doubletake-ctl disconnect 192.168.1.77
doubletake-ctl reset-restore-token 192.168.1.133
doubletake-ctl disconnect
```

Expand Down Expand Up @@ -399,13 +400,19 @@ doubletake-ctl devices
doubletake-ctl connect [target] [PIN-or-password]
doubletake-ctl pin <PIN-or-password>
doubletake-ctl disconnect [target]
doubletake-ctl reset-restore-token <target>
doubletake-ctl mute [target]
doubletake-ctl unmute [target]
```

- `disconnect` without a target stops all active streams.
- `disconnect <target>` stops only that receiver.
- `mute`/`unmute` can operate globally or per target.
- `reset-restore-token <target>` stops one fully streaming receiver, clears only
its saved Wayland portal restore token, and reconnects it on the same IP and
port. Pairing credentials and other streams are unchanged. The command rejects
targets sharing a capture group; disconnect those peers first so the old portal
source can be stopped before a replacement is authorized.
- `pin` retains its historical command name, but submits whichever credential
the daemon requests: an on-screen PIN or a configured password. It is
targetless and therefore requires exactly one waiting receiver; use
Expand Down
8 changes: 7 additions & 1 deletion cmd/doubletake-ctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ func main() {
} else {
resp, err = client.Disconnect()
}
case "reset-restore-token":
if len(args) != 2 {
fmt.Fprintln(os.Stderr, "Usage: doubletake-ctl reset-restore-token <target>")
os.Exit(1)
}
resp, err = client.ResetRestoreToken(args[1])
case "mute":
if len(args) >= 2 {
resp, err = client.MuteTarget(args[1])
Expand Down Expand Up @@ -94,5 +100,5 @@ func main() {
}

func usage() {
fmt.Fprintf(os.Stderr, "Usage: doubletake-ctl [-socket path] <command> [args]\n\nCommands:\n status Show daemon state and all active streams\n discover Discover AirPlay devices on the network\n devices List cached discovered devices\n connect [target] [PIN-or-password] Start mirroring (to target IP, or first free device)\n pin <PIN-or-password> Submit pairing credentials for a waiting device\n disconnect [target] Stop mirroring (all streams, or only the given IP)\n mute [target] Mute mirrored audio (all streams, or only the given IP)\n unmute [target] Unmute mirrored audio (all streams, or only the given IP)\n\nFlags:\n -socket path Override daemon socket path (default: %s)\n", daemon.DefaultSocketPath())
fmt.Fprintf(os.Stderr, "Usage: doubletake-ctl [-socket path] <command> [args]\n\nCommands:\n status Show daemon state and all active streams\n discover Discover AirPlay devices on the network\n devices List cached discovered devices\n connect [target] [PIN-or-password] Start mirroring (to target IP, or first free device)\n pin <PIN-or-password> Submit pairing credentials for a waiting device\n disconnect [target] Stop mirroring (all streams, or only the given IP)\n reset-restore-token <target> Clear one Wayland restore token and reconnect that target\n mute [target] Mute mirrored audio (all streams, or only the given IP)\n unmute [target] Unmute mirrored audio (all streams, or only the given IP)\n\nFlags:\n -socket path Override daemon socket path (default: %s)\n", daemon.DefaultSocketPath())
}
183 changes: 183 additions & 0 deletions cmd/doubletake-ctl/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package main

import (
"bytes"
"context"
"encoding/json"
"errors"
"net"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)

const cliHelperEnvironment = "DOUBLETAKE_CTL_TEST_HELPER"

type controlFixtureResult struct {
request map[string]json.RawMessage
err error
}

func TestResetRestoreTokenCLI(t *testing.T) {
listener, results := startControlFixture(t)
socketPath := listener.Addr().String()

code, stdout, stderr := runCLIProcess(t,
"-socket", socketPath,
"reset-restore-token", "192.0.2.10",
)
if code != 0 {
t.Fatalf("reset-restore-token exit code = %d, want 0; stdout=%q stderr=%q", code, stdout, stderr)
}

result := awaitControlFixture(t, results)
if result.err != nil {
t.Fatalf("control fixture: %v", result.err)
}
if len(result.request) != 2 {
t.Fatalf("request fields = %v, want exactly cmd and target", result.request)
}
assertJSONStringField(t, result.request, "cmd", "reset-restore-token")
assertJSONStringField(t, result.request, "target", "192.0.2.10")

if err := listener.Close(); err != nil {
t.Fatalf("close control fixture: %v", err)
}
}

func TestResetRestoreTokenCLIRejectsInvalidArityWithoutContactingDaemon(t *testing.T) {
for _, test := range []struct {
name string
args []string
}{
{name: "missing target", args: []string{"reset-restore-token"}},
{name: "extra target", args: []string{"reset-restore-token", "192.0.2.10", "192.0.2.11"}},
} {
t.Run(test.name, func(t *testing.T) {
listener, results := startControlFixture(t)
args := append([]string{"-socket", listener.Addr().String()}, test.args...)

code, stdout, stderr := runCLIProcess(t, args...)
if code == 0 {
t.Fatalf("invalid reset exit code = 0, want nonzero; stdout=%q stderr=%q", stdout, stderr)
}
if err := listener.Close(); err != nil {
t.Fatalf("close control fixture: %v", err)
}

result := awaitControlFixture(t, results)
if result.err == nil {
t.Fatalf("invalid reset contacted daemon with request %v", result.request)
}
if !errors.Is(result.err, net.ErrClosed) {
t.Fatalf("control fixture stopped with %v, want listener close", result.err)
}
})
}
}

func TestDoubletakeCtlHelperProcess(t *testing.T) {
if os.Getenv(cliHelperEnvironment) != "1" {
return
}
separator := -1
for i, arg := range os.Args {
if arg == "--" {
separator = i
break
}
}
if separator < 0 {
os.Exit(2)
}
os.Args = append([]string{"doubletake-ctl"}, os.Args[separator+1:]...)
main()
}

func startControlFixture(t *testing.T) (net.Listener, <-chan controlFixtureResult) {
t.Helper()
listener, err := net.Listen("unix", filepath.Join(t.TempDir(), "doubletake.sock"))
if err != nil {
t.Fatalf("listen on control socket: %v", err)
}
t.Cleanup(func() { _ = listener.Close() })
results := make(chan controlFixtureResult, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
results <- controlFixtureResult{err: err}
return
}
defer conn.Close()

var request map[string]json.RawMessage
if err := json.NewDecoder(conn).Decode(&request); err != nil {
results <- controlFixtureResult{err: err}
return
}
if err := json.NewEncoder(conn).Encode(map[string]any{"ok": true, "state": "connecting"}); err != nil {
results <- controlFixtureResult{err: err}
return
}
results <- controlFixtureResult{request: request}
}()
return listener, results
}

func runCLIProcess(t *testing.T, args ...string) (int, string, string) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()

commandArgs := []string{"-test.run=^TestDoubletakeCtlHelperProcess$", "--"}
commandArgs = append(commandArgs, args...)
cmd := exec.CommandContext(ctx, os.Args[0], commandArgs...)
cmd.Env = append(os.Environ(), cliHelperEnvironment+"=1")
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

err := cmd.Run()
if ctx.Err() != nil {
t.Fatalf("CLI process did not complete: %v", ctx.Err())
}
if err == nil {
return 0, stdout.String(), stderr.String()
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("run CLI process: %v", err)
}
return exitErr.ExitCode(), stdout.String(), stderr.String()
}

func awaitControlFixture(t *testing.T, results <-chan controlFixtureResult) controlFixtureResult {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
select {
case result := <-results:
return result
case <-ctx.Done():
t.Fatalf("control fixture did not finish: %v", ctx.Err())
return controlFixtureResult{}
}
}

func assertJSONStringField(t *testing.T, request map[string]json.RawMessage, field, want string) {
t.Helper()
raw, ok := request[field]
if !ok {
t.Fatalf("request is missing %q: %v", field, request)
}
var got string
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("decode request field %q: %v", field, err)
}
if got != want {
t.Fatalf("request field %q = %q, want %q", field, got, want)
}
}
1 change: 1 addition & 0 deletions cmd/doubletake/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ func main() {
}
streamCfg.AutomaticHEVCAvailable = capturePreparation.AutomaticHEVCAvailable()
streamCfg.MeasuredVideoLatency = capturePreparation.MeasuredVideoLatency()
streamCfg.MinimumVideoLead = capturePreparation.MinimumVideoLead()

var capture *airplay.ScreenCapture
var broadcast *airplay.BroadcastCapture
Expand Down
Loading