跳转至

网络共享与 Tailscale 实施方案

供智能体执行者使用: 必选子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实施此计划。步骤使用复选框(- [ ])语法进行跟踪。

目标: 让用户将同一个正在运行的 VoiceStudio 后端暴露给他们的其他机器——通过 PIN 保护的局域网共享(含二维码)和 Tailscale 私有远程访问——无需重启后端、无需丢弃已加载的模型或中断进行中的任务,并且每次启动时默认仅回环(loopback-only)访问。

架构: 一个进程内的第二个 uvicorn.Server 绑定到 0.0.0.0:<P+1>,服务于同一个 FastAPI app(无需重启)。PIN 中间件会拦截非回环客户端(除非设置了 PIN,否则不生效 → Docker 部署不受影响)。现有受回环限制的 system 路由器承载控制端点(按设计仅桌面端可用)。Tailscale 通过调用 tailscale CLI 来驱动,并代理回环后端。前端:底部开关 + 设置面板,在唯一的 apiFetch 汇聚点注入全局 X-VoiceStudio-Pin 请求头,以及一个远程 PIN 门禁。

技术栈: FastAPI + uvicorn(编程式 Server)、Starlette BaseHTTPMiddleware、psutil.net_if_addrs、secrets;React + Vitest、qrcode(新增前端依赖)、tailscale CLI。

参考规范: docs/superpowers/specs/2026-05-30-network-sharing-design.md


文件结构

创建: - backend/services/network_share.py — 共享监听器生命周期 + PIN + 局域网枚举(较纯净,可单元测试)。 - backend/services/tailscale.py — tailscale CLI 封装(status/serve),CLI 不存在时优雅降级。 - frontend/src/components/NetworkToggle.jsx (+ .css) — 底部药丸按钮、确认、可展开面板(地址/二维码/PIN/复制/打开)。 - frontend/src/components/RemoteAuthGate.jsx — 远程设备在 401 时用于输入 PIN 的界面。 - frontend/src/components/settings/SharingPanel.jsx (+ .css) — 设置界面(局域网镜像 + Tailscale)。 - 测试:tests/test_network_share.py、tests/test_network_middleware.py、tests/test_tailscale_service.py、frontend/src/components/NetworkToggle.test.jsx、frontend/src/components/settings/SharingPanel.test.jsx,外加客户端 PIN 测试。

修改: - backend/api/schemas.py — 扩展 SystemInfoResponse 以包含分享字段。 - backend/api/routers/system.py — 添加 /system/network/{enable,disable,state}、/system/tailscale/{status,enable,disable} 及 /system/info 字段。 - backend/main.py — 注册 NetworkAccessMiddleware;在 lifespan 中初始化 app.state.network_share。 - frontend/src/api/client.ts — 在 apiFetch 中注入 X-VoiceStudio-Pin;在模块加载时捕获 ?pin=。 - frontend/src/components/LogsFooter.jsx — 在 logs-footer__right 中挂载 <NetworkToggle/>。 - frontend/src/pages/Settings.jsx — 注册“分享”标签页 + <SharingPanel/>。 - frontend/src/App.jsx(或根组件)— 挂载 <RemoteAuthGate/>。 - frontend/package.json — 添加 qrcode。

常量: 主后端端口是硬编码字面量 3900(backend/main.py;不使用 OMNIVOICE_PORT)。在 backend/services/network_share.py 中引入 BACKEND_PORT = 3900,并以它作为基准;分享端口是自 3901 起第一个空闲端口。


第一阶段——后端:共享监听器、schema、控制端点

任务 1:network_share 模块——局域网枚举 + PIN

文件: - 创建:backend/services/network_share.py - 测试:tests/test_network_share.py

  • [ ] 步骤 1:编写失败的测试
# tests/test_network_share.py
import socket
from unittest.mock import patch
from services import network_share as ns


def _addr(ip):
    class A:  # mimic psutil snicaddr
        family = socket.AF_INET
        address = ip
    return A()


def test_lan_ipv4_filters_loopback_and_linklocal():
    fake = {
        "lo0": [_addr("127.0.0.1")],
        "en0": [_addr("192.168.1.42")],
        "en1": [_addr("169.254.5.5"), _addr("10.0.0.9")],
    }
    with patch("services.network_share.psutil.net_if_addrs", return_value=fake):
        out = ns.lan_ipv4_addresses()
    assert out == ["192.168.1.42", "10.0.0.9"]


def test_gen_pin_is_six_digits():
    pin = ns._gen_pin()
    assert pin.isdigit() and len(pin) == 6
  • [ ] 步骤 2:运行它——预期失败

运行:cd /Users/user4/Desktop/github/VoiceStudio && python -m pytest tests/test_network_share.py -q 预期:失败(模块 services.network_share 不存在)。

  • [ ] 步骤 3:实现模块(枚举 + PIN + 状态,暂不包含监听器)
# backend/services/network_share.py
"""Same-process LAN share listener + access PIN.

Enabling starts a SECOND uvicorn.Server bound to 0.0.0.0 on a dedicated port,
serving the SAME FastAPI app object — so the loaded model and in-flight jobs
are untouched (no restart). Disabling stops it, closing the 0.0.0.0 socket.
Loopback-only by default: nothing binds 0.0.0.0 until enable() is called.
"""
import asyncio
import secrets
import socket
from dataclasses import dataclass, field
from typing import Optional

import psutil
import uvicorn

BACKEND_PORT = 3900  # must match backend/main.py uvicorn.run(port=...)


@dataclass
class ShareState:
    enabled: bool = False
    share_port: Optional[int] = None
    pin: Optional[str] = None
    lan_addresses: list = field(default_factory=list)


_state = ShareState()
_server: Optional["uvicorn.Server"] = None
_task: Optional["asyncio.Task"] = None


def lan_ipv4_addresses() -> list:
    out, seen = [], set()
    for _name, addrs in psutil.net_if_addrs().items():
        for a in addrs:
            if a.family == socket.AF_INET:
                ip = a.address
                if ip.startswith("127.") or ip.startswith("169.254."):
                    continue
                if ip not in seen:
                    seen.add(ip)
                    out.append(ip)
    return out


def _gen_pin() -> str:
    return f"{secrets.randbelow(900000) + 100000}"  # 100000-999999


def _find_free_port(base: int, tries: int = 20) -> int:
    for p in range(base, base + tries):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            try:
                s.bind(("0.0.0.0", p))
                return p
            except OSError:
                continue
    raise RuntimeError("no free share port available")


def get_state() -> ShareState:
    return _state


async def enable(app) -> ShareState:
    global _server, _task, _state
    if _state.enabled:
        return _state
    port = _find_free_port(BACKEND_PORT + 1)
    pin = _gen_pin()
    config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="warning")
    server = uvicorn.Server(config)
    server.install_signal_handlers = lambda: None  # never hijack signals in-process
    _task = asyncio.create_task(server.serve())
    for _ in range(100):  # ~5s for the socket to bind
        if getattr(server, "started", False):
            break
        await asyncio.sleep(0.05)
    _server = server
    _state = ShareState(True, port, pin, lan_ipv4_addresses())
    app.state.network_share = _state
    return _state


async def disable(app) -> ShareState:
    global _server, _task, _state
    if _server is not None:
        _server.should_exit = True
        if _task is not None:
            try:
                await asyncio.wait_for(_task, timeout=5)
            except Exception:
                pass
    _server = _task = None
    _state = ShareState()
    app.state.network_share = _state
    return _state
  • [ ] 步骤 4:运行测试 — 预期通过

运行:python -m pytest tests/test_network_share.py -q 预期结果:PASS(2 项通过)。

  • [ ] 步骤 5:提交
git add backend/services/network_share.py tests/test_network_share.py
git commit -m "feat(network): share-listener module — LAN enumeration + PIN + lifecycle"

任务 2:扩展 SystemInfoResponse schema

文件: - 修改:backend/api/schemas.py(SystemInfoResponse 模型) - 测试:由任务 3 的端点测试覆盖。

  • [ ] 步骤 1:添加可选字段(默认值安全,从而保持 /system/info 永不抛错的契约)

在 backend/api/schemas.py 中找到 class SystemInfoResponse(BaseModel),然后添加:

    share_enabled: bool = False
    share_port: Optional[int] = None
    lan_addresses: list[str] = []
    pin_required: bool = False

(如果文件中没有 from typing import Optional,请添加它。对于响应模型,list[str] = [] 作为默认值是可以的。)

  • [ ] 步骤 2:提交(与任务 3 一起提交)。

任务 3:控制端点 + /system/info 字段

文件: - 修改:backend/api/routers/system.py - 测试:tests/test_network_share.py(追加端点测试)

注意:system 路由器仅限 loopback 访问(APIRouter(dependencies=[Depends(require_loopback)]))。这里这样设计是正确的——这些控制端点按设计仅适用于桌面端。远程设备永远不会调用它们。

  • [ ] 步骤 1:编写预期失败的端点测试
# append to tests/test_network_share.py
from fastapi.testclient import TestClient


def _loopback_client():
    from main import app
    return TestClient(app, client=("127.0.0.1", 50000))


def test_network_state_endpoint_defaults_disabled():
    c = _loopback_client()
    r = c.get("/system/network/state")
    assert r.status_code == 200
    assert r.json()["enabled"] is False


def test_network_control_rejects_non_loopback():
    from main import app
    c = TestClient(app, client=("10.0.0.5", 9999))
    assert c.post("/system/network/enable").status_code == 403


def test_system_info_has_sharing_fields():
    c = _loopback_client()
    body = c.get("/system/info").json()
    for k in ("share_enabled", "share_port", "lan_addresses", "pin_required"):
        assert k in body
  • [ ] 步骤 2:运行 — 预期失败(python -m pytest tests/test_network_share.py -q)→ 404/KeyError。

  • [ ] 步骤 3:在 backend/api/routers/system.py 中添加端点 + 信息字段

在 import 附近添加:from fastapi import Request(扩展现有的 from fastapi import ... 行)以及 from services import network_share。

在文件末尾追加(紧跟 quarantine_status 之后):

# ── Network sharing (loopback-only control surface) ──────────────────────────

@router.get("/system/network/state")
async def network_state():
    st = network_share.get_state()
    return {
        "enabled": st.enabled,
        "share_port": st.share_port,
        "pin": st.pin,
        "lan_addresses": st.lan_addresses,
    }


@router.post("/system/network/enable")
async def network_enable(request: Request):
    st = await network_share.enable(request.app)
    return {
        "enabled": st.enabled,
        "share_port": st.share_port,
        "pin": st.pin,
        "lan_addresses": st.lan_addresses,
    }


@router.post("/system/network/disable")
async def network_disable(request: Request):
    st = await network_share.disable(request.app)
    return {"enabled": st.enabled}

在 system_info() 中,同时添加到成功与 except 这两个字典中(保持永不抛错的契约):

            "share_enabled": network_share.get_state().enabled,
            "share_port": network_share.get_state().share_port,
            "lan_addresses": network_share.get_state().lan_addresses,
            "pin_required": bool(network_share.get_state().pin),

(在 except 分支中使用相同的代码——get_state() 开销低且永不抛错。)

  • [ ] 步骤 4:运行 — 预期通过(python -m pytest tests/test_network_share.py -q)。

  • [ ] 步骤 5:提交

git add backend/api/schemas.py backend/api/routers/system.py tests/test_network_share.py
git commit -m "feat(network): loopback-only control endpoints + /system/info sharing fields"

阶段 2 — 后端:PIN 中间件

任务 4:NetworkAccessMiddleware

文件: - 修改:backend/main.py - 测试:tests/test_network_middleware.py

  • [ ] 步骤 1:编写预期失败的测试(在 app.state 上设置一个伪造的共享状态)
# tests/test_network_middleware.py
from fastapi.testclient import TestClient
from services import network_share as ns


def _app_with_pin(pin="123456"):
    from main import app
    app.state.network_share = ns.ShareState(enabled=True, share_port=3901, pin=pin, lan_addresses=["10.0.0.9"])
    return app


def teardown_function():
    from main import app
    app.state.network_share = ns.ShareState()  # reset → middleware inert


def test_inert_when_no_pin():
    from main import app
    app.state.network_share = ns.ShareState()  # no pin
    c = TestClient(app, client=("10.0.0.5", 1))   # non-loopback
    assert c.get("/health").status_code == 200


def test_loopback_bypasses_pin():
    c = TestClient(_app_with_pin(), client=("127.0.0.1", 1))
    assert c.get("/system/info").status_code == 200  # loopback → ok


def test_non_loopback_without_pin_401_on_api():
    c = TestClient(_app_with_pin(), client=("10.0.0.5", 1))
    r = c.get("/api/voices")  # any non-shell API path
    assert r.status_code in (401,)  # PIN required


def test_non_loopback_with_valid_pin_passes():
    c = TestClient(_app_with_pin("654321"), client=("10.0.0.5", 1))
    r = c.get("/api/voices", headers={"X-VoiceStudio-Pin": "654321"})
    assert r.status_code != 401


def test_spa_shell_served_without_pin():
    c = TestClient(_app_with_pin(), client=("10.0.0.5", 1))
    assert c.get("/health").status_code == 200
  • [ ] 步骤 2:运行 — 预期失败(当前非 loopback 的 /api/voices 返回 200/404,而不是 401)。

  • [ ] 步骤 3:在 backend/main.py 中实现中间件

在其他导入附近添加导入:from starlette.middleware.base import BaseHTTPMiddleware,并确保 JSONResponse 已导入(已导入,约第 196 行)。如果不存在,请添加 import secrets。

在 app.add_middleware(CORSMiddleware, ...) 代码块之前(约第 422 行)定义:

_LOOPBACK_CLIENTS = {"127.0.0.1", "::1"}
_SHELL_PATHS = {"/", "/index.html", "/favicon.ico", "/health"}


class NetworkAccessMiddleware(BaseHTTPMiddleware):
    """When a share PIN is set, require it for non-loopback clients on API
    routes. Inert when no PIN (default + docker deploys). Loopback (incl.
    Tailscale-proxied) always bypasses; the SPA shell is always served so the
    PIN gate UI can load."""

    async def dispatch(self, request, call_next):
        ns = getattr(request.app.state, "network_share", None)
        pin = getattr(ns, "pin", None) if ns else None
        if not pin:
            return await call_next(request)
        client = request.client.host if request.client else None
        if client in _LOOPBACK_CLIENTS:
            return await call_next(request)
        path = request.url.path
        if path in _SHELL_PATHS or path.startswith("/assets/") or path.startswith("/favicon"):
            return await call_next(request)
        supplied = (
            request.headers.get("x-omnivoice-pin")
            or request.query_params.get("pin")
            or request.cookies.get("ov_pin")
            or ""
        )
        if not secrets.compare_digest(supplied, pin):
            return JSONResponse({"detail": "PIN required"}, status_code=401)
        response = await call_next(request)
        if request.cookies.get("ov_pin") != pin:
            response.set_cookie("ov_pin", pin, samesite="lax")
        return response

注册它(顺序:在 CORS 之后添加,使 CORS 包在最外层):

app.add_middleware(NetworkAccessMiddleware)

另外,在 lifespan 中初始化状态(在 yield 之前,约第 252 行):app.state.network_share = network_share.get_state(),并在顶部导入 from services import network_share。

  • [ ] 第 4 步:运行 — 预期通过(python -m pytest tests/test_network_middleware.py -q)。如果 /api/voices 在 PIN 检查之前返回 404,请改用现有的 GET API 路径;断言是 status_code == 401,中间件会在路由之前返回该状态码。

  • [ ] 第 5 步:提交

git add backend/main.py tests/test_network_middleware.py
git commit -m "feat(network): PIN middleware — gate non-loopback API access when sharing on"

阶段 3 — 前端:PIN 请求头注入与远程门禁

任务 5:在 apiFetch 中注入 X-VoiceStudio-Pin 并捕获 ?pin=

文件: - 修改:frontend/src/api/client.ts - 测试:frontend/src/api/client.test.ts

  • [ ] 第 1 步:编写失败测试
// frontend/src/api/client.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

describe('apiFetch PIN header', () => {
  let realFetch;
  beforeEach(() => { realFetch = global.fetch; sessionStorage.clear(); });
  afterEach(() => { global.fetch = realFetch; sessionStorage.clear(); });

  it('attaches X-VoiceStudio-Pin when present in sessionStorage', async () => {
    sessionStorage.setItem('ov_pin', '424242');
    const seen: any = {};
    global.fetch = vi.fn((_url, opts) => { Object.assign(seen, opts); return Promise.resolve({ ok: true, json: async () => ({}) }); });
    const { apiFetch } = await import('./client');
    await apiFetch('/system/info');
    expect((seen.headers || {})['X-VoiceStudio-Pin']).toBe('424242');
  });

  it('omits the header when no pin', async () => {
    const seen: any = {};
    global.fetch = vi.fn((_url, opts) => { Object.assign(seen, opts); return Promise.resolve({ ok: true, json: async () => ({}) }); });
    const { apiFetch } = await import('./client');
    await apiFetch('/system/info');
    expect((seen.headers || {})['X-VoiceStudio-Pin']).toBeUndefined();
  });
});
  • [ ] 第 2 步:运行 — 预期失败(cd frontend && npx vitest run src/api/client.test.ts)。

  • [ ] 第 3 步:实现 — 在 apiFetch 中(约第 39 行的唯一统一入口):

export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
  const pin = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('ov_pin') : null;
  const headers: Record<string, string> = { ...(opts.headers as Record<string, string> || {}) };
  if (pin) headers['X-VoiceStudio-Pin'] = pin;
  const res = await fetch(apiUrl(path), { ...opts, headers });
  if (!res.ok) { const detail = await readError(res); throw new ApiError(res.status, detail); }
  return res;
}

另外,在模块顶部(export const API = ... 之后),一次性捕获二维码 URL 中提供的 PIN:

if (typeof window !== 'undefined') {
  try {
    const p = new URL(window.location.href).searchParams.get('pin');
    if (p) sessionStorage.setItem('ov_pin', p);
  } catch { /* noop */ }
}
  • [ ] 第 4 步:运行 — 预期通过。
  • [ ] 第 5 步:提交
git add frontend/src/api/client.ts frontend/src/api/client.test.ts
git commit -m "feat(network): inject X-VoiceStudio-Pin globally + capture ?pin= from QR URL"

任务 6:RemoteAuthGate 组件

文件: - 创建:frontend/src/components/RemoteAuthGate.jsx - 修改:在 frontend/src/App.jsx 中挂载 - 测试:frontend/src/components/RemoteAuthGate.test.jsx

  • [ ] 第 1 步:编写失败测试
// frontend/src/components/RemoteAuthGate.test.jsx
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import RemoteAuthGate from './RemoteAuthGate';

describe('RemoteAuthGate', () => {
  beforeEach(() => sessionStorage.clear());
  afterEach(() => sessionStorage.clear());

  it('renders children when not gated', () => {
    render(<RemoteAuthGate><div>app-content</div></RemoteAuthGate>);
    expect(screen.getByText('app-content')).toBeInTheDocument();
  });

  it('stores the entered PIN', () => {
    render(<RemoteAuthGate forceGate><div>app-content</div></RemoteAuthGate>);
    fireEvent.change(screen.getByLabelText(/access pin/i), { target: { value: '999111' } });
    fireEvent.click(screen.getByRole('button', { name: /connect/i }));
    expect(sessionStorage.getItem('ov_pin')).toBe('999111');
  });
});
  • [ ] 步骤 2:运行 — 预期失败。

  • [ ] 步骤 3:实现(门禁由 API 层在收到 401 时分发的全局 ov:pin-required 事件触发;forceGate prop 仅供测试):

// frontend/src/components/RemoteAuthGate.jsx
import { useEffect, useState } from 'react';

export default function RemoteAuthGate({ children, forceGate = false }) {
  const [gated, setGated] = useState(forceGate);
  const [pin, setPin] = useState('');

  useEffect(() => {
    const onRequired = () => setGated(true);
    window.addEventListener('ov:pin-required', onRequired);
    return () => window.removeEventListener('ov:pin-required', onRequired);
  }, []);

  if (!gated) return children;

  const submit = (e) => {
    e.preventDefault();
    const v = pin.trim();
    if (!v) return;
    sessionStorage.setItem('ov_pin', v);
    window.location.reload();
  };

  return (
    <div className="remote-auth-gate" role="dialog" aria-modal="true">
      <form onSubmit={submit} className="remote-auth-gate__card">
        <h2>Enter access PIN</h2>
        <p>This VoiceStudio instance is shared on the network. Enter the PIN shown on the host.</p>
        <label htmlFor="ov-pin">Access PIN</label>
        <input id="ov-pin" inputMode="numeric" value={pin} onChange={(e) => setPin(e.target.value)} autoFocus />
        <button type="submit">Connect</button>
      </form>
    </div>
  );
}

在 client.ts 的 apiFetch 中,在收到 401 时派发该事件(以便门禁出现):在 if (!res.ok) 分支内、抛出错误之前 — if (res.status === 401 && typeof window !== 'undefined') window.dispatchEvent(new Event('ov:pin-required'));

在 App.jsx 中挂载:用 <RemoteAuthGate>...</RemoteAuthGate> 包裹顶层应用树(导入该组件;放在根 provider 内部即可)。

  • [ ] 步骤 4:运行 — 预期通过。
  • [ ] 步骤 5:提交
git add frontend/src/components/RemoteAuthGate.jsx frontend/src/components/RemoteAuthGate.test.jsx frontend/src/api/client.ts frontend/src/App.jsx
git commit -m "feat(network): remote PIN gate on 401"

任务 7:添加 qrcode 依赖

  • [ ] 步骤 1: cd frontend && (bun add qrcode || npm install qrcode)
  • [ ] 步骤 2:提交 git add frontend/package.json frontend/bun.lockb frontend/package-lock.json 2>/dev/null; git commit -m "chore(network): add qrcode dep for share QR"

任务 8:NetworkToggle 组件

文件: - 创建:frontend/src/components/NetworkToggle.jsx、frontend/src/components/NetworkToggle.css - 修改:frontend/src/components/LogsFooter.jsx - 测试:frontend/src/components/NetworkToggle.test.jsx

  • [ ] 步骤 1:编写失败的测试
// frontend/src/components/NetworkToggle.test.jsx
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import NetworkToggle from './NetworkToggle';

describe('NetworkToggle', () => {
  let realFetch;
  beforeEach(() => { realFetch = global.fetch; });
  afterEach(() => { global.fetch = realFetch; });

  it('defaults to Local when state reports disabled', async () => {
    global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: async () => ({ enabled: false }) }));
    render(<NetworkToggle />);
    await waitFor(() => expect(screen.getByText(/local/i)).toBeInTheDocument());
  });
});
  • [ ] 步骤 2:运行 — 预期失败。

  • [ ] 步骤 3:实现(逻辑完整;样式遵循 LogsFooter.jsx 中的 SourcePill BEM 模式)。使用 ../api/client 中的 apiJson/apiPost。启用时:POST /system/network/enable;渲染包含 lan_addresses 的面板,每行提供复制(navigator.clipboard.writeText)+ 打开(openExternal,来自 ../api/external)、二维码(import QRCode from 'qrcode' → QRCode.toDataURL('http://'+ip+':'+share_port+'/?pin='+pin))以及 PIN。禁用时:POST /system/network/disable。启用前显示确认对话框。挂载时从 /system/network/state 读取初始状态。

完整组件:

// frontend/src/components/NetworkToggle.jsx
import { useEffect, useState, useCallback } from 'react';
import QRCode from 'qrcode';
import { Wifi, WifiOff, Copy, ExternalLink } from 'lucide-react';
import toast from 'react-hot-toast';
import { apiJson, apiPost } from '../api/client';
import { openExternal } from '../api/external';
import './NetworkToggle.css';

export default function NetworkToggle() {
  const [st, setSt] = useState({ enabled: false });
  const [busy, setBusy] = useState(false);
  const [open, setOpen] = useState(false);
  const [qrs, setQrs] = useState({});

  const refresh = useCallback(async () => {
    try { setSt(await apiJson('/system/network/state')); } catch { /* loopback only; ignore */ }
  }, []);
  useEffect(() => { refresh(); }, [refresh]);

  useEffect(() => {
    if (!st.enabled || !st.pin) { setQrs({}); return; }
    let cancelled = false;
    (async () => {
      const next = {};
      for (const ip of st.lan_addresses || []) {
        next[ip] = await QRCode.toDataURL(`http://${ip}:${st.share_port}/?pin=${st.pin}`);
      }
      if (!cancelled) setQrs(next);
    })();
    return () => { cancelled = true; };
  }, [st.enabled, st.pin, st.share_port, st.lan_addresses]);

  const enable = async () => {
    if (!window.confirm('Share VoiceStudio on your local network? Other devices will be able to reach it with the access PIN.')) return;
    setBusy(true);
    try { setSt(await apiPost('/system/network/enable')); setOpen(true); }
    catch (e) { toast.error(`Could not enable sharing: ${e.message}`); }
    finally { setBusy(false); }
  };
  const disable = async () => {
    setBusy(true);
    try { await apiPost('/system/network/disable'); await refresh(); setOpen(false); }
    catch (e) { toast.error(`Could not disable: ${e.message}`); }
    finally { setBusy(false); }
  };

  const copy = (text) => { navigator.clipboard?.writeText(text); toast.success('Copied'); };

  return (
    <div className="net-toggle">
      <button
        className={`net-toggle__pill ${st.enabled ? 'net-toggle__pill--on' : ''}`}
        onClick={st.enabled ? () => setOpen((o) => !o) : enable}
        disabled={busy}
        title={st.enabled ? 'Sharing on — click for details' : 'Share on your network'}
      >
        {st.enabled ? <Wifi size={12} /> : <WifiOff size={12} />}
        <span>{busy ? 'Switching…' : st.enabled ? 'Network' : 'Local'}</span>
      </button>

      {st.enabled && open && (
        <div className="net-toggle__panel">
          {(st.lan_addresses || []).length === 0 && <p>No reachable network interface — connect to Wi-Fi/Ethernet.</p>}
          {(st.lan_addresses || []).map((ip) => {
            const url = `http://${ip}:${st.share_port}/?pin=${st.pin}`;
            return (
              <div key={ip} className="net-toggle__row">
                <code>{ip}:{st.share_port}</code>
                <button onClick={() => copy(url)} aria-label={`Copy ${ip}`}><Copy size={12} /></button>
                <button onClick={() => openExternal(url)} aria-label={`Open ${ip}`}><ExternalLink size={12} /></button>
                {qrs[ip] && <img className="net-toggle__qr" src={qrs[ip]} alt={`QR for ${ip}`} width={96} height={96} />}
              </div>
            );
          })}
          <div className="net-toggle__pin">PIN: <strong>{st.pin}</strong></div>
          <button className="net-toggle__off" onClick={disable} disabled={busy}>Stop sharing</button>
        </div>
      )}
    </div>
  );
}

NetworkToggle.css:一个小的胶囊状切换按钮 + 下拉面板;与 logs-footer__* 的视觉重量匹配(执行者:遵循 LogsFooter.css 的约定 —— gruvbox 调色板,--on 强调色绿色 #b8bb26)。

  • [ ] 第 4 步:在 LogsFooter.jsx 中挂载 —— 导入 NetworkToggle,在 <div className="logs-footer__right"> 内部、logs-footer__discord 按钮之前(约第 390 行)渲染 <NetworkToggle />。

  • [ ] 第 5 步:运行测试 + 构建

运行:cd frontend && npx vitest run src/components/NetworkToggle.test.jsx && bun run build 预期:测试通过,构建退出码为 0。

  • [ ] 第 6 步:提交
git add frontend/src/components/NetworkToggle.jsx frontend/src/components/NetworkToggle.css frontend/src/components/NetworkToggle.test.jsx frontend/src/components/LogsFooter.jsx
git commit -m "feat(network): footer Local/Network toggle with LAN addresses, QR, copy/open"

阶段 5 — Tailscale

任务 9:tailscale 服务封装

文件: - 创建:backend/services/tailscale.py - 测试:tests/test_tailscale_service.py

  • [ ] 第 1 步:编写失败测试(mock subprocess)
# tests/test_tailscale_service.py
import json
from unittest.mock import patch, MagicMock
from services import tailscale as ts


def test_status_absent_cli_is_graceful():
    with patch("services.tailscale.shutil.which", return_value=None):
        s = ts.status()
    assert s["installed"] is False and s["running"] is False


def test_status_parses_json():
    payload = {"BackendState": "Running", "Self": {"DNSName": "box.tail1234.ts.net.", "TailscaleIPs": ["100.64.0.1"]}}
    with patch("services.tailscale.shutil.which", return_value="/usr/bin/tailscale"), \
         patch("services.tailscale.subprocess.run", return_value=MagicMock(returncode=0, stdout=json.dumps(payload))):
        s = ts.status()
    assert s["installed"] and s["running"]
    assert s["magic_dns_name"] == "box.tail1234.ts.net"
    assert s["tailnet_ips"] == ["100.64.0.1"]
  • [ ] 第 2 步:运行 —— 预期失败。

  • [ ] 第 3 步:实现

# backend/services/tailscale.py
"""Thin wrapper around the `tailscale` CLI. Every call degrades gracefully
when the CLI is missing or not logged in (installed/running flags)."""
import json
import shutil
import subprocess
from services.network_share import BACKEND_PORT


def _cli():
    return shutil.which("tailscale")


def status() -> dict:
    out = {"installed": False, "running": False, "magic_dns_name": "", "tailnet_ips": []}
    cli = _cli()
    if not cli:
        return out
    out["installed"] = True
    try:
        r = subprocess.run([cli, "status", "--json"], capture_output=True, text=True, timeout=10)
        if r.returncode != 0:
            return out
        data = json.loads(r.stdout or "{}")
        out["running"] = data.get("BackendState") == "Running"
        self_ = data.get("Self") or {}
        out["magic_dns_name"] = (self_.get("DNSName") or "").rstrip(".")
        out["tailnet_ips"] = self_.get("TailscaleIPs") or []
    except Exception:
        pass
    return out


def serve_enable(port: int = BACKEND_PORT) -> dict:
    cli = _cli()
    if not cli:
        return {"ok": False, "error": "tailscale CLI not found"}
    try:
        r = subprocess.run(
            [cli, "serve", "--bg", "--https=443", f"http://127.0.0.1:{port}"],
            capture_output=True, text=True, timeout=20,
        )
        if r.returncode != 0:
            return {"ok": False, "error": (r.stderr or r.stdout or "tailscale serve failed").strip()}
        dns = status().get("magic_dns_name", "")
        return {"ok": True, "url": f"https://{dns}" if dns else ""}
    except Exception as e:
        return {"ok": False, "error": str(e)}


def serve_disable() -> dict:
    cli = _cli()
    if not cli:
        return {"ok": True}
    try:
        subprocess.run([cli, "serve", "reset"], capture_output=True, text=True, timeout=20)
    except Exception:
        pass
    return {"ok": True}
  • [ ] 第 4 步:运行 —— 预期通过。

  • [ ] 第 5 步:在 backend/api/routers/system.py 中添加端点(仅回环访问的路由器 —— 这样正确,因为由桌面端驱动):

from services import tailscale as _tailscale

@router.get("/system/tailscale/status")
async def tailscale_status():
    return _tailscale.status()

@router.post("/system/tailscale/enable")
async def tailscale_enable():
    return _tailscale.serve_enable()

@router.post("/system/tailscale/disable")
async def tailscale_disable():
    return _tailscale.serve_disable()
  • [ ] 第 6 步:提交
git add backend/services/tailscale.py tests/test_tailscale_service.py backend/api/routers/system.py
git commit -m "feat(tailscale): CLI status + serve enable/disable + endpoints"

阶段 6 — 设置面板

任务 10:SharingPanel

文件: - 创建:frontend/src/components/settings/SharingPanel.jsx、.css - 修改:frontend/src/pages/Settings.jsx - 测试:frontend/src/components/settings/SharingPanel.test.jsx

  • [ ] 第 1 步:编写失败测试
// frontend/src/components/settings/SharingPanel.test.jsx
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import SharingPanel from './SharingPanel';

describe('SharingPanel', () => {
  let realFetch;
  beforeEach(() => { realFetch = global.fetch; });
  afterEach(() => { global.fetch = realFetch; });

  it('shows Tailscale "not detected" when CLI absent', async () => {
    global.fetch = vi.fn((url) => {
      if (String(url).includes('tailscale/status')) return Promise.resolve({ ok: true, json: async () => ({ installed: false }) });
      return Promise.resolve({ ok: true, json: async () => ({ enabled: false }) });
    });
    render(<SharingPanel />);
    await waitFor(() => expect(screen.getByText(/not detected|install tailscale/i)).toBeInTheDocument());
  });
});
  • [ ] 第 2 步:运行 —— 预期失败。

  • [ ] 第 3 步:实现:按照 StoragePanel.jsx 模板(默认导出、apiJson/apiPost、toast、sharingpanel__* 类、<section aria-labelledby>)。它仿照局域网(LAN)切换实现(复用 <NetworkToggle/> 或调用相同的端点),并新增一个 Tailscale 区块:读取 /system/tailscale/status;如果 !installed,显示一个“Install Tailscale”链接(openExternal('https://tailscale.com/download'));否则显示一个启用/禁用控件,POST /system/tailscale/enable|disable,并展示返回的 url,附带复制/打开按钮 + 一个二维码(复用 qrcode 导入)。

  • [ ] 步骤 4:在 Settings.jsx 中注册 — 添加 import SharingPanel from '../components/settings/SharingPanel';;在 TABS 数组中添加 { id: 'sharing', label: 'Sharing', icon: Wifi, accent: '#83a598' }(从 lucide-react 导入 Wifi);在其它 dispatch 块旁边添加 {activeTab === 'sharing' && <SharingPanel />}。

  • [ ] 步骤 5:运行测试与构建

运行:cd frontend && npx vitest run src/components/settings/SharingPanel.test.jsx && bun run build

  • [ ] 步骤 6:提交
git add frontend/src/components/settings/SharingPanel.jsx frontend/src/components/settings/SharingPanel.css frontend/src/components/settings/SharingPanel.test.jsx frontend/src/pages/Settings.jsx
git commit -m "feat(network): Settings → Sharing & Remote Access panel (LAN + Tailscale)"

阶段 7 — 文档与全量测试验证

任务 11:文档与最终检查

  • [ ] 步骤 1: 创建 docs/sharing.md — 介绍如何在局域网内共享(PIN + 二维码)、安全模型(默认仅 loopback;/system/* 受 loopback 门控)以及 Tailscale 路径。不要包含 CJK(遵守本地化规则)。
  • [ ] 步骤 2:后端测试套件 — python -m pytest tests/test_network_share.py tests/test_network_middleware.py tests/test_tailscale_service.py tests/test_no_hardcoded_cjk.py -q → 全部通过。
  • [ ] 步骤 3:前端 — cd frontend && npx vitest run src/components/NetworkToggle.test.jsx src/components/RemoteAuthGate.test.jsx src/components/settings/SharingPanel.test.jsx src/api/client.test.ts && bun run build → 通过且退出码为 0。
  • [ ] 步骤 4:提交文档 git add docs/sharing.md && git commit -m "docs(network): sharing & remote access guide"
  • [ ] 步骤 5: 从 feat/network-sharing 向 main 打开 PR;合并后,关闭 PR #125 引用即已完成。

给执行者的说明

  • system 路由器由 Depends(require_loopback) 进行 loopback 门控 — 所有 /system/network/* 和 /system/tailscale/* 端点都继承该设置(按设计仅限桌面端;这是安全保证,不是 bug)。请勿将它们移到未受门控的路由器。
  • PIN 中间件在 app.state.network_share.pin 未设置时是惰性的 — 这保留了 Docker 0.0.0.0 部署方式(无回归)。
  • request.client.host 是真实的 TCP 对端(不可伪造);loopback 检查正确且与 require_loopback 一致。
  • 保持所有面向用户的字符串为英语 / i18n — 否则 tests/test_no_hardcoded_cjk.py 会导致 CI 失败。
  • 共享监听器共享 同一个 app 对象 → 模型/任务永不重启。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 om/VoiceStudio