diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 680deb2..4f03511 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -264,11 +264,23 @@ jobs: run: | VERSION="${{ steps.get_version.outputs.version }}" COMMIT="$(git rev-parse --short HEAD)" + LDFLAGS="-s -w -X github.com/cnjack/jcode/internal/command.Version=${VERSION} -X github.com/cnjack/jcode/internal/command.GitCommit=${COMMIT}" mkdir -p desktop/src-tauri/binaries - go build -trimpath \ - -ldflags "-s -w -X github.com/cnjack/jcode/internal/command.Version=${VERSION} -X github.com/cnjack/jcode/internal/command.GitCommit=${COMMIT}" \ + # Main sidecar. The `desktop` tag compiles in desktop-only features (the + # BLE settings toggle); `jcode_headless` omits the embedded SPA. It still + # never links CoreBluetooth — BLE runs in the helper below. + go build -trimpath -tags "jcode_headless desktop" \ + -ldflags "${LDFLAGS}" \ -o "desktop/src-tauri/binaries/jcode-${{ matrix.triple }}${{ matrix.ext }}" \ ./cmd/jcode/ + # BLE helper (externalBin). Built with -tags ble; the matrix cgo flag is + # 1 on macOS (CoreBluetooth needs cgo) and 0 elsewhere (Linux D-Bus / + # Windows WinRT are pure Go). The main app spawns it only when BLE is + # enabled in config, so the bundle never prompts for Bluetooth at launch. + go build -trimpath -tags ble \ + -ldflags "${LDFLAGS}" \ + -o "desktop/src-tauri/binaries/jcode-ble-${{ matrix.triple }}${{ matrix.ext }}" \ + ./cmd/jcode-ble/ # macOS signing + notarization. Everything here is optional: with no secrets # the build still succeeds and produces an UNSIGNED bundle (Gatekeeper warns diff --git a/Makefile b/Makefile index 97f2382..51493ec 100644 --- a/Makefile +++ b/Makefile @@ -40,12 +40,28 @@ build-web: generate cd web && (pnpm install --frozen-lockfile 2>/dev/null || pnpm install) cd web && npx vite build +# The main binary never links CoreBluetooth (whose eager init triggers the macOS +# Bluetooth permission prompt at startup). BLE runs in a separate `jcode-ble` +# helper the main binary spawns only when BLE is enabled in config — so BLE is a +# pure runtime toggle with zero prompt when off. Build the helper once with +# `make build-ble`; no recompile is needed to flip it on/off after that. build: generate build-web go build -ldflags "$(LDFLAGS)" -o $(BIN) $(PKG) build-binary: go build -ldflags "$(LDFLAGS)" -o $(BIN) $(PKG) +# cgo is REQUIRED for BLE on macOS (CoreBluetooth via cbgo). Without it, `-tags +# ble` on darwin silently falls back to the spawner stub — a helper that would +# spawn itself. Linux (D-Bus) / Windows (WinRT) BLE is pure Go, so cgo off is +# fine (and avoids needing a C toolchain). So: 1 on darwin, 0 elsewhere. +BLE_CGO := $(if $(filter darwin,$(shell go env GOOS)),1,0) + +# Build the jcode-ble helper next to the main binary to enable BLE at runtime. +# After this, toggle BLE via config — no rebuild needed. +build-ble: + CGO_ENABLED=$(BLE_CGO) go build -tags ble -ldflags "$(LDFLAGS)" -o $(dir $(BIN))jcode-ble ./cmd/jcode-ble + install: generate build-web go install -ldflags "$(LDFLAGS)" $(PKG) @@ -89,7 +105,9 @@ desktop-icons: desktop-sidecar: generate @echo "Building jcode sidecar for $(RUST_TARGET)..." @mkdir -p $(SIDECAR_DIR) - go build -tags jcode_headless -ldflags "$(LDFLAGS)" -o $(SIDECAR_DIR)/jcode-$(RUST_TARGET)$(SIDECAR_EXE) $(PKG) + go build -tags "jcode_headless desktop" -ldflags "$(LDFLAGS)" -o $(SIDECAR_DIR)/jcode-$(RUST_TARGET)$(SIDECAR_EXE) $(PKG) + @echo "Building jcode-ble helper for $(RUST_TARGET)..." + CGO_ENABLED=$(BLE_CGO) go build -tags ble -ldflags "$(LDFLAGS)" -o $(SIDECAR_DIR)/jcode-ble-$(RUST_TARGET)$(SIDECAR_EXE) ./cmd/jcode-ble # Run the desktop app in development (hot window; rebuilds the sidecar first). desktop-dev: desktop-sidecar diff --git a/cmd/jcode-ble/main_ble.go b/cmd/jcode-ble/main_ble.go new file mode 100644 index 0000000..4b4943c --- /dev/null +++ b/cmd/jcode-ble/main_ble.go @@ -0,0 +1,54 @@ +//go:build ble + +// Command jcode-ble is the out-of-process BLE worker. The main jcode binary +// never links CoreBluetooth (so it never triggers the macOS Bluetooth prompt); +// when BLE is enabled in config it spawns this helper, which is the only process +// that touches Bluetooth. Communication is line-delimited JSON over stdio: +// +// stdin : {"type":,"tool":"...","err":"..."} (NotifyEvent) +// stdout: {"cmd":"...","val":"..."} (inbound device cmd) +package main + +import ( + "bufio" + "encoding/json" + "errors" + "os" + + "github.com/cnjack/jcode/internal/channel" + "github.com/cnjack/jcode/internal/channel/ble" +) + +type wireEvent struct { + Type int `json:"type"` + Tool string `json:"tool,omitempty"` + Err string `json:"err,omitempty"` +} + +func main() { + n := ble.New() // real BLE (tinygo / CoreBluetooth) + defer n.Close() + + // Forward inbound BLE device commands to stdout. + go func() { + for rc := range n.Receive() { + b, _ := json.Marshal(map[string]string{"cmd": rc.Cmd, "val": rc.Val}) + _, _ = os.Stdout.Write(append(b, '\n')) + } + }() + + // Relay NotifyEvents from stdin into BLE sends. + sc := bufio.NewScanner(os.Stdin) + sc.Buffer(make([]byte, 4096), 1<<16) + for sc.Scan() { + var we wireEvent + if json.Unmarshal(sc.Bytes(), &we) != nil { + continue + } + ev := channel.NotifyEvent{Type: channel.EventType(we.Type), Tool: we.Tool} + if we.Err != "" { + ev.Err = errors.New(we.Err) + } + n.Notify(ev) + } +} diff --git a/cmd/jcode-ble/main_stub.go b/cmd/jcode-ble/main_stub.go new file mode 100644 index 0000000..6b3708b --- /dev/null +++ b/cmd/jcode-ble/main_stub.go @@ -0,0 +1,16 @@ +//go:build !ble + +package main + +import ( + "fmt" + "os" +) + +// Without the `ble` tag there is no CoreBluetooth support to run. Building this +// stub (instead of nothing) keeps `go build ./...` green; the real worker is +// produced by `make build-ble`. +func main() { + fmt.Fprintln(os.Stderr, "jcode-ble must be built with -tags ble (run: make build-ble)") + os.Exit(1) +} diff --git a/desktop/src-tauri/Entitlements.plist b/desktop/src-tauri/Entitlements.plist new file mode 100644 index 0000000..b0061a1 --- /dev/null +++ b/desktop/src-tauri/Entitlements.plist @@ -0,0 +1,19 @@ + + + + + + com.apple.security.device.bluetooth + + + diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs index 613543a..098dc9d 100644 --- a/desktop/src-tauri/src/sidecar.rs +++ b/desktop/src-tauri/src/sidecar.rs @@ -49,6 +49,38 @@ fn pick_free_port() -> u16 { .unwrap_or(8799) } +/// Where the last-used sidecar port is remembered so we can reuse it next +/// launch. Reusing the port keeps the browser extension's stored server URL +/// valid across restarts, so it reconnects silently without re-discovering. +fn port_file(app: &AppHandle) -> PathBuf { + let dir = app + .path() + .app_config_dir() + .unwrap_or_else(|_| std::env::temp_dir()); + let _ = std::fs::create_dir_all(&dir); + dir.join("sidecar-port") +} + +/// True if `port` can currently be bound on loopback (i.e. it's free). +fn is_port_free(port: u16) -> bool { + TcpListener::bind(("127.0.0.1", port)).is_ok() +} + +/// Pick the sidecar port, preferring the port used last time (persisted) so the +/// URL stays stable across launches. Falls back to a fresh free port when the +/// remembered one is taken (another instance, or grabbed by something else). +/// The chosen port is persisted for next time. +fn pick_port(app: &AppHandle) -> u16 { + let path = port_file(app); + let port = std::fs::read_to_string(&path) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .filter(|&p| p != 0 && is_port_free(p)) + .unwrap_or_else(pick_free_port); + let _ = std::fs::write(&path, port.to_string()); + port +} + /// Path to the persisted sidecar log. Lives in the app log dir so a crash that /// happens before the window ever loads is still inspectable after the fact — /// the GUI swallows the sidecar's stdout/stderr otherwise. @@ -62,7 +94,7 @@ fn sidecar_log_path(app: &AppHandle) -> PathBuf { } pub fn start(app: &AppHandle) -> Result<(), Box> { - let port = pick_free_port(); + let port = pick_port(app); // Publish the port to managed state immediately so a fast-rendering // frontend's `get_sidecar_port` IPC call can resolve it without waiting for diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 405d863..cb80275 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -41,7 +41,10 @@ "icons/icon.icns", "icons/icon.ico" ], - "externalBin": ["binaries/jcode"], + "externalBin": ["binaries/jcode", "binaries/jcode-ble"], + "macOS": { + "entitlements": "Entitlements.plist" + }, "category": "DeveloperTool", "shortDescription": "jcode desktop", "longDescription": "Native desktop shell for jcode — the embedded Go backend runs as a sidecar and Tauri renders the web UI with native system integration.", diff --git a/internal/browser/bridge_test.go b/internal/browser/bridge_test.go index ef2cfb3..b19fe06 100644 --- a/internal/browser/bridge_test.go +++ b/internal/browser/bridge_test.go @@ -144,6 +144,28 @@ func TestBridgeCDPForwarding(t *testing.T) { } } +func TestBridgeStableTokenIsStable(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + b := NewBridge() + + tok1 := b.StableToken() + if tok1 == "" { + t.Fatal("expected a token") + } + // Same process: identical. + if got := b.StableToken(); got != tok1 { + t.Fatalf("stable token changed within process: %q vs %q", got, tok1) + } + // New bridge (simulates a restart): the persisted token is reused and valid. + b2 := NewBridge() + if got := b2.StableToken(); got != tok1 { + t.Fatalf("stable token not reused across restart: %q vs %q", got, tok1) + } + if !b2.validToken(tok1) { + t.Fatal("reused stable token should authenticate") + } +} + func TestBridgeOfflineBackendErrors(t *testing.T) { b := NewBridge() b.tokenPath = t.TempDir() + "/tokens.json" diff --git a/internal/browser/tokens.go b/internal/browser/tokens.go index 956fc89..f4b572a 100644 --- a/internal/browser/tokens.go +++ b/internal/browser/tokens.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "github.com/cnjack/jcode/internal/config" ) @@ -15,6 +16,34 @@ func (b *Bridge) tokenFile() string { return filepath.Join(config.ConfigDir(), "browser", "ext-tokens.json") } +func (b *Bridge) stableTokenFile() string { + return filepath.Join(config.ConfigDir(), "browser", "server-token") +} + +// StableToken returns one long-lived token, reused across restarts — the "key" +// the extension stores once and re-presents forever. Combined with a stable +// server port, the extension reconnects silently with no re-auth. Persisted to +// ~/.jcode/browser/server-token (0600) and kept in the valid-token set. +// Preferred over IssueToken for the native/auto-connect path so tokens don't +// accumulate a fresh entry on every launch. +func (b *Bridge) StableToken() string { + b.mu.Lock() + defer b.mu.Unlock() + if data, err := os.ReadFile(b.stableTokenFile()); err == nil { + if tok := strings.TrimSpace(string(data)); tok != "" { + b.tokens[tok] = true + return tok + } + } + tok := randomToken() + b.tokens[tok] = true + b.saveTokensLocked() + path := b.stableTokenFile() + _ = os.MkdirAll(filepath.Dir(path), 0o755) + _ = os.WriteFile(path, []byte(tok), 0o600) + return tok +} + func (b *Bridge) loadTokens() { data, err := os.ReadFile(b.tokenFile()) if err != nil { diff --git a/internal/channel/ble/ble_cgo.go b/internal/channel/ble/ble_cgo.go index 94afd98..73454f7 100644 --- a/internal/channel/ble/ble_cgo.go +++ b/internal/channel/ble/ble_cgo.go @@ -1,4 +1,13 @@ -//go:build !darwin || cgo +//go:build ble && (!darwin || cgo) + +// Real BLE support is OPT-IN via the `ble` build tag. This matters on macOS: +// tinygo.org/x/bluetooth's DefaultAdapter eagerly creates a CBCentralManager at +// package-init time, and touching CoreBluetooth triggers the macOS Bluetooth +// permission prompt (and a TCC SIGABRT for Finder-launched bundles) — BEFORE any +// config is read, so the runtime BLEEnabled flag cannot prevent it. Gating the +// import behind `ble` keeps default builds (CLI + desktop sidecar) from linking +// CoreBluetooth at all, so Bluetooth is never touched unless explicitly built +// with `go build -tags ble` (requires cgo on macOS). // Package ble provides a channel.Notifier that sends short status messages // to a JCODE-* BLE IoT device using the Nordic UART Service (NUS). diff --git a/internal/channel/ble/ble_nocgo.go b/internal/channel/ble/ble_nocgo.go index b8cd0cc..066e6de 100644 --- a/internal/channel/ble/ble_nocgo.go +++ b/internal/channel/ble/ble_nocgo.go @@ -1,21 +1,186 @@ -//go:build darwin && !cgo +//go:build !ble || (darwin && !cgo) package ble -import "github.com/cnjack/jcode/internal/channel" +import ( + "bufio" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" -// Notifier is a no-op stub for macOS without CGo (CoreBluetooth requires CGo). -type Notifier struct{} + "github.com/cnjack/jcode/internal/channel" + "github.com/cnjack/jcode/internal/config" +) + +// This is the DEFAULT build's BLE notifier. It links NOTHING that touches +// CoreBluetooth — instead it spawns the separate `jcode-ble` helper binary +// (built with `-tags ble`) and relays events to it over stdio. That is what +// lets BLE be a pure runtime config toggle: the main binary never instantiates +// a CBCentralManager, so it never triggers the macOS Bluetooth permission +// prompt at startup, no matter the config. The helper — spawned only when BLE +// is enabled — is the only process that touches Bluetooth, so the prompt (if +// any) appears exactly when the user turns BLE on. +// +// If the jcode-ble helper isn't present next to the main binary, BLE is simply +// unavailable (no-op). Build it once with `make build-ble`. // ReceivedCommand is a parsed command received from the BLE device. type ReceivedCommand struct { - Cmd string - Val string + Cmd string `json:"cmd"` + Val string `json:"val"` +} + +// wireEvent is the JSON line sent to the helper's stdin per NotifyEvent. +type wireEvent struct { + Type int `json:"type"` + Tool string `json:"tool,omitempty"` + Err string `json:"err,omitempty"` +} + +// Notifier relays notification events to the jcode-ble helper process. +type Notifier struct { + mu sync.Mutex + cmd *exec.Cmd + stdin io.WriteCloser + inbound chan ReceivedCommand + started bool + dead bool +} + +// New spawns the jcode-ble helper (if present) and returns a notifier. It is +// only constructed when BLE is enabled in config, so the helper — and any +// Bluetooth prompt — only appears then. +func New() *Notifier { + n := &Notifier{inbound: make(chan ReceivedCommand, 16)} + n.start() + return n +} + +func helperPath() string { + // Explicit override wins (e.g. set by the desktop shell). + if p := os.Getenv("JCODE_BLE_HELPER"); p != "" { + if _, err := os.Stat(p); err == nil { + return p + } + } + exe, err := os.Executable() + if err != nil { + return "" + } + dir := filepath.Dir(exe) + exeSuffix := "" + if runtime.GOOS == "windows" { + exeSuffix = ".exe" + } + // Exact name first (production bundle co-locates jcode + jcode-ble). + if p := filepath.Join(dir, "jcode-ble"+exeSuffix); statExecutable(p) { + return p + } + // Tauri dev mode keeps the target-triple suffix (jcode-ble-); match it. + if matches, _ := filepath.Glob(filepath.Join(dir, "jcode-ble-*"+exeSuffix)); len(matches) > 0 { + for _, m := range matches { + if statExecutable(m) { + return m + } + } + } + return "" +} + +func statExecutable(p string) bool { + fi, err := os.Stat(p) + return err == nil && !fi.IsDir() } -func New() *Notifier { return &Notifier{} } -func (n *Notifier) Name() string { return "ble" } -func (n *Notifier) Available() bool { return false } -func (n *Notifier) Notify(_ channel.NotifyEvent) {} -func (n *Notifier) Close() {} -func (n *Notifier) Receive() <-chan ReceivedCommand { return nil } +func (n *Notifier) start() { + hp := helperPath() + if hp == "" { + config.Logger().Printf("[ble] enabled but the jcode-ble helper is not installed (build it with `make build-ble`); BLE disabled") + n.dead = true + return + } + cmd := exec.Command(hp) + stdin, err := cmd.StdinPipe() + if err != nil { + n.dead = true + return + } + stdout, err := cmd.StdoutPipe() + if err != nil { + n.dead = true + return + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + config.Logger().Printf("[ble] failed to start helper: %v", err) + n.dead = true + return + } + n.cmd = cmd + n.stdin = stdin + n.started = true + config.Logger().Printf("[ble] helper started: %s", hp) + go n.readLoop(stdout) +} + +func (n *Notifier) readLoop(r io.Reader) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 4096), 1<<16) + for sc.Scan() { + var rc ReceivedCommand + if json.Unmarshal(sc.Bytes(), &rc) == nil && rc.Cmd != "" { + select { + case n.inbound <- rc: + default: + } + } + } + n.mu.Lock() + n.dead = true + n.mu.Unlock() +} + +func (n *Notifier) Name() string { return "ble" } + +func (n *Notifier) Available() bool { + n.mu.Lock() + defer n.mu.Unlock() + return n.started && !n.dead +} + +func (n *Notifier) Notify(event channel.NotifyEvent) { + n.mu.Lock() + defer n.mu.Unlock() + if !n.started || n.dead || n.stdin == nil { + return + } + we := wireEvent{Type: int(event.Type), Tool: event.Tool} + if event.Err != nil { + we.Err = event.Err.Error() + } + b, _ := json.Marshal(we) + if _, err := n.stdin.Write(append(b, '\n')); err != nil { + n.dead = true + } +} + +func (n *Notifier) Receive() <-chan ReceivedCommand { return n.inbound } + +func (n *Notifier) Close() { + n.mu.Lock() + defer n.mu.Unlock() + if n.dead && n.cmd == nil { + return + } + if n.stdin != nil { + _ = n.stdin.Close() + } + if n.cmd != nil && n.cmd.Process != nil { + _ = n.cmd.Process.Kill() + } + n.dead = true +} diff --git a/internal/channel/ble/ble_proxy.go b/internal/channel/ble/ble_proxy.go new file mode 100644 index 0000000..b859296 --- /dev/null +++ b/internal/channel/ble/ble_proxy.go @@ -0,0 +1,76 @@ +package ble + +import ( + "sync" + + "github.com/cnjack/jcode/internal/channel" +) + +// Proxy is a channel.Notifier that forwards to a live BLE notifier which can be +// swapped in/out at runtime. It is added once to each task's notifier chain, so +// enabling/disabling BLE takes effect immediately across all active tasks +// without an app restart. When no inner notifier is set, it is a no-op. +// +// Notifier's concrete type differs per build (real BLE vs. helper-spawner), but +// both expose the same New()/*Notifier surface, so this file is build-tag free. +type Proxy struct { + mu sync.Mutex + inner *Notifier +} + +// Name implements channel.Notifier. +func (p *Proxy) Name() string { return "ble" } + +// Available reports whether a live notifier is present and ready. +func (p *Proxy) Available() bool { + p.mu.Lock() + n := p.inner + p.mu.Unlock() + return n != nil && n.Available() +} + +// Notify forwards to the current inner notifier (no-op when disabled). +func (p *Proxy) Notify(event channel.NotifyEvent) { + p.mu.Lock() + n := p.inner + p.mu.Unlock() + if n != nil { + n.Notify(event) + } +} + +// Close tears down the inner notifier. +func (p *Proxy) Close() { p.Disable() } + +// Active reports whether BLE is currently live. +func (p *Proxy) Active() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.inner != nil +} + +// Enable spawns a fresh BLE notifier (the helper) if not already running and +// pushes an initial idle event so the device connects (and macOS prompts, if it +// is going to) right away. +func (p *Proxy) Enable() { + p.mu.Lock() + if p.inner != nil { + p.mu.Unlock() + return + } + n := New() + p.inner = n + p.mu.Unlock() + n.Notify(channel.NotifyEvent{Type: channel.EventIdle}) +} + +// Disable stops and forgets the inner notifier. +func (p *Proxy) Disable() { + p.mu.Lock() + n := p.inner + p.inner = nil + p.mu.Unlock() + if n != nil { + n.Close() + } +} diff --git a/internal/channel/ble/ble_proxy_test.go b/internal/channel/ble/ble_proxy_test.go new file mode 100644 index 0000000..597a71f --- /dev/null +++ b/internal/channel/ble/ble_proxy_test.go @@ -0,0 +1,45 @@ +package ble + +import ( + "testing" + + "github.com/cnjack/jcode/internal/channel" +) + +func TestProxyStateMachine(t *testing.T) { + var p Proxy + + // Disabled: inert. + if p.Active() { + t.Fatal("new proxy should be inactive") + } + if p.Available() { + t.Fatal("inactive proxy should not be available") + } + p.Notify(channel.NotifyEvent{Type: channel.EventIdle}) // must not panic + + // Enable: becomes active (the helper is absent in tests, so it won't actually + // connect, but the proxy holds an inner notifier). + p.Enable() + if !p.Active() { + t.Fatal("proxy should be active after Enable") + } + // Idempotent enable. + p.Enable() + if !p.Active() { + t.Fatal("double Enable should keep it active") + } + + // Disable: back to inert. + p.Disable() + if p.Active() { + t.Fatal("proxy should be inactive after Disable") + } + // Idempotent disable + notify after disable is a no-op. + p.Disable() + p.Notify(channel.NotifyEvent{Type: channel.EventWorking}) + + if p.Name() != "ble" { + t.Errorf("Name = %q, want ble", p.Name()) + } +} diff --git a/internal/command/interactive.go b/internal/command/interactive.go index 8cd9cb3..274a615 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -22,7 +22,6 @@ import ( "github.com/cnjack/jcode/internal/agent" "github.com/cnjack/jcode/internal/browser" "github.com/cnjack/jcode/internal/channel" - "github.com/cnjack/jcode/internal/channel/ble" "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/handler" "github.com/cnjack/jcode/internal/mode" @@ -1081,7 +1080,35 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { approvalState.SetBrowserOriginFunc(env.CurrentBrowserOrigin) st.approvalState = approvalState - p, _ := tui.RunTUI(hasPrompt, pwd, env.TodoStore, tui.WithVersion(Version), tui.WithGoalStore(env.GoalStore), tui.WithStartupMode(startupMode), tui.WithTheme(cfg.Theme), tui.WithApprovalModeChange(func(enabled bool) { + // Wire the `/browser` command to the browser-use subsystem. + browserCtl := &tui.BrowserController{ + Status: func() tui.BrowserStatus { + s := browserMgr.Status(context.Background()) + info := s.ChromeVersion + if info == "" { + info = s.ChromePath + } + return tui.BrowserStatus{ + Available: true, + Enabled: s.Enabled, + Backend: s.Backend, + ChromeFound: s.ChromeFound, + ChromeInfo: info, + ExtensionOnline: s.ExtensionOnline, + DevMode: s.DevMode, + } + }, + SetEnabled: func(enable bool) error { + if cfg.Browser == nil { + cfg.Browser = &config.BrowserConfig{Backend: "auto"} + } + cfg.Browser.Enabled = enable + browserMgr.SetConfig(browserManagerConfig(cfg)) + return config.SaveConfig(cfg) + }, + } + + p, _ := tui.RunTUI(hasPrompt, pwd, env.TodoStore, tui.WithVersion(Version), tui.WithGoalStore(env.GoalStore), tui.WithStartupMode(startupMode), tui.WithTheme(cfg.Theme), tui.WithBrowser(browserCtl), tui.WithApprovalModeChange(func(enabled bool) { approvalState.SetSessionApproval(enabled) })) st.p = p @@ -1112,22 +1139,8 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { // Register WeChat as a notifier for working/idle status pushes. notifyingH.AddNotifier(channel.NewChannelNotifier(st.wechatClient)) - // Register BLE notifier if enabled (lazy connect — will auto-discover JCODE-* devices). - if cfg.Channel != nil && cfg.Channel.BLEEnabled { - bleNotifier := ble.New() - notifyingH.AddNotifier(bleNotifier) - // Push initial idle status (triggers BLE discovery in background). - bleNotifier.Notify(channel.NotifyEvent{Type: channel.EventIdle}) - - // Forward BLE inbound commands to TUI. - if bleCh := bleNotifier.Receive(); bleCh != nil { - go func() { - for cmd := range bleCh { - p.Send(tui.BLECommandMsg{Cmd: cmd.Cmd, Val: cmd.Val}) - } - }() - } - } + // BLE status pushes are a desktop-only feature (the desktop app bundles the + // jcode-ble helper). The terminal/CLI does not spawn BLE. st.h = notifyingH approvalState.SetHandler(notifyingH) diff --git a/internal/command/web.go b/internal/command/web.go index abcff18..6f263c1 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -28,6 +28,7 @@ import ( "github.com/cnjack/jcode/internal/channel" "github.com/cnjack/jcode/internal/channel/ble" "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/feature" "github.com/cnjack/jcode/internal/handler" "github.com/cnjack/jcode/internal/mode" internalmodel "github.com/cnjack/jcode/internal/model" @@ -282,9 +283,13 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err config.Logger().Printf("[wechat] web auto-enabled") } } - var sharedBLE *ble.Notifier - if cfg.Channel != nil && cfg.Channel.BLEEnabled { - sharedBLE = ble.New() + // BLE is a desktop-only feature (compiled out of plain `jcode web`). The proxy + // is added to every task's notifier chain; enabling/disabling it via the + // settings toggle takes effect live (no restart). It stays a no-op until + // Enable() is called — at startup here if configured, and on the toggle. + bleProxy := &ble.Proxy{} + if feature.BLE && cfg.Channel != nil && cfg.Channel.BLEEnabled { + bleProxy.Enable() } // makeNotifyingHandler wraps a fresh per-task WebHandler with the shared push @@ -307,8 +312,8 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err } }) nh.AddNotifier(channel.NewChannelNotifier(wechatClient)) - if sharedBLE != nil { - nh.AddNotifier(sharedBLE) + if feature.BLE { + nh.AddNotifier(bleProxy) } return nh } @@ -736,6 +741,7 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err AuthToken: webToken, RequireAuth: requireAuth, BrowserManager: browserMgr, + BLEController: bleProxy, }) // Start the periodic automation scheduler. A single process owns periodic diff --git a/internal/feature/feature.go b/internal/feature/feature.go new file mode 100644 index 0000000..81d1299 --- /dev/null +++ b/internal/feature/feature.go @@ -0,0 +1,9 @@ +// Package feature holds compile-time feature flags gated by build tags, so a +// build can omit whole capabilities instead of only hiding them at runtime. +// +// BLE (the Bluetooth status channel) is desktop-only: the desktop app bundles +// the jcode-ble helper and builds with `-tags desktop`. Plain `jcode web` (the +// browser server) is built without it, so BLE is compiled off — its notifier is +// never spawned, its API endpoints report unavailable, and the settings UI hides +// the toggle (the server reports the capability as false). +package feature diff --git a/internal/feature/feature_default.go b/internal/feature/feature_default.go new file mode 100644 index 0000000..6c217f8 --- /dev/null +++ b/internal/feature/feature_default.go @@ -0,0 +1,7 @@ +//go:build !desktop + +package feature + +// BLE is compiled OFF for non-desktop builds (plain `jcode web`, CLI). The +// browser web server has no need for a Bluetooth status channel. +const BLE = false diff --git a/internal/feature/feature_desktop.go b/internal/feature/feature_desktop.go new file mode 100644 index 0000000..453ba1d --- /dev/null +++ b/internal/feature/feature_desktop.go @@ -0,0 +1,6 @@ +//go:build desktop + +package feature + +// BLE is compiled ON for desktop builds (`-tags desktop`). +const BLE = true diff --git a/internal/tui/browser_command.go b/internal/tui/browser_command.go new file mode 100644 index 0000000..1eed191 --- /dev/null +++ b/internal/tui/browser_command.go @@ -0,0 +1,84 @@ +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" +) + +// handleBrowserInput implements the `/browser` slash command: show browser-use +// status, and `/browser on` / `/browser off` to toggle it. +func (m *Model) handleBrowserInput(prompt string, cmds []tea.Cmd) (tea.Model, tea.Cmd) { + m.textarea.SetValue("") + fields := strings.Fields(prompt) + + if m.browser == nil || m.browser.Status == nil { + m.lines = append(m.lines, textLine(" Browser use is not available in this session.")) + m.refreshViewport() + return m, tea.Batch(cmds...) + } + + // /browser on | off + if len(fields) >= 2 { + switch fields[1] { + case "on", "off": + enable := fields[1] == "on" + if m.browser.SetEnabled == nil { + m.lines = append(m.lines, textLine(" Cannot change browser setting here.")) + m.refreshViewport() + return m, tea.Batch(cmds...) + } + if err := m.browser.SetEnabled(enable); err != nil { + m.lines = append(m.lines, textLine(" "+toolLabelStyle.Render("🌐 Browser:")+" failed: "+err.Error())) + } else { + state := "disabled" + if enable { + state = "enabled" + } + m.lines = append(m.lines, textLine(" "+toolLabelStyle.Render("🌐 Browser:")+" "+state+".")) + } + m.refreshViewport() + return m, tea.Batch(cmds...) + default: + m.lines = append(m.lines, textLine(" Usage: /browser [on|off]")) + m.refreshViewport() + return m, tea.Batch(cmds...) + } + } + + // /browser — status. + st := m.browser.Status() + m.lines = append(m.lines, textLine(toolLabelStyle.Render("🌐 Browser use:"))) + + yn := func(b bool, yes, no string) string { + if b { + return yes + } + return no + } + line := func(label, val string) { + m.lines = append(m.lines, textLine(fmt.Sprintf(" %s %s", toolNameStyle.Render(label), val))) + } + + line("state ", yn(st.Enabled, "enabled", "disabled (/browser on to enable)")) + backend := st.Backend + if backend == "" { + backend = "auto" + } + line("backend ", backend) + if st.ChromeFound { + info := st.ChromeInfo + if info == "" { + info = "found" + } + line("chrome ", "found · "+info) + } else { + line("chrome ", "not found (set browser.chrome_path in config)") + } + line("extension", yn(st.ExtensionOnline, "connected", "not connected (open the extension → Auto-connect)")) + line("dev mode ", yn(st.DevMode, "on (browser_eval / raw CDP allowed)", "off")) + + m.refreshViewport() + return m, tea.Batch(cmds...) +} diff --git a/internal/tui/input_views.go b/internal/tui/input_views.go index c90d3ba..c2dc74f 100644 --- a/internal/tui/input_views.go +++ b/internal/tui/input_views.go @@ -32,6 +32,7 @@ func (m Model) getAllCommands() []commandSuggestion { {"/bg", "List background tasks"}, {"/channel", "Manage channels (WeChat etc.)"}, {"/mcp", "List MCP servers / log in (/mcp login )"}, + {"/browser", "Browser use status (/browser on|off)"}, {"/help", "Show keyboard shortcuts"}, } for _, sc := range m.skillSlashCommands { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ea205bc..f273ff2 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -98,6 +98,7 @@ type Model struct { todoStore *tools.TodoStore goalStore *tools.GoalStore + browser *BrowserController totalTokens int64 modelContextLimit int @@ -434,6 +435,32 @@ func WithGoalStore(gs *tools.GoalStore) ModelOption { } } +// BrowserStatus is a snapshot of the browser-use subsystem for `/browser`. +type BrowserStatus struct { + Available bool // false when browser use is not wired in this context + Enabled bool + Backend string // auto | managed | extension + ChromeFound bool + ChromeInfo string // version or path + ExtensionOnline bool + DevMode bool +} + +// BrowserController lets the TUI read browser status and toggle enablement +// without depending on the browser manager directly (which lives in the command +// layer). Nil when browser use is unavailable. +type BrowserController struct { + Status func() BrowserStatus + SetEnabled func(bool) error +} + +// WithBrowser wires the `/browser` command to the browser-use subsystem. +func WithBrowser(bc *BrowserController) ModelOption { + return func(m *Model) { + m.browser = bc + } +} + // WithTheme applies the persisted color theme at startup. A non-empty name // marks the theme as explicit, suppressing terminal-background auto-detection. func WithTheme(name string) ModelOption { diff --git a/internal/tui/update.go b/internal/tui/update.go index ca81f9f..b13ac2f 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -851,6 +851,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen return m.handleMCPInput(prompt, cmds) } + if prompt == "/browser" || strings.HasPrefix(prompt, "/browser ") { + return m.handleBrowserInput(prompt, cmds) + } + if prompt == "/help" { m.showingHelp = true m.helpScroll = 0 diff --git a/internal/web/browser.go b/internal/web/browser.go index 81e9ac3..0a77666 100644 --- a/internal/web/browser.go +++ b/internal/web/browser.go @@ -30,7 +30,9 @@ func (s *Server) SetupNativeMessaging() { if s.browserMgr == nil || !s.browserMgr.GetConfig().Enabled { return } - token := s.browserMgr.Bridge().IssueToken() + // Reuse one long-lived token across restarts (the extension stores it once); + // with a stable port the extension reconnects silently, no re-auth. + token := s.browserMgr.Bridge().StableToken() if err := browser.WriteEndpoint(s.extWSURL(), token); err != nil { config.Logger().Printf("[browser] write endpoint failed: %v", err) } diff --git a/internal/web/channel.go b/internal/web/channel.go index 5f95123..1c474d1 100644 --- a/internal/web/channel.go +++ b/internal/web/channel.go @@ -8,6 +8,7 @@ import ( channelpkg "github.com/cnjack/jcode/internal/channel" "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/feature" ) func (s *Server) handleChannelStatus(w http.ResponseWriter, r *http.Request) { @@ -114,13 +115,17 @@ func (s *Server) handleChannelBLEStatus(w http.ResponseWriter, r *http.Request) if s.cfg != nil && s.cfg.Channel != nil { enabled = s.cfg.Channel.BLEEnabled } - writeJSON(w, http.StatusOK, map[string]any{"enabled": enabled}) + writeJSON(w, http.StatusOK, map[string]any{"enabled": enabled, "available": feature.BLE}) } // handleSetChannelBLE persists the Bluetooth (BLE) status-channel preference. // Like the proxy/cert settings, it takes effect after an app restart (the BLE // notifier is created once at startup when channel.ble_enabled is true). func (s *Server) handleSetChannelBLE(w http.ResponseWriter, r *http.Request) { + if !feature.BLE { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "bluetooth channel is not available in this build"}) + return + } var req struct { Enabled bool `json:"enabled"` } @@ -144,6 +149,17 @@ func (s *Server) handleSetChannelBLE(w http.ResponseWriter, r *http.Request) { return } s.mu.Unlock() + + // Apply live: start/stop the BLE helper now so the toggle takes effect + // without an app restart (and the macOS Bluetooth prompt / device connect + // happens right when the user turns it on). + if s.bleController != nil { + if req.Enabled { + s.bleController.Enable() + } else { + s.bleController.Disable() + } + } writeJSON(w, http.StatusOK, map[string]any{"enabled": req.Enabled}) } diff --git a/internal/web/server.go b/internal/web/server.go index c1c1f73..e04e931 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -149,6 +149,18 @@ type Server struct { // managed Chrome). Shared with per-task Envs so the settings UI and the // agent's browser_* tools drive the same Chrome. nil disables browser use. browserMgr *browser.Manager + + // bleController toggles the BLE status channel live (from the settings + // endpoint) without an app restart. nil when BLE is not compiled in. + bleController BLEController +} + +// BLEController lets the settings endpoint start/stop the BLE status channel at +// runtime. Implemented by *ble.Proxy; kept as an interface so this package does +// not depend on the ble concrete type. +type BLEController interface { + Enable() + Disable() } // ServerConfig holds the configuration for creating a new Server. @@ -187,6 +199,7 @@ type ServerConfig struct { AuthToken string // bearer token required on non-exempt requests when RequireAuth is set RequireAuth bool // enforce token auth (set when bound to a non-loopback host) BrowserManager *browser.Manager // optional: process-wide browser-use manager shared with per-task Envs + BLEController BLEController // optional: live BLE status-channel toggle (desktop builds) } // NewServer creates a new web server. @@ -252,6 +265,7 @@ func NewServer(cfg *ServerConfig) *Server { authToken: cfg.AuthToken, requireAuth: cfg.RequireAuth, browserMgr: cfg.BrowserManager, + bleController: cfg.BLEController, } // The bootstrap engine is registered (and its pump started) in Start, once // the root context exists. diff --git a/web/src/components/SettingsDialog.vue b/web/src/components/SettingsDialog.vue index 4e60542..c6ec4a0 100644 --- a/web/src/components/SettingsDialog.vue +++ b/web/src/components/SettingsDialog.vue @@ -41,7 +41,6 @@ import { CheckIcon, ChartBarIcon, } from '@heroicons/vue/24/outline' -import { isTauri } from '@/composables/useDesktop' import UsageStatsPanel from '@/components/UsageStatsPanel.vue' import ProviderIcon from '@/components/ProviderIcon.vue' import ProviderEditDialog from '@/components/ProviderEditDialog.vue' @@ -121,6 +120,9 @@ const qrCanvas = ref(null) // and applied on the next app launch. const bleEnabled = ref(false) const bleSaving = ref(false) +// Whether the connected backend was built with BLE support (`-tags desktop`). +// Web builds report false, so the toggle is hidden — a compile-time distinction. +const bleAvailable = ref(false) // Provider management state const configuredProviders = ref([]) @@ -172,12 +174,13 @@ watch(() => props.open, async (isOpen) => { channelState.value = ch.state ?? 'none' } catch { /* ignore */ } - // Bluetooth status channel is desktop-only; skip the request in the browser. - if (isTauri) { - try { - bleEnabled.value = (await api.channelBLEStatus()).enabled - } catch { /* ignore */ } - } + // Bluetooth status channel: the backend reports whether it was compiled with + // BLE support (`available`). Web builds report false → the toggle stays hidden. + try { + const ble = await api.channelBLEStatus() + bleAvailable.value = ble.available + bleEnabled.value = ble.enabled + } catch { /* ignore */ } // Load configured providers, then pre-fetch each one's model catalog so the // "Browse Models" panel (default-open on each card) shows models immediately. @@ -1044,8 +1047,9 @@ function closeAndSwitchModel() { - -
+ +
{{ t('settings.general.bleTitle') }}
diff --git a/web/src/composables/api.ts b/web/src/composables/api.ts index e86eee8..d2aad8b 100644 --- a/web/src/composables/api.ts +++ b/web/src/composables/api.ts @@ -261,7 +261,7 @@ export const api = { channelDisable: () => request<{ status: string; state: string }>('/api/channel/disable', { method: 'POST' }), channelBLEStatus: () => - request<{ enabled: boolean }>('/api/channel/ble'), + request<{ enabled: boolean; available: boolean }>('/api/channel/ble'), setChannelBLE: (enabled: boolean) => request<{ enabled: boolean }>('/api/channel/ble', { method: 'POST',