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
16 changes: 14 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions cmd/jcode-ble/main_ble.go
Original file line number Diff line number Diff line change
@@ -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":<EventType int>,"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)
}
}
16 changes: 16 additions & 0 deletions cmd/jcode-ble/main_stub.go
Original file line number Diff line number Diff line change
@@ -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)
}
19 changes: 19 additions & 0 deletions desktop/src-tauri/Entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
Signing entitlements for the notarized macOS bundle.

Bluetooth: `com.apple.security.device.bluetooth` grants Bluetooth access under
the App Sandbox. It is harmless (ignored) for a plain Developer ID build, which
is NOT sandboxed — there, CoreBluetooth is gated only by the Info.plist
NSBluetooth*UsageDescription strings + user consent (TCC). It's declared here so
a future sandboxed/App-Store distribution also works, and to make the Bluetooth
requirement explicit. The BLE work is done by the jcode-ble sidecar; on a
non-sandboxed build it inherits the bundle's Bluetooth TCC context.
-->
<plist version="1.0">
<dict>
<key>com.apple.security.device.bluetooth</key>
<true/>
</dict>
</plist>
34 changes: 33 additions & 1 deletion desktop/src-tauri/src/sidecar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u16>().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.
Expand All @@ -62,7 +94,7 @@ fn sidecar_log_path(app: &AppHandle) -> PathBuf {
}

pub fn start(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
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
Expand Down
5 changes: 4 additions & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
22 changes: 22 additions & 0 deletions internal/browser/bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 29 additions & 0 deletions internal/browser/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"

"github.com/cnjack/jcode/internal/config"
)
Expand All @@ -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 {
Expand Down
11 changes: 10 additions & 1 deletion internal/channel/ble/ble_cgo.go
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
Loading
Loading