## Problem and outcome / 问题与结果 Native sharing could start capture before discovering that the browser could not create its local media connection. Connected Viewer routes could also be retired solely because decoded frames stopped advancing, including quiet or backgrounded game sources. This maintenance phase acquires the browser bridge before capture, preserves connected routes through frame silence, scopes Native keyframe recovery to the requested outputs, and reports terminal Browser sender failures to the existing connection owner. It also includes the accepted narrow-screen layout and tooltip corrections. ## Release notes ### 中文 这是一次维护更新,重点修复分享启动、观看连续性和手机窄屏布局。 - 修复浏览器媒体连接无法初始化时,App 仍启动采集后立即停止的问题,并保留更明确的失败原因。 - 避免仅因画面暂时没有更新,就断开已连接的观看线路;适用于游戏切后台等静止画面场景。 - 修正原生画面恢复请求的范围,避免某一路请求关键帧时影响其他输出;浏览器发送失败也会及时通知现有连接处理流程。 - 修复窄屏下房间号与操作按钮的排布,改善长标题和提示浮层的显示。 短时模糊与部分用户报告的偶发断流仍在调查,本次更新不代表所有相关情况均已解决。升级 App 或自部署 Server 后刷新页面即可,无需修改配置。 ### English This maintenance update focuses on sharing startup, viewing continuity, and compact layouts on phones. - Fix capture starting and immediately stopping when the browser cannot initialize its local media connection, and preserve clearer failure details. - Keep connected viewing routes alive when the picture temporarily stops updating, including quiet or backgrounded game sources. - Keep Native keyframe recovery scoped to the requested outputs and report terminal Browser sender failures to the existing connection handler. - Fix room-code and action layout on narrow screens, and improve long headings and tooltip placement. Brief quality drops and some reports of intermittent stream loss remain under investigation; this update does not claim to resolve every reported case. Update Piik App or your self-hosted Server and refresh the page. No configuration changes are required. ## Verification / 验证 - `npm run check`: 76 files / 1,133 tests, TypeScript and production Web build passed. - Targeted Browser sender/pool checks: 97 tests passed on maintenance and interaction candidates. - Go mediaedge/nativehost/nativecapture/forwarding tests and scoped vet passed; output-scope regression tests reject the old behavior. - Matched Chrome 152 VP8/H264 ordinary/pool tests with two viewers and a one-second network constraint completed with healthy sibling isolation. VP8's deeper pooled quality dip remains open. - Physical startup/resource-exhaustion and quiet-game reproduction boundaries are documented in the existing research owners. - `npm run check:go`: formatting, Go tests/vet, Windows/Linux builds, Windows native capture compilation/probes and regressions passed. Darwin packaging and Go race checks run on the native CI runners. - Actual Windows App H264 and VP8 Host gates passed: decoded video, live/paused profile changes, quiet/background-source recovery, source replacement, failure cleanup and subsequent sharing; all experiment-owned processes and ports closed. - Website build and repository hygiene checks passed. Independent full-phase frontend and native reviews found no release-blocking regression. ## Impact / 影响 Patch release v1.6.6. Browser/server v23, Native control v9, capture v7 and SQLite schema 2 remain unchanged. No interaction prototype, route-selection policy, new quality heuristic, or configuration migration is included. Real-phone and heterogeneous network/device coverage remain bounded by the existing verification record.
440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
NativeMediaBridge,
|
|
NativeMediaBridgeError,
|
|
NativeMediaBridgeInitializationError,
|
|
type NativeMediaBridgeControl,
|
|
} from "../src/client/native/media-bridge";
|
|
import type { NativeClientEvent } from "../src/client/native/wire";
|
|
import { debugError, debugEvent } from "../src/client/lib/debug";
|
|
|
|
vi.mock("../src/client/lib/debug", () => ({
|
|
browserDebugEnabled: true,
|
|
debugEvent: vi.fn(),
|
|
debugError: vi.fn(),
|
|
}));
|
|
|
|
class FakeMediaStream {
|
|
private readonly tracks: MediaStreamTrack[] = [];
|
|
|
|
addTrack(track: MediaStreamTrack): void {
|
|
this.tracks.push(track);
|
|
}
|
|
|
|
getTracks(): MediaStreamTrack[] {
|
|
return [...this.tracks];
|
|
}
|
|
|
|
getVideoTracks(): MediaStreamTrack[] {
|
|
return this.tracks.filter((track) => track.kind === "video");
|
|
}
|
|
|
|
getAudioTracks(): MediaStreamTrack[] {
|
|
return this.tracks.filter((track) => track.kind === "audio");
|
|
}
|
|
}
|
|
|
|
class FakePeerConnection extends EventTarget {
|
|
connectionState: RTCPeerConnectionState = "new";
|
|
iceConnectionState: RTCIceConnectionState = "new";
|
|
iceGatheringState: RTCIceGatheringState = "new";
|
|
readonly getReceivers = vi.fn<() => RTCRtpReceiver[]>(() => []);
|
|
readonly getSenders = vi.fn<() => RTCRtpSender[]>(() => []);
|
|
readonly getStats = vi.fn(async () => new Map());
|
|
readonly addIceCandidate = vi.fn(async () => undefined);
|
|
readonly setRemoteDescription = vi.fn(async () => undefined);
|
|
readonly createAnswer = vi.fn(async () => ({
|
|
type: "answer" as const,
|
|
sdp: "v=0\r\n",
|
|
}));
|
|
readonly setLocalDescription = vi.fn(async (): Promise<void> => undefined);
|
|
readonly close = vi.fn(() => {
|
|
this.connectionState = "closed";
|
|
});
|
|
|
|
emitTrack(track: MediaStreamTrack): void {
|
|
this.dispatchEvent(Object.assign(new Event("track"), { track }));
|
|
}
|
|
|
|
setState(state: RTCPeerConnectionState): void {
|
|
this.connectionState = state;
|
|
this.dispatchEvent(new Event("connectionstatechange"));
|
|
}
|
|
}
|
|
|
|
function fixture(options: {
|
|
hangPreparation?: boolean;
|
|
} = {}) {
|
|
let listener: ((event: NativeClientEvent) => void) | null = null;
|
|
let peer: FakePeerConnection | null = null;
|
|
vi.stubGlobal("window", globalThis);
|
|
vi.stubGlobal("MediaStream", FakeMediaStream);
|
|
vi.stubGlobal("RTCPeerConnection", class {
|
|
constructor() {
|
|
peer = new FakePeerConnection();
|
|
return peer;
|
|
}
|
|
});
|
|
const prepareLocalEdge = vi.fn<
|
|
NativeMediaBridgeControl["prepareLocalEdge"]
|
|
>(async (_shareId, connectionId) => {
|
|
listener?.({
|
|
version: 9,
|
|
type: "edge-candidate",
|
|
shareId: "share_123456",
|
|
connectionId,
|
|
candidate: {
|
|
candidate: "candidate:1 1 udp 1 127.0.0.1 9 typ host",
|
|
},
|
|
});
|
|
if (options.hangPreparation) {
|
|
return await new Promise<RTCSessionDescriptionInit>(() => undefined);
|
|
}
|
|
return { type: "offer", sdp: "v=0\r\n" };
|
|
});
|
|
const control: NativeMediaBridgeControl = {
|
|
prepareLocalEdge,
|
|
acceptSignal: vi.fn(async () => undefined),
|
|
closeEdge: vi.fn(async () => undefined),
|
|
onEvent: vi.fn((next) => {
|
|
listener = next;
|
|
return () => {
|
|
listener = null;
|
|
};
|
|
}),
|
|
};
|
|
const onFailed = vi.fn();
|
|
const bridge = new NativeMediaBridge(
|
|
"share_123456",
|
|
control,
|
|
onFailed,
|
|
);
|
|
return {
|
|
bridge,
|
|
control,
|
|
onFailed,
|
|
peer: () => peer!,
|
|
emit: (event: NativeClientEvent) => listener?.(event),
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("native media bridge", () => {
|
|
it("releases an unstarted Browser reservation without sending an edge command", async () => {
|
|
const current = fixture();
|
|
current.bridge.dispose();
|
|
current.bridge.dispose();
|
|
await expect(current.bridge.start()).rejects.toBeInstanceOf(NativeMediaBridgeError);
|
|
expect(current.peer().close).toHaveBeenCalledOnce();
|
|
expect(current.control.prepareLocalEdge).not.toHaveBeenCalled();
|
|
expect(current.control.closeEdge).not.toHaveBeenCalled();
|
|
expect(current.onFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["MediaStream", "RTCPeerConnection"])("preserves %s initialization failure without preparing an edge", (api) => {
|
|
const current = fixture();
|
|
current.bridge.dispose();
|
|
vi.clearAllMocks();
|
|
const cause = new DOMException("Browser media initialization rejected", "NotAllowedError");
|
|
vi.stubGlobal(api, class { constructor() { throw cause; } });
|
|
let failure: unknown;
|
|
try { new NativeMediaBridge("share_123456", current.control, current.onFailed); }
|
|
catch (error) { failure = error; }
|
|
|
|
expect(failure).toBeInstanceOf(NativeMediaBridgeError);
|
|
expect(failure).toBeInstanceOf(NativeMediaBridgeInitializationError);
|
|
expect((failure as Error).cause).toBe(cause);
|
|
expect(debugError).toHaveBeenCalledWith("native-bridge", "initialization-failed", cause);
|
|
expect(current.control.prepareLocalEdge).not.toHaveBeenCalled();
|
|
expect(current.control.closeEdge).not.toHaveBeenCalled();
|
|
expect(current.onFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("records the negotiation cause before retiring a failed bridge", async () => {
|
|
const current = fixture();
|
|
const cause = new DOMException("Remote offer rejected", "OperationError");
|
|
current.peer().setRemoteDescription.mockRejectedValueOnce(cause);
|
|
await expect(current.bridge.start()).rejects.toMatchObject({ cause });
|
|
expect(debugError).toHaveBeenCalledWith("native-bridge", "failed", cause,
|
|
{ type: "negotiation", state: "starting" });
|
|
expect(current.peer().close).toHaveBeenCalledOnce();
|
|
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
|
expect(current.onFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["candidate-signal", "candidate-apply"])("preserves the active %s failure and retires once", async (stage) => {
|
|
const current = fixture();
|
|
const starting = current.bridge.start();
|
|
await vi.waitFor(() => expect(current.peer().setLocalDescription).toHaveBeenCalledOnce());
|
|
current.peer().emitTrack({ kind: "video", stop: vi.fn() } as unknown as MediaStreamTrack);
|
|
current.peer().setState("connected");
|
|
await starting;
|
|
const cause = new DOMException("Connection is unavailable", "InvalidStateError");
|
|
if (stage === "candidate-signal") {
|
|
vi.mocked(current.control.acceptSignal).mockRejectedValueOnce(cause);
|
|
current.peer().dispatchEvent(Object.assign(new Event("icecandidate"), { candidate: null }));
|
|
} else {
|
|
current.peer().addIceCandidate.mockRejectedValueOnce(cause);
|
|
current.emit({ version: 9, type: "edge-candidate", shareId: "share_123456",
|
|
connectionId: current.bridge.connectionId, candidate: null });
|
|
}
|
|
await vi.waitFor(() => expect(current.onFailed).toHaveBeenCalledOnce());
|
|
expect(debugError).toHaveBeenCalledWith("native-bridge", "failed", cause, { type: stage, state: "active" });
|
|
expect(current.peer().close).toHaveBeenCalledOnce();
|
|
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each(["queued", "active"])("keeps media alive after a %s candidate is rejected", async (phase) => {
|
|
const current = fixture();
|
|
const peer = current.peer();
|
|
if (phase === "queued") {
|
|
peer.addIceCandidate.mockRejectedValueOnce(new DOMException("Rejected candidate", "OperationError"));
|
|
}
|
|
const starting = current.bridge.start();
|
|
// Register a rejection observer before allowing negotiation to run.
|
|
const result = starting.catch((error: unknown) => error);
|
|
if (phase === "queued") {
|
|
current.emit({
|
|
version: 9, type: "edge-candidate", shareId: "share_123456",
|
|
connectionId: current.bridge.connectionId,
|
|
candidate: { candidate: "candidate:2 1 udp 1 127.0.0.1 10 typ host" },
|
|
});
|
|
}
|
|
await vi.waitFor(() => expect(peer.setLocalDescription).toHaveBeenCalledOnce());
|
|
const track = { kind: "video", stop: vi.fn() } as unknown as MediaStreamTrack;
|
|
peer.emitTrack(track);
|
|
peer.setState("connected");
|
|
await expect(result).resolves.toBe(current.bridge.stream);
|
|
if (phase === "active") {
|
|
peer.addIceCandidate.mockRejectedValueOnce(new DOMException("Rejected candidate", "OperationError"));
|
|
current.emit({
|
|
version: 9, type: "edge-candidate", shareId: "share_123456",
|
|
connectionId: current.bridge.connectionId,
|
|
candidate: { candidate: "candidate:2 1 udp 1 127.0.0.1 10 typ host" },
|
|
});
|
|
}
|
|
current.emit({
|
|
version: 9, type: "edge-candidate", shareId: "share_123456",
|
|
connectionId: current.bridge.connectionId, candidate: null,
|
|
});
|
|
await vi.waitFor(() => expect(debugError).toHaveBeenCalledWith(
|
|
"webrtc", "remote-candidate-rejected", expect.any(DOMException), { origin: "ordinary" },
|
|
));
|
|
expect(peer.addIceCandidate).toHaveBeenCalledWith(null);
|
|
expect(peer.close).not.toHaveBeenCalled();
|
|
expect(track.stop).not.toHaveBeenCalled();
|
|
expect(current.onFailed).not.toHaveBeenCalled();
|
|
current.bridge.dispose();
|
|
});
|
|
|
|
it("still rejects invalid negotiation state when applying a candidate", async () => {
|
|
const current = fixture();
|
|
current.peer().addIceCandidate.mockRejectedValueOnce(new DOMException("No description", "InvalidStateError"));
|
|
await expect(current.bridge.start()).rejects.toBeInstanceOf(NativeMediaBridgeError);
|
|
expect(current.peer().close).toHaveBeenCalledOnce();
|
|
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("orders an early native candidate after the remote offer", async () => {
|
|
const current = fixture();
|
|
const starting = current.bridge.start();
|
|
await vi.waitFor(() => {
|
|
expect(current.peer().setLocalDescription).toHaveBeenCalledOnce();
|
|
});
|
|
const track = {
|
|
kind: "video",
|
|
stop: vi.fn(),
|
|
} as unknown as MediaStreamTrack;
|
|
current.peer().emitTrack(track);
|
|
current.peer().setState("connected");
|
|
|
|
await expect(starting).resolves.toBe(current.bridge.stream);
|
|
expect(current.peer().addIceCandidate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ candidate: expect.stringContaining("127.0.0.1") }),
|
|
);
|
|
expect(current.control.acceptSignal).toHaveBeenCalledWith(
|
|
"share_123456",
|
|
current.bridge.connectionId,
|
|
expect.objectContaining({ kind: "description" }),
|
|
);
|
|
current.bridge.dispose();
|
|
expect(current.peer().close).toHaveBeenCalledOnce();
|
|
expect(track.stop).toHaveBeenCalledOnce();
|
|
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("releases a local edge when preparation exceeds the total deadline", async () => {
|
|
vi.useFakeTimers();
|
|
const current = fixture({ hangPreparation: true });
|
|
const starting = current.bridge.start();
|
|
const rejected = expect(starting).rejects.toThrow("Native media bridge failed");
|
|
|
|
await vi.advanceTimersByTimeAsync(8_000);
|
|
|
|
await rejected;
|
|
expect(current.peer().close).toHaveBeenCalledOnce();
|
|
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not publish a declared audio share before its audio track arrives", async () => {
|
|
const current = fixture();
|
|
const starting = current.bridge.start(true);
|
|
let resolved = false;
|
|
void starting.then(() => {
|
|
resolved = true;
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(current.peer().setLocalDescription).toHaveBeenCalledOnce();
|
|
});
|
|
current.peer().emitTrack({
|
|
kind: "video",
|
|
stop: vi.fn(),
|
|
} as unknown as MediaStreamTrack);
|
|
current.peer().setState("connected");
|
|
await Promise.resolve();
|
|
expect(resolved).toBe(false);
|
|
|
|
current.peer().emitTrack({
|
|
kind: "audio",
|
|
stop: vi.fn(),
|
|
} as unknown as MediaStreamTrack);
|
|
|
|
await expect(starting).resolves.toBe(current.bridge.stream);
|
|
expect(current.bridge.stream.getAudioTracks()).toHaveLength(1);
|
|
});
|
|
|
|
it("reports only categorical transport and candidate counts on timeout", async () => {
|
|
vi.useFakeTimers();
|
|
const current = fixture();
|
|
const peer = current.peer();
|
|
peer.connectionState = "connecting";
|
|
peer.iceConnectionState = "checking";
|
|
peer.iceGatheringState = "complete";
|
|
peer.getReceivers.mockReturnValue([{
|
|
track: { kind: "video", readyState: "live", id: "private-track" },
|
|
transport: { state: "new" },
|
|
} as unknown as RTCRtpReceiver]);
|
|
peer.getStats.mockResolvedValue(new Map([
|
|
["private-local", { type: "local-candidate", candidateType: "host", protocol: "tcp", address: "192.0.2.1", port: 54321 }],
|
|
["private-remote", { type: "remote-candidate", candidateType: "host", protocol: "udp", address: "192.0.2.2", port: 54322 }],
|
|
]));
|
|
const starting = current.bridge.start();
|
|
const rejected = expect(starting).rejects.toBeInstanceOf(NativeMediaBridgeError);
|
|
await vi.advanceTimersByTimeAsync(8_000);
|
|
await rejected;
|
|
|
|
expect(debugEvent).toHaveBeenCalledWith("native-bridge", "failed", { type: "timeout", state: "starting" });
|
|
expect(debugEvent).toHaveBeenCalledWith("native-bridge", "ice-state", { state: "checking" });
|
|
expect(debugEvent).toHaveBeenCalledWith("native-bridge", "ice-gathering-state", { state: "complete" });
|
|
expect(debugEvent).toHaveBeenCalledWith("native-bridge", "dtls-state", { state: "new" });
|
|
expect(debugEvent).toHaveBeenCalledWith("native-bridge", "receiver-count", { type: "video", state: "live", count: 1 });
|
|
expect(debugEvent).toHaveBeenCalledWith("native-bridge", "candidate-count", { type: "local-host", state: "udp", count: 0 });
|
|
expect(debugEvent).toHaveBeenCalledWith("native-bridge", "candidate-count", { type: "remote-host", state: "udp", count: 1 });
|
|
const report = JSON.stringify(vi.mocked(debugEvent).mock.calls);
|
|
for (const privateValue of ["private-track", "private-local", "private-remote", "192.0.2.", "54321", "54322"]) {
|
|
expect(report).not.toContain(privateValue);
|
|
}
|
|
expect(peer.getStats).toHaveBeenCalledOnce();
|
|
expect(peer.close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each(["failed", "pending"])("does not delay timeout cleanup for %s diagnostics", async (state) => {
|
|
vi.useFakeTimers();
|
|
const current = fixture();
|
|
current.peer().getStats.mockImplementation(() => state === "failed"
|
|
? Promise.reject(new Error("private stats failure"))
|
|
: new Promise(() => undefined));
|
|
const rejected = expect(current.bridge.start()).rejects.toBeInstanceOf(NativeMediaBridgeError);
|
|
await vi.advanceTimersByTimeAsync(8_000);
|
|
await rejected;
|
|
expect(current.peer().close).toHaveBeenCalledOnce();
|
|
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
|
if (state === "failed") expect(debugError).toHaveBeenCalledWith(
|
|
"native-bridge", "diagnostics-failed", expect.any(Error),
|
|
);
|
|
});
|
|
|
|
it("reports an active native failure once and retires its edge", async () => {
|
|
const current = fixture();
|
|
const starting = current.bridge.start();
|
|
await vi.waitFor(() => {
|
|
expect(current.peer().setLocalDescription).toHaveBeenCalledOnce();
|
|
});
|
|
current.peer().emitTrack({
|
|
kind: "video",
|
|
stop: vi.fn(),
|
|
} as unknown as MediaStreamTrack);
|
|
current.peer().setState("connected");
|
|
await starting;
|
|
|
|
current.emit({
|
|
version: 9,
|
|
type: "edge-state",
|
|
shareId: "share_123456",
|
|
connectionId: current.bridge.connectionId,
|
|
state: "failed",
|
|
});
|
|
current.emit({
|
|
version: 9,
|
|
type: "edge-state",
|
|
shareId: "share_123456",
|
|
connectionId: current.bridge.connectionId,
|
|
state: "failed",
|
|
});
|
|
|
|
expect(current.onFailed).toHaveBeenCalledOnce();
|
|
expect(current.peer().close).toHaveBeenCalledOnce();
|
|
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("rejects a pending start when the bridge is disposed", async () => {
|
|
const current = fixture();
|
|
const starting = current.bridge.start();
|
|
await vi.waitFor(() => {
|
|
expect(current.peer().setLocalDescription).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
current.bridge.dispose();
|
|
|
|
await expect(starting).rejects.toThrow("Native media bridge failed");
|
|
expect(current.onFailed).not.toHaveBeenCalled();
|
|
expect(current.peer().close).toHaveBeenCalledOnce();
|
|
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
|
expect(debugError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["prepare", "answer"])("does not signal after disposal during %s", async (stage) => {
|
|
const current = fixture();
|
|
let complete!: () => void;
|
|
const pending = new Promise<void>(resolve => { complete = resolve; });
|
|
if (stage === "prepare") {
|
|
vi.mocked(current.control.prepareLocalEdge).mockImplementationOnce(async () => {
|
|
await pending;
|
|
return { type: "offer", sdp: "v=0\r\n" };
|
|
});
|
|
} else {
|
|
current.peer().setLocalDescription.mockImplementationOnce(() => pending);
|
|
}
|
|
const rejected = expect(current.bridge.start()).rejects.toBeInstanceOf(NativeMediaBridgeError);
|
|
if (stage === "answer") await vi.waitFor(() => expect(current.peer().setLocalDescription).toHaveBeenCalledOnce());
|
|
current.bridge.dispose();
|
|
complete();
|
|
current.peer().dispatchEvent(Object.assign(new Event("icecandidate"), { candidate: null }));
|
|
await rejected;
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
expect(current.control.acceptSignal).not.toHaveBeenCalled();
|
|
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
|
expect(current.onFailed).not.toHaveBeenCalled();
|
|
});
|
|
});
|