Skip to content
Merged
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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ These are devices that have been tested with doubletake. If there are devices no
- AppleTV3,2 (2013 3rd generation) (currently non-functional, see [#17](https://github.com/omarroth/doubletake/issues/17))
- AppleTV11,1 (4K, 2021 2nd gen)
- AppleTV14,1 (4K, 2022 3rd gen) + Homepod (1st gen)
- AppleTV14,1 (4K, 2022 3rd gen)
- Mac17,2 (MacBook Pro, M5 14")
- Mac16,10 (Mac mini, M4)
- Roku Streaming Stick 4K (3820R2)
Expand Down Expand Up @@ -118,6 +119,34 @@ sudo ufw allow from any proto tcp to any port 60000:60010
For nftables/firewalld, add equivalent rules allowing inbound UDP and TCP from
the Apple TV's address on the chosen range.

## Password-protected receivers

If the receiver has **Require Password** enabled (on an Apple TV: Settings →
AirPlay and HomeKit), it challenges the mirroring `SETUP` request with HTTP
Digest auth and mirroring fails with `HTTP 401` until doubletake answers it.
Pass the password with `-code`:

```sh
DOUBLETAKE_CODE='...' doubletake -target 192.168.1.77
```

`-code` carries whatever the receiver is asking for — the onscreen pairing PIN
during `-pair`, or the fixed password when "Require Password" is enabled.
`$DOUBLETAKE_CODE` is preferred over the flag: a command line is visible to
other users via `ps` and lands in shell history. The environment variable takes
precedence when both are set.

Note that this is separate from pairing. Pairing (`-pair`) authenticates the
client identity via SRP and saves credentials; the password authenticates
individual RTSP requests. A receiver may require either, both, or neither.
Supplying `-code` does not by itself trigger re-pairing.

One thing that makes this confusing to diagnose: **"Require Password" is a
fixed password you set, not a rotating onscreen code.** Nothing appears on the
TV during `-pair`, and the prompt is asking for that configured password.

Run with `-debug` to see the challenge and whether the retry was accepted.

## Usage

```sh
Expand Down Expand Up @@ -167,7 +196,7 @@ doubletake-ctl disconnect
|------|---------|-------------|
| `-target` | | Apple TV IP (skip mDNS discovery) |
| `-port` | 7000 | AirPlay port |
| `-pin` | | 4-digit PIN for pairing |
| `-code` | | Pairing PIN shown on the receiver, or its configured password when "Require Password" is enabled (see [Password-protected receivers](#password-protected-receivers)); prefer `$DOUBLETAKE_CODE` |
| `-cred-backend` | `file` | Credential backend (`file` or `keyring`) |
| `-creds` | `~/.config/doubletake/credentials.json` | Credentials file path |
| `-pair` | false | Force new pairing |
Expand Down
82 changes: 55 additions & 27 deletions cmd/doubletake/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bufio"
"context"
"errors"
"flag"
Expand Down Expand Up @@ -58,7 +59,7 @@ func parsePortRange(s string) (int, int, error) {
func main() {
target := flag.String("target", "", "Apple TV IP address or hostname (skip discovery)")
port := flag.Int("port", 7000, "AirPlay port")
pin := flag.String("pin", "", "4-digit PIN for pairing (shown on Apple TV)")
code := flag.String("code", "", "Pairing PIN shown on the receiver, or the password set on it when \"Require Password\" is enabled; prefer $DOUBLETAKE_CODE so it stays out of shell history and ps output")
credFile := flag.String("creds", airplay.DefaultCredentialsPath(), "Path to saved pairing credentials")
credBackend := flag.String("cred-backend", "file", "Credential storage backend: file or keyring (system keyring via Secret Service)")
forcePair := flag.Bool("pair", false, "Force new pairing even if credentials exist")
Expand Down Expand Up @@ -127,7 +128,15 @@ func main() {
fmt.Printf("selected: %s (%s:%d)\n", device.Name, device.IP, device.Port)
}

// The environment wins over the flag: it keeps the code out of shell history
// and out of `ps`, where a command line is readable by other users.
airPlayCode := *code
if env := os.Getenv("DOUBLETAKE_CODE"); env != "" {
airPlayCode = env
}

client := airplay.NewAirPlayClient(addr, *port)
client.SetPassword(airPlayCode)
if err := client.Connect(ctx); err != nil {
log.Fatalf("connect failed: %v", err)
}
Expand All @@ -140,10 +149,15 @@ func main() {
log.Printf("connected to: %s (model: %s, initialVolume: %.1f)", info.Name, info.Model, info.InitialVolume)

// Pairing flow:
// 1. If --pin provided or --pair forced, do full pair-setup + save credentials
// 1. If --pair is forced, do full pair-setup + save credentials
// 2. If saved credentials exist, load them and do pair-verify only
// 3. Otherwise, do transient (ephemeral) pairing
needFullPair := *forcePair || *pin != ""
//
// -code alone does not force pairing. A receiver with "Require Password"
// needs the code on every session, so treating its presence as "pair again"
// would re-pair on every run and throw away working credentials. When a
// pairing PIN is what is actually wanted, -pair asks for one.
needFullPair := *forcePair

credStore, err := newCredentialStore(*credBackend, *credFile)
if err != nil {
Expand All @@ -157,15 +171,7 @@ func main() {

if needFullPair {
// Full pair-setup with PIN
pinVal := *pin
if pinVal == "" {
// Trigger PIN display on the TV first, then ask user
if err := client.StartPINDisplay(); err != nil {
log.Fatalf("failed to trigger PIN display: %v", err)
}
fmt.Print("Enter the PIN shown on Apple TV: ")
fmt.Scanln(&pinVal)
}
pinVal := codeOrPrompt(airPlayCode, client)
if err := client.Pair(ctx, pinVal); err != nil {
log.Fatalf("pairing failed: %v", err)
}
Expand Down Expand Up @@ -195,11 +201,12 @@ func main() {
log.Fatalf("get info after reconnect failed: %v", err)
}
if err := client.Pair(ctx, ""); err != nil {
log.Printf("transient pairing fallback failed: %v, prompting for PIN", err)
pinVal := promptForPIN(client)
log.Printf("transient pairing fallback failed: %v, pairing with a code", err)
pinVal := codeOrPrompt(airPlayCode, client)
// Reconnect for fresh PIN pairing attempt
client.Close()
client = airplay.NewAirPlayClient(addr, *port)
client.SetPassword(airPlayCode)
if err := client.Connect(ctx); err != nil {
log.Fatalf("reconnect failed: %v", err)
}
Expand All @@ -220,11 +227,12 @@ func main() {
} else {
// Transient pairing (no saved creds, no PIN)
if err := client.Pair(ctx, ""); err != nil {
log.Printf("transient pairing failed: %v, prompting for PIN", err)
pinVal := promptForPIN(client)
log.Printf("transient pairing failed: %v, pairing with a code", err)
pinVal := codeOrPrompt(airPlayCode, client)
// Reconnect for fresh PIN pairing attempt
client.Close()
client = airplay.NewAirPlayClient(addr, *port)
client.SetPassword(airPlayCode)
if err := client.Connect(ctx); err != nil {
log.Fatalf("reconnect failed: %v", err)
}
Expand Down Expand Up @@ -263,13 +271,13 @@ func main() {
log.Fatalf("invalid -port-range: %v", err)
}
streamCfg := airplay.StreamConfig{
FPS: *fps,
Bitrate: *bitrate,
NoEncrypt: *noEncrypt,
DirectKey: *directKey,
NoAudio: *noAudio,
PortMin: portMin,
PortMax: portMax,
FPS: *fps,
Bitrate: *bitrate,
NoEncrypt: *noEncrypt,
DirectKey: *directKey,
NoAudio: *noAudio,
PortMin: portMin,
PortMax: portMax,
}
session, err := client.SetupMirror(ctx, streamCfg)
if err != nil {
Expand Down Expand Up @@ -349,14 +357,34 @@ func main() {
log.Println("stream ended")
}

// codeOrPrompt uses the code supplied on the command line if there is one, and
// otherwise asks for it interactively. The same value serves as the pairing PIN
// and as the password for Digest auth, so a user who supplied one is never
// asked for it again mid-run.
func codeOrPrompt(code string, client *airplay.AirPlayClient) string {
if code != "" {
return code
}
return promptForPIN(client)
}

// promptForPIN asks the receiver to display a pairing code and reads the user's
// answer. Receivers configured with a fixed password rather than an onscreen
// code show nothing at all -- that is not an error, the user simply types the
// password they set, so a failed pair-pin-start is only a warning.
func promptForPIN(client *airplay.AirPlayClient) string {
if err := client.StartPINDisplay(); err != nil {
log.Printf("warning: failed to trigger PIN display: %v", err)
}
fmt.Print("Enter the PIN shown on Apple TV: ")
var pinVal string
fmt.Scanln(&pinVal)
return pinVal
fmt.Print("Enter the code shown on the receiver, or its configured password: ")

// Read the whole line rather than using fmt.Scanln, which stops at the
// first space and would silently truncate a password containing one.
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
log.Printf("warning: failed to read PIN: %v", err)
}
return strings.TrimRight(line, "\r\n")
}

func selectDevice(ctx context.Context) (*airplay.AirPlayDevice, error) {
Expand Down
Loading
Loading