Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cf8e921
docs(p2p): add accountless peer design
Sep 22, 2026
7d71a12
docs(p2p): split implementation into phased plans
Sep 22, 2026
e6c7e7e
docs(p2p): prioritize relay-assisted acceleration
Sep 22, 2026
d822c12
docs(p2p): map stages to release lines
Sep 22, 2026
69d8b7c
docs(p2p): specify relay direct transport
Sep 22, 2026
b7c6de5
feat(p2p): add direct transport crypto foundation
Sep 22, 2026
8e3b8ab
feat(p2p): add direct route handover primitives
Sep 23, 2026
33ae5df
test(p2p): validate Pion direct transport feasibility
Sep 23, 2026
5e2497f
feat(p2p): add authenticated direct signaling broker
Sep 23, 2026
09244de
feat(p2p): add authenticated Pion host transport
Sep 23, 2026
fa4d571
feat(p2p): connect desktop direct host lifecycle
Sep 23, 2026
1089dd2
feat(p2p): add browser direct transport client
Sep 23, 2026
005e0a6
feat(p2p): hand over remote sessions to direct transport
Sep 23, 2026
f69d33e
feat(p2p): add direct rollout controls and diagnostics
Sep 23, 2026
c6e8b7d
feat(traffic): add personal relay and p2p dashboard
Sep 23, 2026
81d74ad
feat(traffic): expand personal usage analytics
Sep 23, 2026
ed5ac98
fix(desktop): allow loopback HTTP relay connections
Sep 23, 2026
8efb3c4
fix(settings): match relay scheme selector to dark theme
Sep 23, 2026
f84d542
refactor(settings): use custom relay protocol dropdown
Sep 23, 2026
c626160
fix(settings): keep dialog size consistent across tabs
Sep 23, 2026
0b39e9d
refactor(settings): move direct connection toggle to relay
Sep 23, 2026
eab375c
fix(p2p): keep takeover working without WebRTC
Sep 23, 2026
467a73b
refactor(terminal): merge route into remote session badge
Sep 23, 2026
0c50f5d
feat(p2p): use native Pion client on desktop
Sep 23, 2026
7a97cca
fix(linux): set desktop window icon in dev builds
Sep 24, 2026
f6a4fc5
fix(traffic): report active P2P byte usage
Sep 24, 2026
c8de2ac
refactor(settings): align direct toggle with uplink
Sep 24, 2026
09c1e1d
refactor(settings): move direct hint to info tooltip
Sep 24, 2026
8feeed5
fix(settings): remove beta wording from direct hint
Sep 24, 2026
4c44c93
Merge remote-tracking branch 'origin/main' into design/accountless-p2p
Sep 24, 2026
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
2 changes: 2 additions & 0 deletions .claude/skills/atterm-map/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ description: atterm 仓库导航——某个功能/场景要改哪些文件,
- `ATTERM_RATE_LIMIT_PER_MINUTE` / `ATTERM_MAX_CONNECTIONS_PER_KEY`:限额;`0` 默认值、负数禁用。**per-instance**(不 DB-backed),改后需重启该实例
- `ATTERM_RELAY_DEBUG` / `ATTERM_RELAY_DEBUG_PAYLOAD`:详细日志(现在 Admin → Config 也能热开关);开启即无条件输出 `[relay-debug]`,不受下面的级别阈值限制
- `ATTERM_RELAY_LOG_LEVEL` / `--log-level`:写到 stderr 的最低级别 `DEBUG|INFO|WARN|ERROR`(默认 `INFO`)。`--debug` 会把它压到 `DEBUG`
- `ATTERM_DIRECT_SIGNAL_ENABLED` / `--direct-signal`:启用 v0.6 beta WebRTC direct signaling broker;默认关闭,per-instance,改后需重启该实例
- `ATTERM_DIRECT_P2P`:桌面端启用 v0.6 beta 直连 host rollout;默认关闭,当前开发阶段仅作灰度开关,正式用户偏好和 client 接入在后续 Stage 1 版本加入

其它:
- `ATTERM_RELAY_URL` / `ATTERM_RELAY_TOKEN`:桌面 app 首次启动时若无配置文件,从这俩 env 读初始值
Expand Down
2 changes: 2 additions & 0 deletions cmd/atterm-relay/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func main() {
maxConns := flag.Int("max-connections-per-key", envInt("ATTERM_MAX_CONNECTIONS_PER_KEY", 0), "active websocket limit per remote IP/token; 0=default, negative=disable")
debug := flag.Bool("debug", debugDefault, "enable verbose relay interaction logs (or ATTERM_RELAY_DEBUG=1)")
debugPayload := flag.Bool("debug-payload", debugPayloadDefault, "include IN/OUT byte contents in debug logs (or ATTERM_RELAY_DEBUG_PAYLOAD=1)")
directSignal := flag.Bool("direct-signal", envEnabled("ATTERM_DIRECT_SIGNAL_ENABLED"), "enable the beta WebRTC direct signaling broker (or ATTERM_DIRECT_SIGNAL_ENABLED=1)")
devInsecure := flag.Bool("dev-insecure", false, "allow insecure public relay settings (unbootstrapped admin); development/private networks only")
logLevel := flag.String("log-level", envOr("ATTERM_RELAY_LOG_LEVEL", "INFO"), "minimum log level written to stderr: DEBUG|INFO|WARN|ERROR (or ATTERM_RELAY_LOG_LEVEL)")
flag.Parse()
Expand Down Expand Up @@ -204,6 +205,7 @@ func main() {
BootstrapAdminEmail: bootstrapEmail,
RealmID: realmID,
InstancePublicURL: instancePublicURL,
DirectSignalEnabled: *directSignal,
}

// VAPID subject is consumed once here; changing it later needs a restart.
Expand Down
44 changes: 43 additions & 1 deletion desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,11 @@ type RelayConfig struct {
// desktop build has no relay baseURL in localStorage at all. Read-only;
// empty if the proxy is unavailable. The frontend appends "/relay-http".
RemoteHTTPProxyURL string `json:"remote_http_proxy_url"`
Paused bool `json:"paused"`
// HomeInstanceURL is the public Relay node selected for this user. The
// frontend uses it only for direct signaling; terminal /client traffic
// continues through RemoteProxyURL in the Wails build.
HomeInstanceURL string `json:"home_instance_url"`
Paused bool `json:"paused"`
}

type LoggingConfig struct {
Expand Down Expand Up @@ -368,6 +372,11 @@ type App struct {
accountKeyMu sync.Mutex
accountKey []byte

// nativeDirect owns Wails-side Pion client attempts. The browser transport
// remains in use on Web/Capacitor; desktop attempts never depend on WebKit.
nativeDirectMu sync.Mutex
nativeDirect map[string]*nativeDirectClient

startupFatalMu sync.RWMutex
startupFatal StartupError

Expand Down Expand Up @@ -592,6 +601,7 @@ func (a *App) startup(ctx context.Context) {
// shutdown is called when the window is closed; clean up PTYs and HTTP server.
func (a *App) shutdown(ctx context.Context) {
a.servicePreviews.stopAll()
a.stopNativeDirectClients()
a.mu.Lock()
if a.uplinkCancel != nil {
a.uplinkCancel()
Expand Down Expand Up @@ -645,6 +655,7 @@ func (a *App) shutdown(ctx context.Context) {
// the uplink and the Feishu integration mode. Caller need not hold a.mu.
func (a *App) applyRelayConfig(cfg appConfig) {
a.servicePreviews.stopAll()
a.stopNativeDirectClients()
a.applyRelayUplink(cfg)
a.applyRelayPrefsWatch(cfg)
// Feishu mode follows the relay login state: relay when logged in, local
Expand Down Expand Up @@ -696,6 +707,7 @@ func (a *App) applyRelayUplink(cfg appConfig) {
a.uplinkCancel = cancel
dialURL := uplinkDialURL(cfg.RelayHomeInstanceURL, cfg.RelayURL)
a.uplink = newUplink(dialURL, cfg.RelaySessionToken, cfg.RemotePermissionOrDefault(), a.host, a.recordRelayError, a.agentSealAccountKey, cfg.AllowInsecureRelay)
a.uplink.directEnabled = cfg.DirectP2PEnabled || envEnabled("ATTERM_DIRECT_P2P")
go a.uplink.Run(uplinkCtx)
logInfo("uplink", "configured for %s", dialURL)
}
Expand Down Expand Up @@ -783,6 +795,7 @@ func (a *App) GetRelayConfig() RelayConfig {
Paused: cfg.RelayPaused,
RemoteProxyURL: a.remoteProxy.wsURL(),
RemoteHTTPProxyURL: a.remoteProxy.httpURL(),
HomeInstanceURL: cfg.RelayHomeInstanceURL,
}
}

Expand Down Expand Up @@ -1759,6 +1772,35 @@ func (a *App) SetWebglRendererEnabled(enabled bool) error {
return a.cfgStore.Set(cfg)
}

// GetDirectP2PEnabled reports whether this device should attempt and accept
// Relay-assisted direct terminal routes. The environment switch remains a
// deployment-level force-on override for staged builds.
func (a *App) GetDirectP2PEnabled() bool {
if envEnabled("ATTERM_DIRECT_P2P") {
return true
}
return a.cfgStore != nil && a.cfgStore.Get().DirectP2PEnabled
}

// SetDirectP2PEnabled persists the per-device beta preference. Re-applying the
// Relay config restarts the uplink so its direct host listener follows the new
// value immediately; local PTYs remain independent and keep running.
func (a *App) SetDirectP2PEnabled(enabled bool) error {
if a.cfgStore == nil {
return fmt.Errorf("config store unavailable")
}
cfg := a.cfgStore.Get()
if cfg.DirectP2PEnabled == enabled {
return nil
}
cfg.DirectP2PEnabled = enabled
if err := a.cfgStore.Set(cfg); err != nil {
return err
}
a.applyRelayConfig(cfg)
return nil
}

// GetNotificationsEnabled returns the current persisted preference.
// Defaults to true for fresh installs.
func (a *App) GetNotificationsEnabled() bool {
Expand Down
43 changes: 43 additions & 0 deletions desktop/app_direct_p2p_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package main

import "testing"

func TestDirectP2PPreferenceDefaultsOffAndControlsUplink(t *testing.T) {
a := newRelayTestApp(t)
if a.GetDirectP2PEnabled() {
t.Fatal("direct P2P must default off")
}
if err := a.cfgStore.Set(appConfig{
RelayURL: "wss://relay.example",
RelaySessionToken: "atk_direct_test",
RemotePermission: "full",
}); err != nil {
t.Fatal(err)
}
if err := a.SetDirectP2PEnabled(true); err != nil {
t.Fatalf("SetDirectP2PEnabled(true): %v", err)
}
if !a.cfgStore.Get().DirectP2PEnabled || !a.GetDirectP2PEnabled() {
t.Fatal("enabled preference was not persisted")
}
a.mu.Lock()
uplink := a.uplink
a.mu.Unlock()
if uplink == nil || !uplink.directEnabled {
t.Fatal("restarted uplink did not enable direct host transport")
}
if err := a.SetDirectP2PEnabled(false); err != nil {
t.Fatalf("SetDirectP2PEnabled(false): %v", err)
}
if a.GetDirectP2PEnabled() || a.cfgStore.Get().DirectP2PEnabled {
t.Fatal("disabled preference was not persisted")
}
}

func TestDirectP2PEnvironmentOverrideForcesOn(t *testing.T) {
t.Setenv("ATTERM_DIRECT_P2P", "1")
a := newRelayTestApp(t)
if !a.GetDirectP2PEnabled() {
t.Fatal("ATTERM_DIRECT_P2P must force the rollout preference on")
}
}
22 changes: 22 additions & 0 deletions desktop/app_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,28 @@ func TestLoginRemoteRelay_WrongPasswordReturnsError(t *testing.T) {
}
}

func TestRemoteRelayAuthRejectsPublicCleartextBeforeDial(t *testing.T) {
app := &App{ctx: context.Background()}
for _, tc := range []struct {
name string
call func() error
}{
{name: "login", call: func() error {
return app.LoginRemoteRelay("http://relay.example.com", "[email protected]", "password", false)
}},
{name: "register", call: func() error {
return app.RegisterRemoteRelay("http://relay.example.com", "[email protected]", "password", "", false)
}},
} {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil || !strings.Contains(err.Error(), "insecure relay url") {
t.Fatalf("expected insecure relay rejection before dialing; got %v", err)
}
})
}
}

// TestRegisterRemoteRelay_PersistsAndUnlocksKey covers the happy register
// path: URL + email + token persisted, account_key unlocked into memory.
func TestRegisterRemoteRelay_PersistsAndUnlocksKey(t *testing.T) {
Expand Down
8 changes: 8 additions & 0 deletions desktop/app_probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,11 @@ func TestProbeRelayVersion_EmptyURL(t *testing.T) {
t.Fatalf("expected empty-url error; got %v", err)
}
}

func TestProbeRelayVersion_RejectsPublicCleartextWithoutOptIn(t *testing.T) {
app := &App{ctx: context.Background()}
err := app.ProbeRelayVersion("http://relay.example.com", false)
if err == nil || !strings.Contains(err.Error(), "insecure relay url") {
t.Fatalf("expected insecure relay rejection before dialing; got %v", err)
}
}
11 changes: 10 additions & 1 deletion desktop/app_relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ func (a *App) LoginRemoteRelay(relayURL, email, password string, allowInsecure b
if err != nil {
return err
}
if err := validateRelayEndpoint(wsURL, allowInsecure); err != nil {
return err
}
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
Expand Down Expand Up @@ -120,6 +123,9 @@ func (a *App) RegisterRemoteRelay(relayURL, email, password, claimToken string,
if err != nil {
return err
}
if err := validateRelayEndpoint(wsURL, allowInsecure); err != nil {
return err
}
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
Expand Down Expand Up @@ -224,10 +230,13 @@ func (a *App) ProbeRelayVersion(relayURL string, allowInsecure bool) error {
if relayURL == "" {
return fmt.Errorf("relay url is empty")
}
httpURL, _, err := relayLoginEndpoints(relayURL)
httpURL, wsURL, err := relayLoginEndpoints(relayURL)
if err != nil {
return fmt.Errorf("invalid relay url: %w", err)
}
if err := validateRelayEndpoint(wsURL, allowInsecure); err != nil {
return err
}
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
Expand Down
14 changes: 14 additions & 0 deletions desktop/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ func newRelayTestApp(t *testing.T) *App {
return a
}

func TestGetRelayConfigExposesDirectSignalHomeInstance(t *testing.T) {
a := newRelayTestApp(t)
if err := a.cfgStore.Set(appConfig{
RelayURL: "wss://relay.example",
RelaySessionToken: "atk_test",
RelayHomeInstanceURL: "https://home.example.com",
}); err != nil {
t.Fatal(err)
}
if got := a.GetRelayConfig().HomeInstanceURL; got != "https://home.example.com" {
t.Fatalf("HomeInstanceURL = %q; want https://home.example.com", got)
}
}

// TestSetUplinkPaused_TogglesWithoutWipingConfig verifies the
// "disconnect erases config" fix: pausing/unpausing via SetUplinkPaused
// must not touch the persisted URL/token, only the pause flag.
Expand Down
5 changes: 5 additions & 0 deletions desktop/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ type appConfig struct {
// on macOS/Windows so the #33 light-theme ghosting fix stays active.
WebglRendererEnabled *bool `json:"webgl_renderer_enabled,omitempty"`

// DirectP2PEnabled opts this device into the beta Relay-assisted direct
// terminal route. It gates both client upgrade attempts and this desktop's
// direct host listener; false keeps the established Relay-only behavior.
DirectP2PEnabled bool `json:"direct_p2p_enabled,omitempty"`

// Plugins is the plugin-system block. Defaults filled in on first run.
Plugins PluginConfig `json:"plugins"`

Expand Down
Loading
Loading