Skip to content

Commit 1fd020d

Browse files
committed
Add restore-token reset control command
Let control clients clear one target's Wayland portal restore token and reconnect it without rewriting other credentials. Preserve receiver ports and reject shared capture groups so unrelated streams stay intact. Co-authored-by: minpeter <minpeterx@gmail.com> Generated with [OpenAI Codex](https://openai.com/codex/)
1 parent ae06722 commit 1fd020d

10 files changed

Lines changed: 442 additions & 1 deletion

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ doubletake-ctl status
344344
doubletake-ctl connect 192.168.1.77
345345
doubletake-ctl connect 192.168.1.133
346346
doubletake-ctl disconnect 192.168.1.77
347+
doubletake-ctl reset-restore-token 192.168.1.133
347348
doubletake-ctl disconnect
348349
```
349350

@@ -399,13 +400,19 @@ doubletake-ctl devices
399400
doubletake-ctl connect [target] [PIN-or-password]
400401
doubletake-ctl pin <PIN-or-password>
401402
doubletake-ctl disconnect [target]
403+
doubletake-ctl reset-restore-token <target>
402404
doubletake-ctl mute [target]
403405
doubletake-ctl unmute [target]
404406
```
405407

406408
- `disconnect` without a target stops all active streams.
407409
- `disconnect <target>` stops only that receiver.
408410
- `mute`/`unmute` can operate globally or per target.
411+
- `reset-restore-token <target>` stops one fully streaming receiver, clears only
412+
its saved Wayland portal restore token, and reconnects it on the same IP and
413+
port. Pairing credentials and other streams are unchanged. The command rejects
414+
targets sharing a capture group; disconnect those peers first so the old portal
415+
source can be stopped before a replacement is authorized.
409416
- `pin` retains its historical command name, but submits whichever credential
410417
the daemon requests: an on-screen PIN or a configured password. It is
411418
targetless and therefore requires exactly one waiting receiver; use

cmd/doubletake-ctl/main.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ func main() {
6161
} else {
6262
resp, err = client.Disconnect()
6363
}
64+
case "reset-restore-token":
65+
if len(args) != 2 {
66+
fmt.Fprintln(os.Stderr, "Usage: doubletake-ctl reset-restore-token <target>")
67+
os.Exit(1)
68+
}
69+
resp, err = client.ResetRestoreToken(args[1])
6470
case "mute":
6571
if len(args) >= 2 {
6672
resp, err = client.MuteTarget(args[1])
@@ -94,5 +100,5 @@ func main() {
94100
}
95101

96102
func usage() {
97-
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())
103+
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())
98104
}

internal/airplay/credentials.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,24 @@ func (cs *CredentialStore) SaveRestoreToken(deviceID, restoreToken string) error
193193
return cs.backend.Save(deviceID, creds)
194194
}
195195

196+
// ClearRestoreToken removes only the Wayland screencast restore token for a
197+
// device. Pairing credentials and all other device entries are preserved.
198+
func (cs *CredentialStore) ClearRestoreToken(deviceID string) error {
199+
cs.mu.Lock()
200+
defer cs.mu.Unlock()
201+
202+
creds, err := cs.backend.Lookup(deviceID)
203+
if err != nil {
204+
return err
205+
}
206+
if creds == nil || creds.RestoreToken == "" {
207+
return nil
208+
}
209+
updated := *creds
210+
updated.RestoreToken = ""
211+
return cs.backend.Save(deviceID, &updated)
212+
}
213+
196214
// fileBackend stores credentials as a JSON file on disk.
197215
type fileBackend struct {
198216
path string
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package airplay
2+
3+
import (
4+
"crypto/ed25519"
5+
"crypto/rand"
6+
"path/filepath"
7+
"testing"
8+
)
9+
10+
func TestCredentialStoreClearRestoreTokenPreservesPairingAndOtherDevices(t *testing.T) {
11+
path := filepath.Join(t.TempDir(), "credentials.json")
12+
store, err := NewCredentialStore(path)
13+
if err != nil {
14+
t.Fatalf("NewCredentialStore: %v", err)
15+
}
16+
pub, priv, err := ed25519.GenerateKey(rand.Reader)
17+
if err != nil {
18+
t.Fatalf("GenerateKey: %v", err)
19+
}
20+
if err := store.SavePairing("device-1", "pair-1", pub, priv, PairingProtocolHAP); err != nil {
21+
t.Fatalf("SavePairing: %v", err)
22+
}
23+
if err := store.SaveRestoreToken("device-1", "restore-1"); err != nil {
24+
t.Fatalf("SaveRestoreToken device-1: %v", err)
25+
}
26+
if err := store.SaveRestoreToken("device-2", "restore-2"); err != nil {
27+
t.Fatalf("SaveRestoreToken device-2: %v", err)
28+
}
29+
30+
if err := store.ClearRestoreToken("device-1"); err != nil {
31+
t.Fatalf("ClearRestoreToken: %v", err)
32+
}
33+
34+
reloaded, err := NewCredentialStore(path)
35+
if err != nil {
36+
t.Fatalf("reload credential store: %v", err)
37+
}
38+
cleared := reloaded.Lookup("device-1")
39+
if cleared == nil || !cleared.HasPairingCredentials() {
40+
t.Fatal("clearing the restore token removed pairing credentials")
41+
}
42+
if cleared.PairingID != "pair-1" || cleared.PairingProtocol != PairingProtocolHAP {
43+
t.Fatalf("pairing metadata changed: %+v", cleared)
44+
}
45+
if cleared.RestoreToken != "" {
46+
t.Fatalf("restore token = %q, want empty", cleared.RestoreToken)
47+
}
48+
other := reloaded.Lookup("device-2")
49+
if other == nil || other.RestoreToken != "restore-2" {
50+
t.Fatalf("other device changed: %+v", other)
51+
}
52+
}
53+
54+
type recordingCredentialBackend struct {
55+
devices map[string]*SavedCredentials
56+
saves []string
57+
}
58+
59+
func (b *recordingCredentialBackend) Lookup(deviceID string) (*SavedCredentials, error) {
60+
return b.devices[deviceID], nil
61+
}
62+
63+
func (b *recordingCredentialBackend) Save(deviceID string, creds *SavedCredentials) error {
64+
b.devices[deviceID] = creds
65+
b.saves = append(b.saves, deviceID)
66+
return nil
67+
}
68+
69+
func TestCredentialStoreClearRestoreTokenUsesBackendWithoutDeletingEntry(t *testing.T) {
70+
backend := &recordingCredentialBackend{devices: map[string]*SavedCredentials{
71+
"device-1": {PairingID: "pair-1", RestoreToken: "restore-1"},
72+
}}
73+
store := NewCredentialStoreWithBackend(backend)
74+
75+
if err := store.ClearRestoreToken("device-1"); err != nil {
76+
t.Fatalf("ClearRestoreToken: %v", err)
77+
}
78+
79+
if len(backend.saves) != 1 || backend.saves[0] != "device-1" {
80+
t.Fatalf("backend saves = %v, want [device-1]", backend.saves)
81+
}
82+
creds := backend.devices["device-1"]
83+
if creds == nil || creds.PairingID != "pair-1" || creds.RestoreToken != "" {
84+
t.Fatalf("backend credentials after clear = %+v", creds)
85+
}
86+
}

internal/daemon/daemon.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ type activeStream struct {
204204
device string // friendly name
205205
deviceIP string
206206
deviceID string
207+
port int
207208
state State
208209
audioMuted bool
209210
session *airplay.MirrorSession
@@ -548,6 +549,8 @@ func (d *Daemon) handleRequest(req Request) Response {
548549
return d.handleConnect(req)
549550
case "disconnect":
550551
return d.handleDisconnect(req)
552+
case "reset-restore-token":
553+
return d.handleResetRestoreToken(req)
551554
case "mute":
552555
return d.handleSetMute(req, true)
553556
case "unmute":
@@ -783,6 +786,7 @@ func (d *Daemon) handleConnect(req Request) Response {
783786
connCtx, cancel := context.WithCancel(context.Background())
784787
entry := &activeStream{
785788
deviceIP: target,
789+
port: port,
786790
state: StateConnecting,
787791
cancelFn: cancel,
788792
credentialCh: make(chan string, 1),

internal/daemon/daemonclient/client.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ func (c *Client) DisconnectTarget(target string) (*daemon.Response, error) {
5454
return c.send(daemon.Request{Cmd: "disconnect", Target: target})
5555
}
5656

57+
// ResetRestoreToken clears one active receiver's Wayland restore token and
58+
// reconnects that same target.
59+
func (c *Client) ResetRestoreToken(target string) (*daemon.Response, error) {
60+
return c.send(daemon.Request{Cmd: "reset-restore-token", Target: target})
61+
}
62+
5763
// Mute mutes mirrored audio on all active sessions.
5864
func (c *Client) Mute() (*daemon.Response, error) {
5965
return c.send(daemon.Request{Cmd: "mute"})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package daemonclient
2+
3+
import (
4+
"encoding/json"
5+
"net"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
10+
"doubletake/internal/daemon"
11+
)
12+
13+
func TestClientResetRestoreTokenSendsTargetedCommand(t *testing.T) {
14+
socketPath := filepath.Join(t.TempDir(), "doubletake.sock")
15+
listener, err := net.Listen("unix", socketPath)
16+
if err != nil {
17+
t.Fatalf("listen: %v", err)
18+
}
19+
defer listener.Close()
20+
requestCh := make(chan daemon.Request, 1)
21+
go func() {
22+
conn, acceptErr := listener.Accept()
23+
if acceptErr != nil {
24+
return
25+
}
26+
defer conn.Close()
27+
var request daemon.Request
28+
if json.NewDecoder(conn).Decode(&request) != nil {
29+
return
30+
}
31+
requestCh <- request
32+
_ = json.NewEncoder(conn).Encode(daemon.Response{OK: true, State: daemon.StateConnecting})
33+
}()
34+
client := New(socketPath)
35+
36+
response, err := client.ResetRestoreToken("192.0.2.10")
37+
38+
if err != nil {
39+
t.Fatalf("ResetRestoreToken: %v", err)
40+
}
41+
if response == nil || !response.OK {
42+
t.Fatalf("response = %+v", response)
43+
}
44+
select {
45+
case request := <-requestCh:
46+
if request.Cmd != "reset-restore-token" || request.Target != "192.0.2.10" {
47+
t.Fatalf("request = %+v", request)
48+
}
49+
case <-time.After(time.Second):
50+
t.Fatal("client did not send reset request")
51+
}
52+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package daemon
2+
3+
import "context"
4+
5+
func (d *Daemon) handleResetRestoreToken(req Request) Response {
6+
d.mu.Lock()
7+
if req.Target == "" {
8+
response := Response{OK: false, State: d.overallStateLocked(), Error: "reset-restore-token requires a target IP"}
9+
d.mu.Unlock()
10+
return response
11+
}
12+
entry, ok := d.streams[req.Target]
13+
if !ok {
14+
response := Response{OK: false, State: d.overallStateLocked(), Error: "no active stream to " + req.Target}
15+
d.mu.Unlock()
16+
return response
17+
}
18+
if entry.state != StateStreaming {
19+
response := Response{OK: false, State: d.overallStateLocked(), Error: "restore token can be reset only for a fully streaming target: " + req.Target}
20+
d.mu.Unlock()
21+
return response
22+
}
23+
if entry.captureGroup == nil || d.captureGroups[entry.captureGroup.key] != entry.captureGroup {
24+
response := Response{OK: false, State: d.overallStateLocked(), Error: "active target has no owned capture group: " + req.Target}
25+
d.mu.Unlock()
26+
return response
27+
}
28+
for target, other := range d.streams {
29+
if target != req.Target && other.captureGroup == entry.captureGroup {
30+
response := Response{OK: false, State: d.overallStateLocked(), Error: "target uses a shared capture group; disconnect its peers before resetting the restore token"}
31+
d.mu.Unlock()
32+
return response
33+
}
34+
}
35+
if entry.deviceID == "" || entry.port == 0 {
36+
response := Response{OK: false, State: d.overallStateLocked(), Error: "active target is missing receiver identity or port: " + req.Target}
37+
d.mu.Unlock()
38+
return response
39+
}
40+
41+
target := entry.deviceIP
42+
deviceID := entry.deviceID
43+
port := entry.port
44+
cleanup := d.detachStreamLocked(target)
45+
connCtx, cancel := context.WithCancel(context.Background())
46+
replacement := &activeStream{
47+
deviceIP: target,
48+
deviceID: deviceID,
49+
port: port,
50+
state: StateConnecting,
51+
cancelFn: cancel,
52+
credentialCh: make(chan string, 1),
53+
}
54+
d.clearLastErrorForTargetLocked(target)
55+
d.streams[target] = replacement
56+
// Reserve the target and register the replacement worker before unlocking.
57+
// Shutdown can detach the reservation, but cannot finish Wait until this
58+
// reset either abandons it or hands ownership to connectAndStream.
59+
d.streamWorkers.Add(1)
60+
d.mu.Unlock()
61+
62+
cleanup.run()
63+
64+
d.mu.Lock()
65+
if d.shuttingDown || d.streams[target] != replacement {
66+
if d.streams[target] == replacement {
67+
abandoned := d.detachStreamLocked(target)
68+
d.mu.Unlock()
69+
abandoned.run()
70+
} else {
71+
d.mu.Unlock()
72+
}
73+
d.streamWorkers.Done()
74+
return Response{OK: false, State: StateIdle, Error: "daemon is shutting down"}
75+
}
76+
if err := d.credStore.ClearRestoreToken(deviceID); err != nil {
77+
abandoned := d.detachStreamLocked(target)
78+
state := d.overallStateLocked()
79+
d.mu.Unlock()
80+
abandoned.run()
81+
d.streamWorkers.Done()
82+
return Response{OK: false, State: state, Error: "clear restore token: " + err.Error()}
83+
}
84+
state := d.overallStateLocked()
85+
d.mu.Unlock()
86+
87+
go func() {
88+
defer d.streamWorkers.Done()
89+
d.connectAndStream(connCtx, replacement, target, port, "")
90+
}()
91+
return Response{OK: true, State: state, Device: target, DeviceIP: target}
92+
}

0 commit comments

Comments
 (0)