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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Add the Crabfleet Connect foundation with a shared Go RFB 3.8 host core, per-run VNC-DES authentication, Tight/JPEG and client-side cursor/input support, a CI-safe synthetic backend, and a Linux X11 MIT-SHM/XFixes/XTest backend plus cross-compiled CLI, while documenting deferred codecs, Wayland, ARD, audio, and hardware validation.
- Warn without blocking Share This Mac when Tailscale reports a numerically suffixed duplicate registration, while continuing to advertise the current `Self` node address.
- Blend the macOS title bar into the desktop deck, align its unified top band, and add hover, focus, refresh-progress, and empty-state interaction polish.
- Keep Share This Mac permission status and start availability synchronized with Screen Recording and Accessibility grants made in System Settings while the sheet is open.
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,25 @@ Full documentation available at [docs.crabfleet.ai](https://docs.crabfleet.ai):
- [API](https://docs.crabfleet.ai/api) – REST and WebSocket APIs
- [Spec](https://docs.crabfleet.ai/spec) – Complete product specification

## Crabfleet Connect for Linux

`crabfleet-connect` is the first cross-platform host foundation for sharing a
Linux machine to the native macOS viewer or browser client. It provides a Go
RFB 3.8 server, a fresh per-run VNC password, Tight/JPEG frames, client-side
cursor updates, remote input, a synthetic test backend, and a Linux X11
MIT-SHM/XFixes/XTest backend. The X11 backend cross-compiles in CI but has not
been validated on physical Linux hardware yet.

The CLI listens on loopback by default because VNC-DES does not encrypt RFB
traffic. Remote use requires an explicit `--bind` on an already protected
private path.

This increment does not provide ARD host authentication, H.264 or HEVC
encoding, Wayland/PipeWire capture, multi-group XKB input, audio, clipboard
synchronization, or service packaging. See
[`cmd/crabfleet-connect/README.md`](cmd/crabfleet-connect/README.md)
for the exact boundary and run command.

## Security

- All state-changing operations require authentication
Expand Down
23 changes: 23 additions & 0 deletions cmd/crabfleet-connect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Crabfleet Connect foundation

`crabfleet-connect` is the first Linux host foundation for sharing a machine to Crabfleet's macOS viewer or browser client.

Real in this increment:

- RFB 3.8 server handshake and direct-listener VNC-DES authentication with an eight-character per-run share password; Security None is never offered.
- Tight/JPEG full-frame updates, client-side cursor pseudo-encodings, pointer/key input, strict protocol bounds, bounded sessions, and idempotent teardown.
- A pure-Go synthetic capture/input backend used by tests and as an explicit fallback.
- A Linux X11 backend implemented with MIT-SHM `XShmGetImage` capture, XFixes cursor images, and XTest input with key-level modifier mapping. It compiles for Linux; it has not been validated on physical Linux hardware in this track. Multi-group XKB layouts fail closed to the synthetic fallback rather than risking incorrect input.

Deferred:

- ARD host authentication. Type 30 remains in the Track H-compatible security offer but fails closed; VNC-DES type 2 is the working MVP.
- H.264/HEVC hardware encoding, Wayland/PipeWire capture, multi-group XKB input, audio, clipboard synchronization, packaging/service installation, and real-hardware validation.

Run:

```sh
go run ./cmd/crabfleet-connect --display :0 --port 5900
```

The process prints its per-run share password and listener address. If native capture cannot initialize, it says why and uses the synthetic test pattern. Use `--synthetic` to force that backend. The listener defaults to `127.0.0.1` because VNC-DES authenticates but does not encrypt the RFB session. Use `--bind` to select a private interface only when the network path is already protected, such as through an authenticated tunnel; `--bind 0.0.0.0` is an explicit insecure exposure.
130 changes: 130 additions & 0 deletions cmd/crabfleet-connect/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package main

import (
"context"
"crypto/rand"
"errors"
"flag"
"fmt"
"io"
"net"
"os"
"os/signal"
"strconv"
"strings"
"syscall"

"github.com/openclaw/crabfleet/internal/connect"
"github.com/openclaw/crabfleet/internal/rfb"
)

var version = "dev"

func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "crabfleet-connect:", err)
os.Exit(1)
}
}

func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("crabfleet-connect", flag.ContinueOnError)
flags.SetOutput(stderr)
display := flags.String("display", "", "X11 display to capture (defaults to DISPLAY)")
bind := flags.String("bind", "127.0.0.1", "listener address; use a private interface explicitly for remote access")
port := flags.Int("port", 5900, "TCP port for the direct RFB listener")
synthetic := flags.Bool("synthetic", false, "force the synthetic test-pattern backend")
showVersion := flags.Bool("version", false, "print version and exit")
if err := flags.Parse(arguments); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("unexpected arguments: %v", flags.Args())
}
if *showVersion {
fmt.Fprintf(stdout, "crabfleet-connect %s\n", version)
return nil
}
if *port < 1 || *port > 65_535 {
return errors.New("port must be between 1 and 65535")
}
if strings.TrimSpace(*bind) == "" {
return errors.New("bind address must not be empty")
}

backend, description, err := selectBackend(*synthetic, *display, stderr)
if err != nil {
return err
}
password, err := generateSharePassword()
if err != nil {
_ = backend.Close()
return err
}
hostname, err := os.Hostname()
if err != nil || hostname == "" {
hostname = "Linux"
}
server, err := rfb.NewServer(rfb.ServerConfig{Session: rfb.SessionConfig{
Backend: backend,
Password: password,
DesktopName: "Crabfleet Connect (" + hostname + ")",
}})
if err != nil {
_ = backend.Close()
return err
}
listener, err := net.Listen("tcp", net.JoinHostPort(*bind, strconv.Itoa(*port)))
if err != nil {
_ = server.Close()
return fmt.Errorf("listen on port %d: %w", *port, err)
}
fmt.Fprintf(stdout, "Crabfleet Connect %s\n", version)
fmt.Fprintf(stdout, "Backend: %s\n", description)
fmt.Fprintf(stdout, "Listening: %s\n", listener.Addr())
fmt.Fprintf(stdout, "Share password: %s\n", password)
return server.Serve(ctx, listener)
}

func selectBackend(forceSynthetic bool, display string, stderr io.Writer) (connect.Backend, string, error) {
if !forceSynthetic {
backend, err := connect.NewPlatformBackend(display)
if err == nil {
return backend, "Linux X11 (MIT-SHM capture + XTest input)", nil
}
fmt.Fprintf(stderr, "Native capture unavailable (%v); using synthetic test pattern.\n", err)
}
backend, err := connect.NewSynthetic(connect.SyntheticOptions{})
if err != nil {
return nil, "", fmt.Errorf("create synthetic backend: %w", err)
}
return backend, "synthetic test pattern", nil
}

func generateSharePassword() (string, error) {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
const length = 8
result := make([]byte, 0, length)
limit := byte(256 - (256 % len(alphabet)))
buffer := make([]byte, 32)
for len(result) < length {
if _, err := io.ReadFull(rand.Reader, buffer); err != nil {
return "", fmt.Errorf("generate share password: %w", err)
}
for _, value := range buffer {
if value >= limit {
continue
}
result = append(result, alphabet[int(value)%len(alphabet)])
if len(result) == length {
break
}
}
}
return string(result), nil
}
62 changes: 62 additions & 0 deletions cmd/crabfleet-connect/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package main

import (
"bytes"
"context"
"strings"
"testing"
)

func TestVersion(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
if err := run(context.Background(), []string{"--version"}, &stdout, &stderr); err != nil {
t.Fatal(err)
}
if stdout.String() != "crabfleet-connect dev\n" || stderr.Len() != 0 {
t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}

func TestHelpIsSuccessful(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
if err := run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil {
t.Fatal(err)
}
if stdout.Len() != 0 || !strings.Contains(stderr.String(), "Usage of crabfleet-connect:") {
t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}

func TestGenerateSharePassword(t *testing.T) {
t.Parallel()
first, err := generateSharePassword()
if err != nil {
t.Fatal(err)
}
second, err := generateSharePassword()
if err != nil {
t.Fatal(err)
}
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
if len(first) != 8 || strings.Trim(first, alphabet) != "" || first == second {
t.Fatalf("generated passwords %q and %q", first, second)
}
}

func TestRejectsInvalidPort(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
if err := run(context.Background(), []string{"--port", "0"}, &stdout, &stderr); err == nil {
t.Fatal("accepted invalid port")
}
}

func TestRejectsEmptyBindAddress(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
if err := run(context.Background(), []string{"--bind="}, &stdout, &stderr); err == nil {
t.Fatal("accepted empty bind address")
}
}
21 changes: 21 additions & 0 deletions docs/macos-native-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,27 @@ full UTF-8 text with servers that negotiate it; against servers without the
extension, standard cut text must encode losslessly as ISO-8859-1 and
unsupported text is rejected instead of silently becoming empty data.

## Linux Connect foundation

The native viewer can also connect directly to the first
`crabfleet-connect` Linux host foundation. That Go host speaks RFB 3.8 with a
fresh per-run VNC-DES password, Tight/JPEG full-frame updates, client-side
cursor rectangles, and pointer/key input. Its synthetic backend provides the
portable CI and protocol-test path. The Linux-only backend implements X11
capture with MIT-SHM `XShmGetImage`, cursor images with XFixes, and input with
XTest; it cross-compiles for amd64 and arm64 but has not been exercised on
physical Linux hardware in this increment.

The Connect listener defaults to loopback because VNC-DES does not encrypt RFB
traffic. A remote listener requires an explicit private bind on a separately
protected network path.

The Linux host still advertises the direct-listener ARD security type for wire
compatibility, but ARD host authentication fails closed and viewers must select
VNC password authentication. H.264/HEVC encoding, Wayland/PipeWire, audio,
multi-group XKB input, clipboard synchronization, service packaging, and
real-hardware validation remain follow-up work.

## Share This Mac

The host path is deliberately app-owned. It does not start, configure, proxy,
Expand Down
16 changes: 8 additions & 8 deletions docs/screen-recording-indicator.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ Sonoma, **blue on macOS 26 Tahoe**) and why macOS periodically re-prompts for sc
For any third-party app running an `SCStream`, this is **mandatory and unsuppressible** —
there is no app-side API (`SCStreamConfiguration`/`SCContentFilter` do not affect it), and it
is independent of display-vs-window capture, audio capture, or picker choice. It exists as a
privacy guarantee. The only Apple-sanctioned hide is the *external-display* exemption
privacy guarantee. The only Apple-sanctioned hide is the _external-display_ exemption
(`system-override suppress-sw-camera-indication-on-external-displays=on`, Apple Support
118449), which does not cover a host sharing its built-in/primary display.

2. **The recurring "…bypass the system private window picker and directly access your screen and
audio" prompt** — macOS 15+ periodic TCC re-authorization for SCK used *without* the system
`SCContentSharingPicker`. This is **not** an indicator; it's a consent nag, and it *is*
audio" prompt** — macOS 15+ periodic TCC re-authorization for SCK used _without_ the system
`SCContentSharingPicker`. This is **not** an indicator; it's a consent nag, and it _is_
solvable (below).

Do not conflate the two: the prompt is fixable, the indicator largely is not for third-party apps.
Expand All @@ -28,26 +28,26 @@ the third-party indicator. Jump Desktop appears exempt because its capture lands
**Remote Desktop / Remote Management** TCC class (`kTCCServiceRemoteDesktop`) — the same
unattended-access bucket as Apple's Screen Sharing — rather than plain "Screen Recording".
Jump also holds Apple's restricted entitlement **`com.apple.developer.persistent-content-capture`**,
which removes the recurring re-auth prompt for VNC-style apps. (Jump migrated *toward* SCK, not
which removes the recurring re-auth prompt for VNC-style apps. (Jump migrated _toward_ SCK, not
away from it; the legacy `CGDisplayStream`/`CGWindowListCreateImage` path is deprecated and
triggers *more* consent alerts on Sonoma+.)
triggers _more_ consent alerts on Sonoma+.)

**Unverified:** whether the Remote Desktop grant actually removes the *indicator* on macOS 26 for
**Unverified:** whether the Remote Desktop grant actually removes the _indicator_ on macOS 26 for
a third-party app. The prompt removal is documented; the indicator removal is a plausible
side effect of the permission class but must be confirmed on-device before relying on it.

## Options for Crabfleet (ranked)

1. **`persistent-content-capture` entitlement + Remote Desktop grant** — the real "Jump playbook".
Removes the recurring prompt; may drop the indicator (verify on-device). Cost: Apple-*gated*
Removes the recurring prompt; may drop the indicator (verify on-device). Cost: Apple-_gated_
restricted entitlement (applied for per signing identity — **open-source status does not waive
this**), a dedicated App ID, per-executable provisioning profiles, notarization, and users must
grant under Privacy → Remote Desktop, not Screen Recording. Medium effort, external approval
dependency.
2. **Stay on SCK, adopt `SCContentSharingPicker`** — removes the "bypass the private window picker"
prompt with no entitlement, but adds a picker step (undesirable for an unattended host) and does
**not** remove the indicator. Low effort; honest fallback.
3. **Legacy `CGDisplayStream`/`CGWindowListCreateImage`** — rejected: deprecated, *more* prompts,
3. **Legacy `CGDisplayStream`/`CGWindowListCreateImage`** — rejected: deprecated, _more_ prompts,
still lights the indicator.
4. **System extension / DriverKit virtual display** — could sidestep the built-in-display indicator
by capturing a synthetic display, but very high cost (system-extension approval, DriverKit
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/alecthomas/kong v1.15.0
github.com/coder/websocket v1.8.15
github.com/jezek/xgb v1.3.1
golang.org/x/crypto v0.54.0
golang.org/x/sys v0.47.0
)

require golang.org/x/sys v0.47.0 // indirect
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jezek/xgb v1.3.1 h1:NQCAEfQyzN+3RjWUSHBuVIxQcy2YfG3/mNvKfs/0rEg=
github.com/jezek/xgb v1.3.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
Expand Down
Loading