fix(connection): retry startup and clarify sharing status
Public invitations could stop after an early tunnel-process exit, and connection progress and Viewer status did not consistently describe the current attempt or Host state. This maintenance release adds bounded startup retries, aligns enhanced NAT retry feedback across entry paths, and fixes camera-preview and room-entry layout. The existing route owner and published contracts remain in place. Four-attempt progress is advertised and opted into; older readers retain supported messages. Interaction/chat experiments are outside this PR. Validation: npm run check (74 files, 1072 tests and production build), npm run check:go (including Windows capture compile), documentation/repository hygiene, 10 production Viewer-entry discovery cases, and a real public-link gate with remote HTTP/WebSocket access and shutdown cleanup all passed. Existing device/network evidence limits remain documented. ## Release notes ### 中文 这是一次维护更新,重点改善公网邀请启动、连接重试反馈和分享状态提示,并修正两处界面布局问题。 - 公网邀请服务在连接就绪前意外退出时,会自动重试,最多重试 3 次;所有尝试仍共用 30 秒启动预算。 - 增强穿透连接在首次尝试失败后最多重试 3 次,重试进度显示为 1/3、2/3、3/3。邀请链接和房间号加入都支持新版进度提示。 - 修正观众状态提示:房主离线时不再误报“没有可用的媒体线路”,服务器连接恢复与媒体恢复也会分别呈现。 - 修正电视内房间号输入界面的居中位置,以及摄像头选择预览被裁切的问题。 - 尚未确认来源声音状态时,不再提前提示“未分享来源声音”。 本次保持现有版本兼容;新版重试进度需要页面与服务端均支持。额外重试不能保证所有网络都能直连。 ### English This maintenance update improves public invitation startup, connection retry feedback, and sharing status messages, with two layout fixes. - If the public invitation service exits before connecting, Piik retries up to three times within the existing shared 30-second startup budget. - Enhanced NAT traversal now allows up to three retries after the initial attempt, shown as 1/3, 2/3, and 3/3. Both invitation links and room-code entry support the updated progress display. - Viewer status now distinguishes an offline host from an unavailable media route, and reports server reconnection separately from media recovery. - Fixed the alignment of room-code entry inside the TV and cropping in camera-selection previews. - The missing-source-audio notice now waits until the source's audio availability is known. Existing version compatibility is preserved. Updated retry progress requires support from both the page and server. Additional retries cannot guarantee a direct connection on every network.
This commit is contained in:
41 files changed
+671
-175
No files matched your search
@@ -102,7 +102,7 @@ continuous rebalance or general load score is introduced.
|
|||||||
A Peer opportunity is fenced by its exact parent and endpoint sessions plus
|
A Peer opportunity is fenced by its exact parent and endpoint sessions plus
|
||||||
endpoint-transition strength. With NAT traversal disabled, one failed attempt
|
endpoint-transition strength. With NAT traversal disabled, one failed attempt
|
||||||
consumes it. With traversal enabled, availability acquisition and direct
|
consumes it. With traversal enabled, availability acquisition and direct
|
||||||
continuation may create at most three candidate connections for that opportunity.
|
continuation may create at most four candidate connections for that opportunity.
|
||||||
Only an admitted candidate creation spends an attempt; duplicate failure and
|
Only an admitted candidate creation spends an attempt; duplicate failure and
|
||||||
timeout messages cannot spend it again. A preparation rejection that cannot
|
timeout messages cannot spend it again. A preparation rejection that cannot
|
||||||
create a connection remains terminal. A new session or strictly stronger
|
create a connection remains terminal. A new session or strictly stronger
|
||||||
@@ -168,7 +168,10 @@ remaining Peer budget into the existing round-robin direct continuations, not
|
|||||||
another retry ledger. No usable SFU route is interrupted by these attempts.
|
another retry ledger. No usable SFU route is interrupted by these attempts.
|
||||||
This permits longer total acquisition while keeping every operation bounded and
|
This permits longer total acquisition while keeping every operation bounded and
|
||||||
requiring no polling or independent retry timer. The prepare message reports
|
requiring no polling or independent retry timer. The prepare message reports
|
||||||
the actual candidate's connection attempt, not inferred UI progress.
|
the actual candidate's connection attempt, not inferred UI progress. Four-attempt
|
||||||
|
progress is sent only to recipients opting in after HTTP capability discovery;
|
||||||
|
older pages receive the same candidate without the optional progress field.
|
||||||
|
This reader support does not change routing authority or its attempt budget.
|
||||||
|
|
||||||
The five-second no-transport-progress window advances a silent Peer only while
|
The five-second no-transport-progress window advances a silent Peer only while
|
||||||
another bounded candidate or SFU fallback remains. It is a route scheduling
|
another bounded candidate or SFU fallback remains. It is a route scheduling
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ connection acquisition reuses the route controller's existing opportunity ledger
|
|||||||
whether a signaled candidate was ordinary, predicted, or end-of-candidates,
|
whether a signaled candidate was ordinary, predicted, or end-of-candidates,
|
||||||
and whether the selected remote foundation was ordinary, predicted, or
|
and whether the selected remote foundation was ordinary, predicted, or
|
||||||
unknown.
|
unknown.
|
||||||
5. The same per-share gate also enables the three-attempt Peer acquisition
|
5. The same per-share gate also enables the four-attempt Peer acquisition
|
||||||
budget in [ADR-0005](0005-automatic-hybrid-media-routing.md). Browser and
|
budget in [ADR-0005](0005-automatic-hybrid-media-routing.md). Browser and
|
||||||
Native candidates use the ordinary server-owned prepare/rollback lifecycle;
|
Native candidates use the ordinary server-owned prepare/rollback lifecycle;
|
||||||
no Viewer-owned ICE-restart loop is added. Local public-link and Hosted
|
no Viewer-owned ICE-restart loop is added. Local public-link and Hosted
|
||||||
@@ -86,7 +86,7 @@ Negative:
|
|||||||
may add connectivity checks for up to eight adjacent UDP ports;
|
may add connectivity checks for up to eight adjacent UDP ports;
|
||||||
- two endpoint-dependent NATs remain sensitive to candidate scheduling and
|
- two endpoint-dependent NATs remain sensitive to candidate scheduling and
|
||||||
intervening mappings, so room-wide coverage is not a success guarantee; and
|
intervening mappings, so room-wide coverage is not a success guarantee; and
|
||||||
- enabled acquisition can occupy up to three ordinary operation windows when
|
- enabled acquisition can occupy up to four ordinary operation windows when
|
||||||
one parent remains, yielding between operations to other waiting Viewers.
|
one parent remains, yielding between operations to other waiting Viewers.
|
||||||
|
|
||||||
Predicted connectivity checks originate from participant ICE agents and target
|
Predicted connectivity checks originate from participant ICE agents and target
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ its content and design boundary.
|
|||||||
| Documentation source | [Reader guides](../guide/README.md), with a separate [developer map](../README.md) |
|
| Documentation source | [Reader guides](../guide/README.md), with a separate [developer map](../README.md) |
|
||||||
|
|
||||||
GitHub Pages serves `piik.tv` behind
|
GitHub Pages serves `piik.tv` behind
|
||||||
Cloudflare; both the GitHub origin and Cloudflare edge use HTTPS. Website DNS is
|
Cloudflare; both the GitHub origin and Cloudflare edge use HTTPS. Website and
|
||||||
proxied with strict origin TLS verification and origin cache headers respected.
|
Demo HTTP traffic use proxied DNS with origin cache headers respected.
|
||||||
Demo DNS remains unproxied so its UDP listeners stay reachable.
|
The separate `stun.piik.tv` hostname stays DNS-only for direct UDP connectivity.
|
||||||
|
|
||||||
GitHub Pages can serve the complete website: it hosts
|
GitHub Pages can serve the complete website: it hosts
|
||||||
[static HTML, CSS and JavaScript](https://docs.github.com/en/pages/getting-started-with-github-pages/what-is-github-pages).
|
[static HTML, CSS and JavaScript](https://docs.github.com/en/pages/getting-started-with-github-pages/what-is-github-pages).
|
||||||
@@ -38,19 +38,32 @@ ALLOWED_ORIGINS=https://demo.piik.tv
|
|||||||
SITE_ACCESS_PASSWORD=
|
SITE_ACCESS_PASSWORD=
|
||||||
ROOM_DATABASE_PATH=/var/lib/piik/rooms.sqlite
|
ROOM_DATABASE_PATH=/var/lib/piik/rooms.sqlite
|
||||||
MAX_VIEWERS_PER_ROOM=20
|
MAX_VIEWERS_PER_ROOM=20
|
||||||
STUN_URLS=stun:demo.piik.tv:3478
|
STUN_URLS=stun:stun.piik.tv:3478
|
||||||
NAT_PREDICTION_ENABLED=true
|
NAT_PREDICTION_ENABLED=true
|
||||||
SFU_UDP_PORT=
|
SFU_UDP_PORT=
|
||||||
```
|
```
|
||||||
|
|
||||||
Point `demo.piik.tv` directly at the US server so UDP reaches its STUN listeners;
|
Point both hostnames at the US server: proxy `demo.piik.tv` through Cloudflare,
|
||||||
allow UDP 3478/3479/3480 and terminate HTTPS/WSS at Caddy. The demo uses the
|
but leave `stun.piik.tv` DNS-only. Allow UDP 3478/3479/3480 directly to the server;
|
||||||
|
Cloudflare's ordinary HTTP proxy does not carry STUN. Caddy terminates origin
|
||||||
|
HTTPS/WSS. The demo uses the
|
||||||
standard [systemd service](./service-management.md#systemd), with its release
|
standard [systemd service](./service-management.md#systemd), with its release
|
||||||
under `/opt/piik/current` and configuration at `/etc/piik/piik.env`. The service
|
under `/opt/piik/current` and configuration at `/etc/piik/piik.env`. The service
|
||||||
unit provides the SQLite state directory. Room ownership, invitations and private-room
|
unit provides the SQLite state directory. Room ownership, invitations and private-room
|
||||||
admission still apply. The [configuration reference](../standards/configuration.md)
|
admission still apply. The [configuration reference](../standards/configuration.md)
|
||||||
owns each setting and its bounds.
|
owns each setting and its bounds.
|
||||||
|
|
||||||
|
The Demo's Caddy proxy compresses `/assets/*` with zstd/gzip. Successful
|
||||||
|
content-hashed JavaScript and CSS responses use
|
||||||
|
`Cache-Control: public, max-age=31536000, immutable`; other responses default to
|
||||||
|
`no-store`. Match both the hashed filename and a successful response status so
|
||||||
|
a missing bundle cannot acquire the long cache lifetime. Keep HTML, room APIs,
|
||||||
|
health responses and signaling outside static caching. New bundles have new
|
||||||
|
filenames; do not publish different bytes under an existing hashed URL.
|
||||||
|
Cloudflare caches those static bundles; HTML and API responses remain uncached,
|
||||||
|
and WebSocket signaling passes through to the same room authority. Changing the
|
||||||
|
HTTP delivery path does not enable SFU or proxy peer media.
|
||||||
|
|
||||||
For updates, follow the [application release boundary](../deployment.md#release-boundary)
|
For updates, follow the [application release boundary](../deployment.md#release-boundary)
|
||||||
and its artifact verification, health checks and recovery rules. Routine application
|
and its artifact verification, health checks and recovery rules. Routine application
|
||||||
rollback restores the previous executable while keeping current room data. This
|
rollback restores the previous executable while keeping current room data. This
|
||||||
|
|||||||
@@ -166,6 +166,15 @@ its HTTP/2 fallback. Piik requests `--protocol auto` and
|
|||||||
the existing 30-second startup budget. This affects HTTP/WebSocket control only;
|
the existing 30-second startup budget. This affects HTTP/WebSocket control only;
|
||||||
media routes, tunnel identity and ordered shutdown keep their existing owners.
|
media routes, tunnel identity and ordered shutdown keep their existing owners.
|
||||||
DNS failures, API rejection and networks blocking both transports can still fail.
|
DNS failures, API rejection and networks blocking both transports can still fail.
|
||||||
|
The preceding Quick Tunnel allocation is a single HTTPS request with a 15-second
|
||||||
|
dependency timeout; failure exits before transport fallback applies. The App's
|
||||||
|
pre-readiness exit message and exit code do not identify that cause. Match the
|
||||||
|
same run's `public-tunnel` dependency diagnostics before classifying a report.
|
||||||
|
Piik retries a child that exits before readiness, up to four starts with a
|
||||||
|
one-second pause between starts, all within the same 30-second acquisition
|
||||||
|
deadline. The previous child, output readers and temporary config retire before
|
||||||
|
the next start. Cancellation and local setup errors stop acquisition; a ready
|
||||||
|
tunnel retains its original identity and explicit ordered-close owner.
|
||||||
|
|
||||||
A 2026-09-21 Windows check used the packaged binary with an isolated UDP sink and
|
A 2026-09-21 Windows check used the packaged binary with an isolated UDP sink and
|
||||||
TCP forwarder. The previous arguments timed out at 30 seconds without trying TCP.
|
TCP forwarder. The previous arguments timed out at 30 seconds without trying TCP.
|
||||||
@@ -177,7 +186,9 @@ establish regional reachability, uninterrupted connector availability or the
|
|||||||
cause of reports without matching diagnostics. Upstream
|
cause of reports without matching diagnostics. Upstream
|
||||||
[Quick Tunnel setup](https://github.com/cloudflare/cloudflared/blob/2026.8.3/cmd/cloudflared/tunnel/quick_tunnel.go)
|
[Quick Tunnel setup](https://github.com/cloudflare/cloudflared/blob/2026.8.3/cmd/cloudflared/tunnel/quick_tunnel.go)
|
||||||
and [protocol fallback](https://github.com/cloudflare/cloudflared/blob/2026.8.3/supervisor/tunnel.go)
|
and [protocol fallback](https://github.com/cloudflare/cloudflared/blob/2026.8.3/supervisor/tunnel.go)
|
||||||
own the dependency behavior; Piik adds no separate retry loop.
|
own the dependency behavior. An early-exit fixture confirms fourth-start recovery
|
||||||
|
and bounded exhaustion; these checks do not identify a reporter's API or network
|
||||||
|
failure or guarantee four starts when earlier attempts consume the deadline.
|
||||||
|
|
||||||
[HTTP 1033](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1033/)
|
[HTTP 1033](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1033/)
|
||||||
means Cloudflare cannot find a healthy tunnel connector. App readiness requires
|
means Cloudflare cannot find a healthy tunnel connector. App readiness requires
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ production behavior and remaining acceptance.
|
|||||||
unsupported, pending, denied or failed access must leave ordinary capture and
|
unsupported, pending, denied or failed access must leave ordinary capture and
|
||||||
stop controls usable. Other capture sessions can keep the border visible.
|
stop controls usable. Other capture sessions can keep the border visible.
|
||||||
Browser indicators remain browser-owned.
|
Browser indicators remain browser-owned.
|
||||||
- Share and source-switch requests ask the Browser for available audio by
|
- Display/window/tab capture requests ask the Browser for available audio by
|
||||||
default. Missing audio is reported clearly but does not block video-only
|
default. Missing audio is reported clearly but does not block video-only
|
||||||
sharing. Native screen capture can include system playback audio and native
|
sharing. Native screen capture can include system playback audio and native
|
||||||
window capture can include selected-process audio when the platform exposes it.
|
window capture can include selected-process audio when the platform exposes it.
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ over a reused comic's default; text and pure-visual tooltips retain that context
|
|||||||
show a Browser and server, not a failed video path. A media-route failure can
|
show a Browser and server, not a failed video path. A media-route failure can
|
||||||
coexist with a healthy server connection, and vice versa.
|
coexist with a healthy server connection, and vice versa.
|
||||||
- Connection notice: signaling recovery may coexist with healthy media. It
|
- Connection notice: signaling recovery may coexist with healthy media. It
|
||||||
cannot convert proved playback into waiting.
|
cannot convert proved playback into waiting or hide media recovery. The
|
||||||
|
presentation stage owns the primary media status; a coexisting notice never
|
||||||
|
selects that status or asserts another connection's health.
|
||||||
- Notices: necessary coexisting issues and operation feedback use the existing
|
- Notices: necessary coexisting issues and operation feedback use the existing
|
||||||
pills near the stage; they are not another primary media status. Raw
|
pills near the stage; they are not another primary media status. Raw
|
||||||
PeerConnection errors remain diagnostic observations, not persistent red
|
PeerConnection errors remain diagnostic observations, not persistent red
|
||||||
@@ -61,6 +63,19 @@ over a reused comic's default; text and pure-visual tooltips retain that context
|
|||||||
The operation supplies its outcome tone and matching scene together. Neither
|
The operation supplies its outcome tone and matching scene together. Neither
|
||||||
the presence of an illustration nor a default checkmark proves success;
|
the presence of an illustration nor a default checkmark proves success;
|
||||||
partial application retains a limitation instead of claiming completion.
|
partial application retains a limitation instead of claiming completion.
|
||||||
|
- Source sound: every capture source with confirmed absent source audio keeps
|
||||||
|
the same neutral notice and detail, including camera capture. This describes
|
||||||
|
the captured input, not a failure or the mixed output's sound. The microphone
|
||||||
|
control owns voice state separately; do not hide facts by source category or
|
||||||
|
infer missing audio while capture details are unknown.
|
||||||
|
- Host absence: without a proved current picture, a stopped/unknown Host means
|
||||||
|
waiting for sharing, and an offline Host means waiting for return. A failed
|
||||||
|
route does not override those facts. Retain route evidence so reconnection
|
||||||
|
alone cannot claim media recovery; independent playback/server errors retain
|
||||||
|
their meaning. An authenticated snapshot with an absent Host does not identify
|
||||||
|
why the Host is absent: use neutral waiting. Only the explicit sharing-stop
|
||||||
|
event establishes an intentional stop; do not reconstruct it from a retained
|
||||||
|
share generation or remembered messages.
|
||||||
- Topology: unready edges use the blue pending treatment. Readiness alone cannot
|
- Topology: unready edges use the blue pending treatment. Readiness alone cannot
|
||||||
distinguish initial connection from recovery, so this view does not invent a
|
distinguish initial connection from recovery, so this view does not invent a
|
||||||
warning. Confirmed interruption remains owned by the status projection.
|
warning. Confirmed interruption remains owned by the status projection.
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ share generation:
|
|||||||
Native uses one media socket for IPv4 and, where available, IPv6
|
Native uses one media socket for IPv4 and, where available, IPv6
|
||||||
direct connections; its STUN survey and best-effort gateway mapping use IPv4.
|
direct connections; its STUN survey and best-effort gateway mapping use IPv4.
|
||||||
Availability and background direct acquisition share a bounded budget of
|
Availability and background direct acquisition share a bounded budget of
|
||||||
three actual connection attempts per eligible parent/session opportunity.
|
four actual connection attempts per eligible parent/session opportunity:
|
||||||
|
the initial attempt plus three retries. The first has no retry label; subsequent
|
||||||
|
attempts show retry 1/3, 2/3 and 3/3 on pages supporting that progress format.
|
||||||
Retries use the same serial controller and normal operation deadlines;
|
Retries use the same serial controller and normal operation deadlines;
|
||||||
waiting Viewers and untried parents receive their opportunities first.
|
waiting Viewers and untried parents receive their opportunities first.
|
||||||
It is not a participant capability, route score, or SFU preference. Candidate
|
It is not a participant capability, route score, or SFU preference. Candidate
|
||||||
|
|||||||
@@ -144,7 +144,10 @@ The microphone comic depicts Host commentary gain;
|
|||||||
the Viewer's listening-volume comic remains a different action.
|
the Viewer's listening-volume comic remains a different action.
|
||||||
|
|
||||||
Visual source choices (windows, screens and cameras) share named thumbnail cards,
|
Visual source choices (windows, screens and cameras) share named thumbnail cards,
|
||||||
including their focus, hover and refresh treatment. Microphone selection uses
|
including their focus, hover and refresh treatment. Thumbnails fit the whole frame
|
||||||
|
at its original aspect ratio within the card; constrain the media box itself so
|
||||||
|
portrait and wide sources cannot enlarge its layout or be cropped. Keep both
|
||||||
|
orientations represented in the source-picker preview. Microphone selection uses
|
||||||
device names in a compact selector. Shared enumeration does not make these two
|
device names in a compact selector. Shared enumeration does not make these two
|
||||||
selection tasks visually interchangeable.
|
selection tasks visually interchangeable.
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -1,6 +1,6 @@
|
|||||||
# Current Status
|
# Current Status
|
||||||
|
|
||||||
Last updated: 2026-09-21
|
Last updated: 2026-09-22
|
||||||
|
|
||||||
This is the compact execution/deployment index. Product modules own behavior,
|
This is the compact execution/deployment index. Product modules own behavior,
|
||||||
[verification status](./verification-status.md) owns unresolved physical limits,
|
[verification status](./verification-status.md) owns unresolved physical limits,
|
||||||
@@ -47,8 +47,10 @@ HTTPS; external LiveKit/coturn services remain disabled. Participants, routes an
|
|||||||
media remain process-only. This private service is not a public demonstration.
|
media remain process-only. This private service is not a public demonstration.
|
||||||
|
|
||||||
The separate US [public demo](https://demo.piik.tv) is deployed with open site
|
The separate US [public demo](https://demo.piik.tv) is deployed with open site
|
||||||
entry, persistent room authority and P2P-only media. Caddy owns HTTPS, and the
|
entry, persistent room authority and P2P-only media. Cloudflare fronts HTTP/WSS
|
||||||
standard systemd service runs the published Server package. Public DNS,
|
and caches static bundles; STUN uses a separate DNS-only hostname. Caddy owns
|
||||||
|
origin HTTPS, and the standard systemd service runs the published Server package.
|
||||||
|
Public DNS,
|
||||||
HTTPS, runtime assets, all three STUN listeners and a bounded browser P2P
|
HTTPS, runtime assets, all three STUN listeners and a bounded browser P2P
|
||||||
sharing/stop check passed; the browser check used synthetic video on one network.
|
sharing/stop check passed; the browser check used synthetic video on one network.
|
||||||
The operator record owns exact identity and postflight evidence.
|
The operator record owns exact identity and postflight evidence.
|
||||||
|
|||||||
+12
-8
@@ -1,6 +1,6 @@
|
|||||||
# Current TODO Ledger
|
# Current TODO Ledger
|
||||||
|
|
||||||
Last reviewed: 2026-09-22
|
Last reviewed: 2026-09-23
|
||||||
|
|
||||||
Only **Now** is executable. Product modules own behavior; Git/PRs own completed
|
Only **Now** is executable. Product modules own behavior; Git/PRs own completed
|
||||||
history. A parked idea is not implementation authority.
|
history. A parked idea is not implementation authority.
|
||||||
@@ -20,9 +20,9 @@ history. A parked idea is not implementation authority.
|
|||||||
- [ ] **Refine the isolated room-interaction prototype.** Review chat, optional
|
- [ ] **Refine the isolated room-interaction prototype.** Review chat, optional
|
||||||
danmaku and participant-targeted reactions for usability, placement and intended
|
danmaku and participant-targeted reactions for usability, placement and intended
|
||||||
cross-view synchronization. Preserve room authorization and media-route owners.
|
cross-view synchronization. Preserve room authorization and media-route owners.
|
||||||
Accept its combined layout: sharing settings with the picture, wide-screen chat
|
Accept its combined layout: sharing settings with the picture, floating chat
|
||||||
beside participants, stacked chat on narrow screens and room actions before
|
without page reflow and room actions before diagnostics. Check narrow-screen
|
||||||
diagnostics. Check its theater chat entry and participant menu on real devices;
|
keyboard avoidance, its theater entry and participant menu on real devices;
|
||||||
keep chat available independently of sharing.
|
keep chat available independently of sharing.
|
||||||
Retain current room creation: the first share creates a room, an existing room
|
Retain current room creation: the first share creates a room, an existing room
|
||||||
can resume, and stopping media keeps the opted-in interaction session. Do not
|
can resume, and stopping media keeps the opted-in interaction session. Do not
|
||||||
@@ -31,7 +31,9 @@ history. A parked idea is not implementation authority.
|
|||||||
|
|
||||||
## Awaiting Device Or Reporter Evidence
|
## Awaiting Device Or Reporter Evidence
|
||||||
|
|
||||||
- [ ] **No available media route.** Obtain matched Host/Viewer diagnostics from
|
- [ ] **No available media route.** A decline is reported since around v1.4;
|
||||||
|
establish comparable attempts on the same endpoints/network before attributing
|
||||||
|
a version regression. Obtain matched Host/Viewer diagnostics from
|
||||||
a failed attempt, with version and mode. Inspect candidate exchange, selected
|
a failed attempt, with version and mode. Inspect candidate exchange, selected
|
||||||
paths, first-frame admission and route rejection separately. The Browser
|
paths, first-frame admission and route rejection separately. The Browser
|
||||||
candidate-queue rejection defect is repaired and regression-tested; current
|
candidate-queue rejection defect is repaired and regression-tested; current
|
||||||
@@ -49,7 +51,8 @@ history. A parked idea is not implementation authority.
|
|||||||
[#396 timeout](https://github.com/TNTcraftHIM/Piik/issues/396#issuecomment-5691465700)
|
[#396 timeout](https://github.com/TNTcraftHIM/Piik/issues/396#issuecomment-5691465700)
|
||||||
with the updated App. A controlled UDP-blocked/TCP-available reproduction confirms
|
with the updated App. A controlled UDP-blocked/TCP-available reproduction confirms
|
||||||
a dependency configuration defect; the existing tunnel now uses cloudflared's
|
a dependency configuration defect; the existing tunnel now uses cloudflared's
|
||||||
bounded protocol fallback. [Runtime evidence](./research/cross-platform-client-runtime.md#public-invitation-startup)
|
bounded protocol fallback and pre-readiness retries within one startup deadline.
|
||||||
|
[Runtime evidence](./research/cross-platform-client-runtime.md#public-invitation-startup)
|
||||||
separates this fix from DNS, provider and remote access failures. The reporting
|
separates this fix from DNS, provider and remote access failures. The reporting
|
||||||
environments still need version and diagnostic evidence before assigning a cause.
|
environments still need version and diagnostic evidence before assigning a cause.
|
||||||
Demo access failures and public-link HTTP 1033 also remain unconfirmed: current
|
Demo access failures and public-link HTTP 1033 also remain unconfirmed: current
|
||||||
@@ -164,8 +167,9 @@ not establish better connection success or speed; this note adds no retry policy
|
|||||||
and retain Gitee; do not add a self-hosted mirror. Chrome still blocks the
|
and retain Gitee; do not add a self-hosted mirror. Chrome still blocks the
|
||||||
Gitee attachment when Referer is removed. Reopen for new evidence or a
|
Gitee attachment when Referer is removed. Reopen for new evidence or a
|
||||||
provider review; the warning remains unresolved.
|
provider review; the warning remains unresolved.
|
||||||
10. **4K in advanced sharing settings.** Deferred until after the current capture
|
10. **4K in advanced sharing settings.** Hold the isolated candidate for joint
|
||||||
maintenance phase. Add 3840x2160 without changing the default or recommended
|
review with the next room-interaction release; its compatibility and release
|
||||||
|
boundary are not yet accepted. Add 3840x2160 without changing the default or recommended
|
||||||
presets. Extend the existing capture, decode and relay bounds together; verify
|
presets. Extend the existing capture, decode and relay bounds together; verify
|
||||||
H.264 level negotiation for 4K at 60 fps. Current strict quality messages reject
|
H.264 level negotiation for 4K at 60 fps. Current strict quality messages reject
|
||||||
`2160p`, so preserve published Browser/App/Server compatibility through explicit
|
`2160p`, so preserve published Browser/App/Server compatibility through explicit
|
||||||
|
|||||||
@@ -24,15 +24,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
startupTimeout = 30 * time.Second
|
startupTimeout = 30 * time.Second
|
||||||
shutdownTimeout = 5 * time.Second
|
startupAttempts = 4
|
||||||
maxLogLineBytes = 64 * 1024
|
startupRetryDelay = time.Second
|
||||||
|
shutdownTimeout = 5 * time.Second
|
||||||
|
maxLogLineBytes = 64 * 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
var quickOriginPattern = regexp.MustCompile(
|
var quickOriginPattern = regexp.MustCompile(
|
||||||
`https://[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.trycloudflare\.com`,
|
`https://[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.trycloudflare\.com`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errStartupExited = errors.New("public invitation service exited before connecting; reopen Piik to try again")
|
||||||
|
|
||||||
type Process struct {
|
type Process struct {
|
||||||
origin string
|
origin string
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -72,6 +76,48 @@ func Start(
|
|||||||
if err := validateLocalOrigin(localOrigin); err != nil {
|
if err := validateLocalOrigin(localOrigin); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
startup, cancel := context.WithTimeout(parent, startupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
var err error
|
||||||
|
for attempt := 1; attempt <= startupAttempts; attempt++ {
|
||||||
|
var process *Process
|
||||||
|
process, err = startAttempt(startup, executable, localOrigin)
|
||||||
|
if err == nil {
|
||||||
|
return process, nil
|
||||||
|
}
|
||||||
|
if startup.Err() != nil || !errors.Is(err, errStartupExited) || attempt == startupAttempts {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Only a retired, pre-ready child is retried. Ready tunnel recovery
|
||||||
|
// remains cloudflared's job; all startup attempts share one deadline.
|
||||||
|
slog.Debug("public-tunnel", "event", "startup-retry", "attempt", attempt+1, "total", startupAttempts)
|
||||||
|
timer := time.NewTimer(startupRetryDelay)
|
||||||
|
select {
|
||||||
|
case <-startup.Done():
|
||||||
|
timer.Stop()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
if startup.Err() != nil {
|
||||||
|
// No child is live during the retry pause; stopping here is clean.
|
||||||
|
err = startup.Err()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if startup.Err() != nil {
|
||||||
|
if err != startup.Err() {
|
||||||
|
err = errors.Join(startup.Err(), err)
|
||||||
|
}
|
||||||
|
if parent.Err() == nil {
|
||||||
|
err = errors.Join(errors.New("public invitation service did not connect within 30 seconds; check your Internet connection and reopen Piik to try again"), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAttempt(parent context.Context, executable, localOrigin string) (*Process, error) {
|
||||||
|
if err := parent.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
configPath, err := writeTemporaryConfig()
|
configPath, err := writeTemporaryConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -167,8 +213,6 @@ func Start(
|
|||||||
close(process.done)
|
close(process.done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
timer := time.NewTimer(startupTimeout)
|
|
||||||
defer timer.Stop()
|
|
||||||
registered := false
|
registered := false
|
||||||
for process.origin == "" || !registered {
|
for process.origin == "" || !registered {
|
||||||
select {
|
select {
|
||||||
@@ -177,9 +221,7 @@ func Start(
|
|||||||
registered = true
|
registered = true
|
||||||
case <-process.done:
|
case <-process.done:
|
||||||
cancel()
|
cancel()
|
||||||
return nil, errors.Join(errors.New("public invitation service exited before connecting; reopen Piik to try again"), process.Err())
|
return nil, errors.Join(errStartupExited, process.Err())
|
||||||
case <-timer.C:
|
|
||||||
return nil, errors.Join(errors.New("public invitation service did not connect within 30 seconds; check your Internet connection and reopen Piik to try again"), process.Close())
|
|
||||||
case <-parent.Done():
|
case <-parent.Done():
|
||||||
if err := process.Close(); err != nil {
|
if err := process.Close(); err != nil {
|
||||||
return nil, errors.Join(parent.Err(), err)
|
return nil, errors.Join(parent.Err(), err)
|
||||||
@@ -187,6 +229,17 @@ func Start(
|
|||||||
return nil, parent.Err()
|
return nil, parent.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
select {
|
||||||
|
case <-parent.Done():
|
||||||
|
if err := process.Close(); err != nil {
|
||||||
|
return nil, errors.Join(parent.Err(), err)
|
||||||
|
}
|
||||||
|
return nil, parent.Err()
|
||||||
|
case <-process.done:
|
||||||
|
cancel()
|
||||||
|
return nil, errors.Join(errStartupExited, process.Err())
|
||||||
|
default:
|
||||||
|
}
|
||||||
return process, nil
|
return process, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -18,6 +19,38 @@ import (
|
|||||||
|
|
||||||
func TestMain(tests *testing.M) {
|
func TestMain(tests *testing.M) {
|
||||||
if mode := os.Getenv("PIIK_TUNNEL_FIXTURE"); mode != "" {
|
if mode := os.Getenv("PIIK_TUNNEL_FIXTURE"); mode != "" {
|
||||||
|
if record := os.Getenv("PIIK_TUNNEL_FIXTURE_ATTEMPTS"); record != "" {
|
||||||
|
previous, _ := os.ReadFile(record)
|
||||||
|
var paths []string
|
||||||
|
if len(previous) > 0 {
|
||||||
|
paths = strings.Split(strings.TrimSpace(string(previous)), "\n")
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(strings.TrimSpace(path)); !os.IsNotExist(err) {
|
||||||
|
fmt.Fprintln(os.Stderr, "previous attempt was not retired")
|
||||||
|
os.Exit(9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configIndex := slices.Index(os.Args, "--config")
|
||||||
|
if configIndex < 0 || configIndex+1 >= len(os.Args) {
|
||||||
|
os.Exit(9)
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(record, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(9)
|
||||||
|
}
|
||||||
|
_, writeErr := fmt.Fprintln(file, os.Args[configIndex+1])
|
||||||
|
closeErr := file.Close()
|
||||||
|
if writeErr != nil || closeErr != nil {
|
||||||
|
os.Exit(9)
|
||||||
|
}
|
||||||
|
failures, _ := strconv.Atoi(os.Getenv("PIIK_TUNNEL_FIXTURE_FAILURES"))
|
||||||
|
if len(paths) < failures {
|
||||||
|
fmt.Fprintln(os.Stderr, "fixture allocation unavailable")
|
||||||
|
code, _ := strconv.Atoi(os.Getenv("PIIK_TUNNEL_FIXTURE_EXIT_CODE"))
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
if mode == "tcp-only" {
|
if mode == "tcp-only" {
|
||||||
// Model the pinned Quick Tunnel's opt-in protocol fallback. Without
|
// Model the pinned Quick Tunnel's opt-in protocol fallback. Without
|
||||||
// both flags, an unavailable UDP edge consumes Piik's startup budget.
|
// both flags, an unavailable UDP edge consumes Piik's startup budget.
|
||||||
@@ -49,6 +82,109 @@ func TestMain(tests *testing.M) {
|
|||||||
os.Exit(tests.Run())
|
os.Exit(tests.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartupRetriesOnlyRetiredPreReadyChildren(t *testing.T) {
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, sample := range []struct {
|
||||||
|
name string
|
||||||
|
failures, exitCode int
|
||||||
|
ready bool
|
||||||
|
}{
|
||||||
|
{"fourth attempt succeeds", 3, 7, true},
|
||||||
|
{"four failures stop", 9, 7, false},
|
||||||
|
{"clean exit is not readiness", 9, 0, false},
|
||||||
|
} {
|
||||||
|
t.Run(sample.name, func(t *testing.T) {
|
||||||
|
record := filepath.Join(t.TempDir(), "attempts")
|
||||||
|
t.Setenv("PIIK_TUNNEL_FIXTURE", "ready")
|
||||||
|
t.Setenv("PIIK_TUNNEL_FIXTURE_ATTEMPTS", record)
|
||||||
|
t.Setenv("PIIK_TUNNEL_FIXTURE_FAILURES", strconv.Itoa(sample.failures))
|
||||||
|
t.Setenv("PIIK_TUNNEL_FIXTURE_EXIT_CODE", strconv.Itoa(sample.exitCode))
|
||||||
|
process, err := Start(t.Context(), executable, "http://127.0.0.1:8787")
|
||||||
|
if sample.ready {
|
||||||
|
if err != nil || process == nil {
|
||||||
|
t.Fatalf("fourth attempt: %v, %v", process, err)
|
||||||
|
}
|
||||||
|
if err := process.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if process != nil || !errors.Is(err, errStartupExited) {
|
||||||
|
t.Fatalf("exhausted startup: %v, %v", process, err)
|
||||||
|
}
|
||||||
|
if sample.exitCode != 0 {
|
||||||
|
var failure *exec.ExitError
|
||||||
|
if !errors.As(err, &failure) || failure.ExitCode() != sample.exitCode {
|
||||||
|
t.Fatalf("latest dependency exit was lost: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertRetiredAttempts(t, record, 4)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupRetriesShareCancellationAndDeadline(t *testing.T) {
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, sample := range []struct {
|
||||||
|
name string
|
||||||
|
timeout time.Duration
|
||||||
|
attempts int
|
||||||
|
cancelOnly bool
|
||||||
|
}{
|
||||||
|
{"during retry delay", 500 * time.Millisecond, 1, false},
|
||||||
|
{"during second child", 2 * time.Second, 2, false},
|
||||||
|
{"clean cancellation during retry delay", 500 * time.Millisecond, 1, true},
|
||||||
|
} {
|
||||||
|
t.Run(sample.name, func(t *testing.T) {
|
||||||
|
record := filepath.Join(t.TempDir(), "attempts")
|
||||||
|
t.Setenv("PIIK_TUNNEL_FIXTURE", "starting")
|
||||||
|
t.Setenv("PIIK_TUNNEL_FIXTURE_ATTEMPTS", record)
|
||||||
|
t.Setenv("PIIK_TUNNEL_FIXTURE_FAILURES", "1")
|
||||||
|
t.Setenv("PIIK_TUNNEL_FIXTURE_EXIT_CODE", "7")
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), sample.timeout)
|
||||||
|
defer cancel()
|
||||||
|
if sample.cancelOnly {
|
||||||
|
ctx, cancel = context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
defer time.AfterFunc(sample.timeout, cancel).Stop()
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
process, err := Start(ctx, executable, "http://127.0.0.1:8787")
|
||||||
|
if process != nil || (!sample.cancelOnly && !errors.Is(err, context.DeadlineExceeded)) ||
|
||||||
|
(sample.cancelOnly && err != context.Canceled) {
|
||||||
|
t.Fatalf("startup deadline: %v, %v", process, err)
|
||||||
|
}
|
||||||
|
if time.Since(started) > sample.timeout+2*time.Second {
|
||||||
|
t.Fatal("retry renewed the startup deadline")
|
||||||
|
}
|
||||||
|
assertRetiredAttempts(t, record, sample.attempts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRetiredAttempts(t *testing.T, record string, count int) {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := os.ReadFile(record)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
paths := strings.Split(strings.TrimSpace(string(payload)), "\n")
|
||||||
|
if len(paths) != count {
|
||||||
|
t.Fatalf("started %d children, want %d", len(paths), count)
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(strings.TrimSpace(path)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("attempt config was not removed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStartupEnablesCloudflaredProtocolFallback(t *testing.T) {
|
func TestStartupEnablesCloudflaredProtocolFallback(t *testing.T) {
|
||||||
executable, err := os.Executable()
|
executable, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ func TestCapabilitiesReportServicesDisabledByDefault(t *testing.T) {
|
|||||||
server := start(t, Options{Config: testConfig(t)})
|
server := start(t, Options{Config: testConfig(t)})
|
||||||
|
|
||||||
server.do(http.MethodGet, "/api/capabilities").
|
server.do(http.MethodGet, "/api/capabilities").
|
||||||
expect(http.StatusOK, `{"sfu":false,"natPrediction":false}`)
|
expect(http.StatusOK, `{"connectionAttemptProgress4":true,"sfu":false,"natPrediction":false}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCapabilitiesReportOptionalNATPredictionWithoutExposingConfiguration(t *testing.T) {
|
func TestCapabilitiesReportOptionalNATPredictionWithoutExposingConfiguration(t *testing.T) {
|
||||||
@@ -363,7 +363,7 @@ func TestCapabilitiesReportOptionalNATPredictionWithoutExposingConfiguration(t *
|
|||||||
server := start(t, Options{Config: configuration})
|
server := start(t, Options{Config: configuration})
|
||||||
|
|
||||||
server.do(http.MethodGet, "/api/capabilities").
|
server.do(http.MethodGet, "/api/capabilities").
|
||||||
expect(http.StatusOK, `{"sfu":false,"natPrediction":true}`).
|
expect(http.StatusOK, `{"connectionAttemptProgress4":true,"sfu":false,"natPrediction":true}`).
|
||||||
expectHeader("Cache-Control", "no-store").
|
expectHeader("Cache-Control", "no-store").
|
||||||
expectHeader("X-Content-Type-Options", "nosniff")
|
expectHeader("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
@@ -406,7 +406,7 @@ func TestSFUListenerFollowsApplicationCloseAndEnd(t *testing.T) {
|
|||||||
t.Fatal("missing runtime owner")
|
t.Fatal("missing runtime owner")
|
||||||
}
|
}
|
||||||
server.do(http.MethodGet, "/api/capabilities").
|
server.do(http.MethodGet, "/api/capabilities").
|
||||||
expect(http.StatusOK, `{"sfu":true,"natPrediction":false}`)
|
expect(http.StatusOK, `{"connectionAttemptProgress4":true,"sfu":true,"natPrediction":false}`)
|
||||||
var err error
|
var err error
|
||||||
if ending {
|
if ending {
|
||||||
err = server.End(context.Background())
|
err = server.End(context.Background())
|
||||||
|
|||||||
@@ -76,8 +76,9 @@ func (s *Server) route(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendJSON(writer, http.StatusOK, protocol.RuntimeCapabilities{
|
sendJSON(writer, http.StatusOK, protocol.RuntimeCapabilities{
|
||||||
Sfu: s.config.SFU != nil,
|
ConnectionAttemptProgress4: true,
|
||||||
NatPrediction: s.config.NATPredictionEnabled,
|
Sfu: s.config.SFU != nil,
|
||||||
|
NatPrediction: s.config.NATPredictionEnabled,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,31 +13,33 @@ type ClientMessage interface {
|
|||||||
|
|
||||||
// AuthenticateHostMessage is the host member of authenticateMessageSchema.
|
// AuthenticateHostMessage is the host member of authenticateMessageSchema.
|
||||||
type AuthenticateHostMessage struct {
|
type AuthenticateHostMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
RoomID string `json:"roomId"`
|
RoomID string `json:"roomId"`
|
||||||
Role Role `json:"role"`
|
Role Role `json:"role"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
ClientID string `json:"clientId"`
|
ClientID string `json:"clientId"`
|
||||||
ShareGeneration string `json:"shareGeneration,omitempty"`
|
ShareGeneration string `json:"shareGeneration,omitempty"`
|
||||||
SharingPaused *bool `json:"sharingPaused,omitempty"`
|
SharingPaused *bool `json:"sharingPaused,omitempty"`
|
||||||
QualitySettings *QualitySettings `json:"qualitySettings,omitempty"`
|
QualitySettings *QualitySettings `json:"qualitySettings,omitempty"`
|
||||||
RoutePolicy RoutePolicy `json:"routePolicy"`
|
RoutePolicy RoutePolicy `json:"routePolicy"`
|
||||||
ViewerPresence bool `json:"viewerPresence,omitempty"`
|
ViewerPresence bool `json:"viewerPresence,omitempty"`
|
||||||
DisplayName *DisplayName `json:"displayName,omitempty"`
|
ConnectionAttemptProgress4 bool `json:"connectionAttemptProgress4,omitempty"`
|
||||||
|
DisplayName *DisplayName `json:"displayName,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthenticateViewerMessage is the viewer member of authenticateMessageSchema.
|
// AuthenticateViewerMessage is the viewer member of authenticateMessageSchema.
|
||||||
type AuthenticateViewerMessage struct {
|
type AuthenticateViewerMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
RoomID string `json:"roomId"`
|
RoomID string `json:"roomId"`
|
||||||
Role Role `json:"role"`
|
Role Role `json:"role"`
|
||||||
ClientID string `json:"clientId"`
|
ClientID string `json:"clientId"`
|
||||||
ViewerGrant string `json:"viewerGrant,omitempty"`
|
ViewerGrant string `json:"viewerGrant,omitempty"`
|
||||||
ViewerPassword string `json:"viewerPassword,omitempty"`
|
ViewerPassword string `json:"viewerPassword,omitempty"`
|
||||||
DisplayName *DisplayName `json:"displayName,omitempty"`
|
DisplayName *DisplayName `json:"displayName,omitempty"`
|
||||||
ViewerPresence bool `json:"viewerPresence,omitempty"`
|
ViewerPresence bool `json:"viewerPresence,omitempty"`
|
||||||
|
ConnectionAttemptProgress4 bool `json:"connectionAttemptProgress4,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SignalingChallengeMessage is { type: "signaling-challenge", sequence }.
|
// SignalingChallengeMessage is { type: "signaling-challenge", sequence }.
|
||||||
@@ -281,7 +283,7 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := present.optional("shareGeneration", "sharingPaused", "qualitySettings",
|
if err := present.optional("shareGeneration", "sharingPaused", "qualitySettings",
|
||||||
"routePolicy", "viewerPresence", "displayName"); err != nil {
|
"routePolicy", "viewerPresence", "displayName", "connectionAttemptProgress4"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if message.Protocol != SignalingProtocol {
|
if message.Protocol != SignalingProtocol {
|
||||||
@@ -302,6 +304,9 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
|
|||||||
if present.has("viewerPresence") && !message.ViewerPresence {
|
if present.has("viewerPresence") && !message.ViewerPresence {
|
||||||
return nil, errors.New("viewerPresence must be true when present")
|
return nil, errors.New("viewerPresence must be true when present")
|
||||||
}
|
}
|
||||||
|
if present.has("connectionAttemptProgress4") && !message.ConnectionAttemptProgress4 {
|
||||||
|
return nil, errors.New("connectionAttemptProgress4 must be true when present")
|
||||||
|
}
|
||||||
if !present.has("routePolicy") {
|
if !present.has("routePolicy") {
|
||||||
message.RoutePolicy = DefaultRoutePolicy
|
message.RoutePolicy = DefaultRoutePolicy
|
||||||
}
|
}
|
||||||
@@ -316,7 +321,7 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := present.optional(
|
if err := present.optional(
|
||||||
"viewerGrant", "viewerPassword", "displayName", "viewerPresence"); err != nil {
|
"viewerGrant", "viewerPassword", "displayName", "viewerPresence", "connectionAttemptProgress4"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if message.Protocol != SignalingProtocol {
|
if message.Protocol != SignalingProtocol {
|
||||||
@@ -337,6 +342,9 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
|
|||||||
if present.has("viewerPresence") && !message.ViewerPresence {
|
if present.has("viewerPresence") && !message.ViewerPresence {
|
||||||
return nil, errors.New("viewerPresence must be true when present")
|
return nil, errors.New("viewerPresence must be true when present")
|
||||||
}
|
}
|
||||||
|
if present.has("connectionAttemptProgress4") && !message.ConnectionAttemptProgress4 {
|
||||||
|
return nil, errors.New("connectionAttemptProgress4 must be true when present")
|
||||||
|
}
|
||||||
return message, nil
|
return message, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("unknown authenticate role %q", role)
|
return nil, fmt.Errorf("unknown authenticate role %q", role)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func TestRuntimeCapabilities(t *testing.T) {
|
|||||||
want RuntimeCapabilities
|
want RuntimeCapabilities
|
||||||
}{
|
}{
|
||||||
{`{}`, RuntimeCapabilities{}},
|
{`{}`, RuntimeCapabilities{}},
|
||||||
|
{`{"connectionAttemptProgress4":true}`, RuntimeCapabilities{ConnectionAttemptProgress4: true}},
|
||||||
{`{"sfu":true,"extra":{"enabled":true}}`, RuntimeCapabilities{Sfu: true}},
|
{`{"sfu":true,"extra":{"enabled":true}}`, RuntimeCapabilities{Sfu: true}},
|
||||||
{`{"natPrediction":true,"SFU":true}`, RuntimeCapabilities{NatPrediction: true}},
|
{`{"natPrediction":true,"SFU":true}`, RuntimeCapabilities{NatPrediction: true}},
|
||||||
{`{"sfu":true,"natPrediction":true}`, RuntimeCapabilities{Sfu: true, NatPrediction: true}},
|
{`{"sfu":true,"natPrediction":true}`, RuntimeCapabilities{Sfu: true, NatPrediction: true}},
|
||||||
@@ -27,6 +28,7 @@ func TestRuntimeCapabilities(t *testing.T) {
|
|||||||
for _, data := range []string{
|
for _, data := range []string{
|
||||||
`null`, `[]`, `true`, `{"sfu":null}`, `{"sfu":"true"}`,
|
`null`, `[]`, `true`, `{"sfu":null}`, `{"sfu":"true"}`,
|
||||||
`{"natPrediction":null}`, `{"natPrediction":1}`,
|
`{"natPrediction":null}`, `{"natPrediction":1}`,
|
||||||
|
`{"connectionAttemptProgress4":null}`, `{"connectionAttemptProgress4":4}`,
|
||||||
} {
|
} {
|
||||||
var got RuntimeCapabilities
|
var got RuntimeCapabilities
|
||||||
if err := json.Unmarshal([]byte(data), &got); err == nil {
|
if err := json.Unmarshal([]byte(data), &got); err == nil {
|
||||||
@@ -473,7 +475,10 @@ func TestPreparedRouteCandidate(t *testing.T) {
|
|||||||
{`{"current":3,"total":3}`, true},
|
{`{"current":3,"total":3}`, true},
|
||||||
{`{"current":0,"total":3}`, false},
|
{`{"current":0,"total":3}`, false},
|
||||||
{`{"current":4,"total":3}`, false},
|
{`{"current":4,"total":3}`, false},
|
||||||
{`{"current":1,"total":4}`, false},
|
{`{"current":1,"total":4}`, true},
|
||||||
|
{`{"current":4,"total":4}`, true},
|
||||||
|
{`{"current":5,"total":4}`, false},
|
||||||
|
{`{"current":1,"total":5}`, false},
|
||||||
{`{"current":1.5,"total":3}`, false},
|
{`{"current":1.5,"total":3}`, false},
|
||||||
} {
|
} {
|
||||||
assertServerMessage(t, "connectionAttempt "+sample.attempt,
|
assertServerMessage(t, "connectionAttempt "+sample.attempt,
|
||||||
@@ -664,6 +669,19 @@ func TestOptionalAndNullableAreExact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A host authenticate without routePolicy takes DEFAULT_ROUTE_POLICY.
|
// A host authenticate without routePolicy takes DEFAULT_ROUTE_POLICY.
|
||||||
|
func TestAuthenticateConnectionProgressOptIn(t *testing.T) {
|
||||||
|
for _, role := range []string{"host", "viewer"} {
|
||||||
|
base := `"type":"authenticate","protocol":"` + SignalingProtocol + `","roomId":"1234","clientId":"client_12345678","role":"` + role + `"`
|
||||||
|
if role == "host" {
|
||||||
|
base += `,"token":"` + repeat("a", 43) + `"`
|
||||||
|
}
|
||||||
|
for _, value := range []string{"true", "false", "null", "4", `"true"`} {
|
||||||
|
assertClientMessage(t, role+value, []byte(`{`+base+`,"connectionAttemptProgress4":`+value+`}`), value == "true")
|
||||||
|
}
|
||||||
|
assertClientMessage(t, role+" baseline", []byte(`{`+base+`}`), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthenticateAppliesTheRoutePolicyDefault(t *testing.T) {
|
func TestAuthenticateAppliesTheRoutePolicyDefault(t *testing.T) {
|
||||||
message, err := DecodeClientMessage([]byte(
|
message, err := DecodeClientMessage([]byte(
|
||||||
`{"type":"authenticate","protocol":"piik-v23","roomId":"1234","role":"host",` +
|
`{"type":"authenticate","protocol":"piik-v23","roomId":"1234","role":"host",` +
|
||||||
|
|||||||
@@ -168,8 +168,9 @@ func (v *RoutePolicy) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
// RuntimeCapabilities mirrors runtimeCapabilitiesSchema.
|
// RuntimeCapabilities mirrors runtimeCapabilitiesSchema.
|
||||||
type RuntimeCapabilities struct {
|
type RuntimeCapabilities struct {
|
||||||
Sfu bool `json:"sfu"`
|
ConnectionAttemptProgress4 bool `json:"connectionAttemptProgress4,omitempty"`
|
||||||
NatPrediction bool `json:"natPrediction"`
|
Sfu bool `json:"sfu"`
|
||||||
|
NatPrediction bool `json:"natPrediction"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalJSON implements json.Unmarshaler.
|
// UnmarshalJSON implements json.Unmarshaler.
|
||||||
@@ -182,11 +183,12 @@ func (v *RuntimeCapabilities) UnmarshalJSON(data []byte) error {
|
|||||||
if present == nil {
|
if present == nil {
|
||||||
return errors.New("runtime capabilities must be an object")
|
return errors.New("runtime capabilities must be an object")
|
||||||
}
|
}
|
||||||
if err := present.optional("sfu", "natPrediction"); err != nil {
|
if err := present.optional("sfu", "natPrediction", "connectionAttemptProgress4"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for key, target := range map[string]*bool{
|
for key, target := range map[string]*bool{
|
||||||
"sfu": &v.Sfu, "natPrediction": &v.NatPrediction,
|
"sfu": &v.Sfu, "natPrediction": &v.NatPrediction,
|
||||||
|
"connectionAttemptProgress4": &v.ConnectionAttemptProgress4,
|
||||||
} {
|
} {
|
||||||
if value, ok := present[key]; ok {
|
if value, ok := present[key]; ok {
|
||||||
if err := json.Unmarshal(value, target); err != nil {
|
if err := json.Unmarshal(value, target); err != nil {
|
||||||
@@ -482,11 +484,11 @@ func (v *ConnectionAttempt) UnmarshalJSON(data []byte) error {
|
|||||||
if err := present.require("current", "total"); err != nil {
|
if err := present.require("current", "total"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !inRangeInt(v.Current, 1, 3) {
|
if !inRangeInt(v.Current, 1, int64(v.Total)) {
|
||||||
return errors.New("connectionAttempt.current is out of range")
|
return errors.New("connectionAttempt.current is out of range")
|
||||||
}
|
}
|
||||||
if v.Total != 3 {
|
if v.Total != 3 && v.Total != 4 {
|
||||||
return errors.New("connectionAttempt.total must be 3")
|
return errors.New("connectionAttempt.total must be 3 or 4")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ type HostPublication struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConnectionAttemptProgress is the NAT-prediction attempt counter
|
// ConnectionAttemptProgress is the NAT-prediction attempt counter
|
||||||
// (Total is always 3). TS: ConnectionAttemptProgress.
|
// (initial attempt plus retries; the controller owns Total).
|
||||||
type ConnectionAttemptProgress struct {
|
type ConnectionAttemptProgress struct {
|
||||||
Current int
|
Current int
|
||||||
Total int
|
Total int
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
maxDirectHeadStartMs int64 = 5_000
|
maxDirectHeadStartMs int64 = 5_000
|
||||||
maxNatConnectionAttempts = 3
|
maxNatConnectionAttempts = 4
|
||||||
sfuOpportunityPrefix = "sfu\x00"
|
sfuOpportunityPrefix = "sfu\x00"
|
||||||
maxSafeInteger int64 = protocol.MaxSafeInteger
|
maxSafeInteger int64 = protocol.MaxSafeInteger
|
||||||
qualityEvidenceExpiryMs int64 = protocol.ViewerQualityEvidenceExpiryMs
|
qualityEvidenceExpiryMs int64 = protocol.ViewerQualityEvidenceExpiryMs
|
||||||
|
|||||||
@@ -2802,17 +2802,16 @@ func TestDoesNotPromoteWhenSynchronousAdmissionCommitFails(t *testing.T) {
|
|||||||
|
|
||||||
// --- describe("bounded NAT candidate acquisition") -------------------------
|
// --- describe("bounded NAT candidate acquisition") -------------------------
|
||||||
|
|
||||||
// TS 5033: runs three real generations on %s, then exhausts until a new session
|
func TestRunsFourRealGenerationsThenExhaustsUntilNewSession(t *testing.T) {
|
||||||
func TestRunsThreeRealGenerationsThenExhaustsUntilNewSession(t *testing.T) {
|
|
||||||
for _, failure := range []string{"failed", "timeout"} {
|
for _, failure := range []string{"failed", "timeout"} {
|
||||||
t.Run(failure, func(t *testing.T) {
|
t.Run(failure, func(t *testing.T) {
|
||||||
routes := newController(2, Options{NatPredictionEnabled: true, OperationTimeoutMs: 20_000})
|
routes := newController(2, Options{NatPredictionEnabled: true, OperationTimeoutMs: 20_000})
|
||||||
addViewer(routes, A, 0, nil)
|
addViewer(routes, A, 0, nil)
|
||||||
nowMs := int64(0)
|
nowMs := int64(0)
|
||||||
var previous *CandidateGuard
|
var previous *CandidateGuard
|
||||||
for attempt := 1; attempt <= 3; attempt++ {
|
for attempt := 1; attempt <= 4; attempt++ {
|
||||||
operation, prepared, guard := preparePeer(t, routes, nowMs, fmt.Sprintf("nat_%d", attempt))
|
operation, prepared, guard := preparePeer(t, routes, nowMs, fmt.Sprintf("nat_%d", attempt))
|
||||||
eqDeep(t, prepared.Current.ConnectionAttempt, &ConnectionAttemptProgress{Current: attempt, Total: 3})
|
eqDeep(t, prepared.Current.ConnectionAttempt, &ConnectionAttemptProgress{Current: attempt, Total: 4})
|
||||||
eq(t, operation.DeadlineAtMs-nowMs, 20_000)
|
eq(t, operation.DeadlineAtMs-nowMs, 20_000)
|
||||||
eq(t, prepared.WakeAtMs, operation.DeadlineAtMs)
|
eq(t, prepared.WakeAtMs, operation.DeadlineAtMs)
|
||||||
if previous != nil {
|
if previous != nil {
|
||||||
@@ -2827,7 +2826,7 @@ func TestRunsThreeRealGenerationsThenExhaustsUntilNewSession(t *testing.T) {
|
|||||||
nowMs++
|
nowMs++
|
||||||
result = routes.CandidateFailed(guard, nowMs)
|
result = routes.CandidateFailed(guard, nowMs)
|
||||||
}
|
}
|
||||||
if attempt == 3 {
|
if attempt == 4 {
|
||||||
eqSlice(t, result.FailedPeerIDs, []string{A})
|
eqSlice(t, result.FailedPeerIDs, []string{A})
|
||||||
} else {
|
} else {
|
||||||
eqSlice(t, result.FailedPeerIDs, []string{})
|
eqSlice(t, result.FailedPeerIDs, []string{})
|
||||||
@@ -2850,7 +2849,7 @@ func TestGivesWaitingViewersFirstOpportunityBeforeAnotherViewerRetries(t *testin
|
|||||||
routes := newController(2, Options{NatPredictionEnabled: true})
|
routes := newController(2, Options{NatPredictionEnabled: true})
|
||||||
addViewer(routes, A, 0, nil)
|
addViewer(routes, A, 0, nil)
|
||||||
addViewer(routes, B, 0, nil)
|
addViewer(routes, B, 0, nil)
|
||||||
for index, childPeerID := range []string{A, B, A, B, A, B} {
|
for index, childPeerID := range []string{A, B, A, B, A, B, A, B} {
|
||||||
_, prepared, guard := preparePeer(t, routes, int64(index*2), fmt.Sprintf("fair_%d", index))
|
_, prepared, guard := preparePeer(t, routes, int64(index*2), fmt.Sprintf("fair_%d", index))
|
||||||
eq(t, prepared.ChildPeerID, childPeerID)
|
eq(t, prepared.ChildPeerID, childPeerID)
|
||||||
eq(t, attemptCurrent(t, prepared), index/2+1)
|
eq(t, attemptCurrent(t, prepared), index/2+1)
|
||||||
@@ -2911,11 +2910,11 @@ func TestKeepsSfuForegroundTimingAndSharesAttemptBudgetWithDirectContinuations(t
|
|||||||
}, nowMs, nil, CandidateProof{})
|
}, nowMs, nil, CandidateProof{})
|
||||||
nowMs++
|
nowMs++
|
||||||
}
|
}
|
||||||
for index, childPeerID := range []string{A, B, A, B} {
|
for index, childPeerID := range []string{A, B, A, B, A, B} {
|
||||||
_, retry, guard := preparePeer(t, routes, nowMs, fmt.Sprintf("background_%d", index))
|
_, retry, guard := preparePeer(t, routes, nowMs, fmt.Sprintf("background_%d", index))
|
||||||
eq(t, retry.ChildPeerID, childPeerID)
|
eq(t, retry.ChildPeerID, childPeerID)
|
||||||
eq(t, retry.Reason, DemandDirectConvergence)
|
eq(t, retry.Reason, DemandDirectConvergence)
|
||||||
eqDeep(t, retry.Current.ConnectionAttempt, &ConnectionAttemptProgress{Current: index/2 + 2, Total: 3})
|
eqDeep(t, retry.Current.ConnectionAttempt, &ConnectionAttemptProgress{Current: index/2 + 2, Total: 4})
|
||||||
eq(t, retry.WakeAtMs-nowMs, 20_000)
|
eq(t, retry.WakeAtMs-nowMs, 20_000)
|
||||||
nowMs++
|
nowMs++
|
||||||
eqSlice(t, routes.CandidateFailed(guard, nowMs).FailedPeerIDs, []string{})
|
eqSlice(t, routes.CandidateFailed(guard, nowMs).FailedPeerIDs, []string{})
|
||||||
|
|||||||
@@ -1305,10 +1305,10 @@ func TestRouterCreatesNatRetriesThroughRealPrepareMessages(t *testing.T) {
|
|||||||
})
|
})
|
||||||
connectionIDs := map[string]struct{}{}
|
connectionIDs := map[string]struct{}{}
|
||||||
var staleFailure *protocol.RouteFailedMessage
|
var staleFailure *protocol.RouteFailedMessage
|
||||||
for current := 1; current <= 3; current++ {
|
for current := 1; current <= 4; current++ {
|
||||||
prepared := h.waitPreparedMatching(viewer.sessionID, "connection attempt", func(prepared protocol.RouteUpdatePrepareMessage) bool {
|
prepared := h.waitPreparedMatching(viewer.sessionID, "connection attempt", func(prepared protocol.RouteUpdatePrepareMessage) bool {
|
||||||
return prepared.Candidate.ConnectionAttempt != nil &&
|
return prepared.Candidate.ConnectionAttempt != nil &&
|
||||||
*prepared.Candidate.ConnectionAttempt == protocol.ConnectionAttempt{Current: protocol.Int(current), Total: 3}
|
*prepared.Candidate.ConnectionAttempt == protocol.ConnectionAttempt{Current: protocol.Int(current), Total: 4}
|
||||||
})
|
})
|
||||||
connectionIDs[prepared.Candidate.ConnectionID] = struct{}{}
|
connectionIDs[prepared.Candidate.ConnectionID] = struct{}{}
|
||||||
h.locked(func() {
|
h.locked(func() {
|
||||||
@@ -1321,7 +1321,7 @@ func TestRouterCreatesNatRetriesThroughRealPrepareMessages(t *testing.T) {
|
|||||||
if again, _ := lastPrepare(h.sent[viewer.sessionID], ""); again.Candidate.ConnectionID != prepared.Candidate.ConnectionID {
|
if again, _ := lastPrepare(h.sent[viewer.sessionID], ""); again.Candidate.ConnectionID != prepared.Candidate.ConnectionID {
|
||||||
t.Fatal("a stale failure must not disturb the current candidate")
|
t.Fatal("a stale failure must not disturb the current candidate")
|
||||||
}
|
}
|
||||||
if current == 3 {
|
if current == 4 {
|
||||||
h.doReady(viewer, int64(prepared.Revision))
|
h.doReady(viewer, int64(prepared.Revision))
|
||||||
if active := h.doMustEdge(created.RoomID, viewer.peerID); active.connectionID != prepared.Candidate.ConnectionID {
|
if active := h.doMustEdge(created.RoomID, viewer.peerID); active.connectionID != prepared.Candidate.ConnectionID {
|
||||||
t.Fatalf("active edge = %+v", active)
|
t.Fatalf("active edge = %+v", active)
|
||||||
@@ -1334,8 +1334,8 @@ func TestRouterCreatesNatRetriesThroughRealPrepareMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if len(connectionIDs) != 3 {
|
if len(connectionIDs) != 4 {
|
||||||
t.Fatalf("distinct connection ids = %d, want 3", len(connectionIDs))
|
t.Fatalf("distinct connection ids = %d, want 4", len(connectionIDs))
|
||||||
}
|
}
|
||||||
for _, message := range h.allMessages() {
|
for _, message := range h.allMessages() {
|
||||||
switch typed := message.(type) {
|
switch typed := message.(type) {
|
||||||
|
|||||||
@@ -698,44 +698,47 @@ func (s *Server) handleMessage(sess *session, encoded []byte) {
|
|||||||
// fields authenticate() and authenticationErrorCode() read. Empty strings
|
// fields authenticate() and authenticationErrorCode() read. Empty strings
|
||||||
// and nil pointers are absent keys.
|
// and nil pointers are absent keys.
|
||||||
type authRequest struct {
|
type authRequest struct {
|
||||||
role protocol.Role
|
role protocol.Role
|
||||||
roomID string
|
roomID string
|
||||||
clientID string
|
clientID string
|
||||||
token string
|
token string
|
||||||
viewerGrant string
|
viewerGrant string
|
||||||
viewerPassword string
|
viewerPassword string
|
||||||
shareGeneration string
|
shareGeneration string
|
||||||
sharingPaused bool
|
sharingPaused bool
|
||||||
qualitySettings *protocol.QualitySettings
|
qualitySettings *protocol.QualitySettings
|
||||||
routePolicy protocol.RoutePolicy
|
routePolicy protocol.RoutePolicy
|
||||||
viewerPresence bool
|
viewerPresence bool
|
||||||
displayName *string
|
connectionAttemptProgress4 bool
|
||||||
|
displayName *string
|
||||||
}
|
}
|
||||||
|
|
||||||
func authRequestOf(message protocol.ClientMessage) (authRequest, bool) {
|
func authRequestOf(message protocol.ClientMessage) (authRequest, bool) {
|
||||||
switch m := message.(type) {
|
switch m := message.(type) {
|
||||||
case protocol.AuthenticateHostMessage:
|
case protocol.AuthenticateHostMessage:
|
||||||
return authRequest{
|
return authRequest{
|
||||||
role: protocol.RoleHost,
|
role: protocol.RoleHost,
|
||||||
roomID: m.RoomID,
|
roomID: m.RoomID,
|
||||||
clientID: m.ClientID,
|
clientID: m.ClientID,
|
||||||
token: m.Token,
|
token: m.Token,
|
||||||
shareGeneration: m.ShareGeneration,
|
shareGeneration: m.ShareGeneration,
|
||||||
sharingPaused: m.SharingPaused != nil && *m.SharingPaused,
|
sharingPaused: m.SharingPaused != nil && *m.SharingPaused,
|
||||||
qualitySettings: m.QualitySettings,
|
qualitySettings: m.QualitySettings,
|
||||||
routePolicy: m.RoutePolicy,
|
routePolicy: m.RoutePolicy,
|
||||||
viewerPresence: m.ViewerPresence,
|
viewerPresence: m.ViewerPresence,
|
||||||
displayName: displayNameString(m.DisplayName),
|
connectionAttemptProgress4: m.ConnectionAttemptProgress4,
|
||||||
|
displayName: displayNameString(m.DisplayName),
|
||||||
}, true
|
}, true
|
||||||
case protocol.AuthenticateViewerMessage:
|
case protocol.AuthenticateViewerMessage:
|
||||||
return authRequest{
|
return authRequest{
|
||||||
role: protocol.RoleViewer,
|
role: protocol.RoleViewer,
|
||||||
roomID: m.RoomID,
|
roomID: m.RoomID,
|
||||||
clientID: m.ClientID,
|
clientID: m.ClientID,
|
||||||
viewerGrant: m.ViewerGrant,
|
viewerGrant: m.ViewerGrant,
|
||||||
viewerPassword: m.ViewerPassword,
|
viewerPassword: m.ViewerPassword,
|
||||||
viewerPresence: m.ViewerPresence,
|
viewerPresence: m.ViewerPresence,
|
||||||
displayName: displayNameString(m.DisplayName),
|
connectionAttemptProgress4: m.ConnectionAttemptProgress4,
|
||||||
|
displayName: displayNameString(m.DisplayName),
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
return authRequest{}, false
|
return authRequest{}, false
|
||||||
@@ -877,12 +880,13 @@ func (s *Server) completeAuthentication(sess *session, request authRequest, part
|
|||||||
}
|
}
|
||||||
displayName := authenticatedDisplayName(request)
|
displayName := authenticatedDisplayName(request)
|
||||||
authenticated := &authenticatedSession{
|
authenticated := &authenticatedSession{
|
||||||
roomID: roomID,
|
roomID: roomID,
|
||||||
role: participant.Role,
|
role: participant.Role,
|
||||||
peerID: participant.PeerID,
|
peerID: participant.PeerID,
|
||||||
shareGeneration: shareGeneration,
|
shareGeneration: shareGeneration,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
viewerPresence: request.viewerPresence,
|
viewerPresence: request.viewerPresence,
|
||||||
|
connectionAttemptProgress4: request.connectionAttemptProgress4,
|
||||||
}
|
}
|
||||||
sess.authenticated = authenticated
|
sess.authenticated = authenticated
|
||||||
if participant.Role == protocol.RoleViewer {
|
if participant.Role == protocol.RoleViewer {
|
||||||
|
|||||||
@@ -795,13 +795,14 @@ func isMediaReady(entry presenceEntry) bool {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type presenceOptions struct {
|
type presenceOptions struct {
|
||||||
displayName string
|
connectionAttemptProgress4 bool
|
||||||
viewerPresence bool
|
displayName string
|
||||||
viewerPassword string
|
viewerPresence bool
|
||||||
codeOnly bool
|
viewerPassword string
|
||||||
sharingPaused *bool
|
codeOnly bool
|
||||||
qualitySettings map[string]any
|
sharingPaused *bool
|
||||||
routePolicy *protocol.RoutePolicy
|
qualitySettings map[string]any
|
||||||
|
routePolicy *protocol.RoutePolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticate ports the authenticate() helper. relayCapacity -1 is the TS
|
// authenticate ports the authenticate() helper. relayCapacity -1 is the TS
|
||||||
@@ -824,6 +825,9 @@ func authenticate(
|
|||||||
"role": role,
|
"role": role,
|
||||||
"clientId": clientID,
|
"clientId": clientID,
|
||||||
}
|
}
|
||||||
|
if presence.connectionAttemptProgress4 {
|
||||||
|
message["connectionAttemptProgress4"] = true
|
||||||
|
}
|
||||||
if role == protocol.RoleHost {
|
if role == protocol.RoleHost {
|
||||||
message["token"] = rm.HostToken
|
message["token"] = rm.HostToken
|
||||||
if shareGeneration != "" {
|
if shareGeneration != "" {
|
||||||
@@ -1148,6 +1152,54 @@ func TestSignalDeliversOnlySelfHostedAuxiliaryNatStunEndpoints(t *testing.T) {
|
|||||||
// lifecycle and restart
|
// lifecycle and restart
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestConnectionAttemptProgressIsNegotiatedForEachRecipient(t *testing.T) {
|
||||||
|
for _, hostSupport := range []bool{false, true} {
|
||||||
|
for _, viewerSupport := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("host=%t/viewer=%t", hostSupport, viewerSupport), func(t *testing.T) {
|
||||||
|
h := startHarness(t, harnessOptions{
|
||||||
|
natPredictionEnabled: true,
|
||||||
|
stunURLs: []string{"stun:share.example.test:3478"},
|
||||||
|
})
|
||||||
|
host, viewer := openClient(t, h), openClient(t, h)
|
||||||
|
policy := protocol.RoutePolicy{PeerOnly: true, NatPrediction: true}
|
||||||
|
authenticate(t, host, h.room, protocol.RoleHost, "progress-host", 1, "progress_share_generation_12345678",
|
||||||
|
presenceOptions{routePolicy: &policy, connectionAttemptProgress4: hostSupport})
|
||||||
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "progress-viewer", 0, "",
|
||||||
|
presenceOptions{connectionAttemptProgress4: viewerSupport})
|
||||||
|
for current := 1; current <= 4; current++ {
|
||||||
|
hostPrepare, viewerPrepare := nextPreparedRoute(t, host), nextPreparedRoute(t, viewer)
|
||||||
|
for _, recipient := range []struct {
|
||||||
|
update routeUpdate
|
||||||
|
support bool
|
||||||
|
}{
|
||||||
|
{hostPrepare, hostSupport}, {viewerPrepare, viewerSupport},
|
||||||
|
} {
|
||||||
|
decoded, err := protocol.DecodeServerMessage(recipient.update.raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
progress := decoded.(protocol.RouteUpdatePrepareMessage).Candidate.ConnectionAttempt
|
||||||
|
if recipient.support {
|
||||||
|
if progress == nil || *progress != (protocol.ConnectionAttempt{Current: protocol.Int(current), Total: 4}) {
|
||||||
|
t.Fatalf("new reader progress = %+v", progress)
|
||||||
|
}
|
||||||
|
} else if progress != nil {
|
||||||
|
t.Fatalf("old reader received unsupported progress: %+v", progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hostPrepare.Candidate.ConnectionID != viewerPrepare.Candidate.ConnectionID {
|
||||||
|
t.Fatal("reader capability changed the actual candidate")
|
||||||
|
}
|
||||||
|
viewer.sendJSON(map[string]any{
|
||||||
|
"type": "route-failed", "phase": "prepare",
|
||||||
|
"revision": viewerPrepare.Revision, "connectionId": viewerPrepare.Candidate.ConnectionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSignalRebuildsRouteWhenViewerReconnectsBeforeHostAfterRestart(t *testing.T) {
|
func TestSignalRebuildsRouteWhenViewerReconnectsBeforeHostAfterRestart(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "rooms.sqlite")
|
path := filepath.Join(t.TempDir(), "rooms.sqlite")
|
||||||
firstDatabase, err := room.NewDatabase(path)
|
firstDatabase, err := room.NewDatabase(path)
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ import (
|
|||||||
// authentication replaced between dispatch and use. Empty shareGeneration
|
// authentication replaced between dispatch and use. Empty shareGeneration
|
||||||
// means no share; nil displayName means the participant has no display name.
|
// means no share; nil displayName means the participant has no display name.
|
||||||
type authenticatedSession struct {
|
type authenticatedSession struct {
|
||||||
roomID string
|
roomID string
|
||||||
role protocol.Role
|
role protocol.Role
|
||||||
peerID string
|
peerID string
|
||||||
shareGeneration string
|
shareGeneration string
|
||||||
displayName *string
|
displayName *string
|
||||||
viewerPresence bool
|
viewerPresence bool
|
||||||
|
connectionAttemptProgress4 bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// outbound is one queue item: a text frame or the close frame that ends the
|
// outbound is one queue item: a text frame or the close frame that ends the
|
||||||
@@ -257,6 +258,13 @@ func (s *Server) heartbeat() []*session {
|
|||||||
// send drops unless open, terminates above the buffer bound,
|
// send drops unless open, terminates above the buffer bound,
|
||||||
// and otherwise queues the encoded message.
|
// and otherwise queues the encoded message.
|
||||||
func (s *Server) send(sess *session, message protocol.ServerMessage) {
|
func (s *Server) send(sess *session, message protocol.ServerMessage) {
|
||||||
|
// Progress is optional feedback, not route authority. Older pages reject
|
||||||
|
// totals above three; omit it for that recipient without changing the route.
|
||||||
|
if prepare, ok := message.(protocol.RouteUpdatePrepareMessage); ok &&
|
||||||
|
(sess.authenticated == nil || !sess.authenticated.connectionAttemptProgress4) {
|
||||||
|
prepare.Candidate.ConnectionAttempt = nil
|
||||||
|
message = prepare
|
||||||
|
}
|
||||||
encoded, err := protocol.EncodeServerMessage(message)
|
encoded, err := protocol.EncodeServerMessage(message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// JSON.stringify of a server literal cannot fail; neither can this.
|
// JSON.stringify of a server literal cannot fail; neither can this.
|
||||||
|
|||||||
+23
-7
@@ -11,6 +11,7 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
authenticateSiteAccess,
|
authenticateSiteAccess,
|
||||||
getRuntimeCapabilities,
|
getRuntimeCapabilities,
|
||||||
|
getOptionalRuntimeCapabilities,
|
||||||
getSiteAccess,
|
getSiteAccess,
|
||||||
type SiteAccessStatus,
|
type SiteAccessStatus,
|
||||||
} from "./lib/api";
|
} from "./lib/api";
|
||||||
@@ -163,10 +164,9 @@ function AppRoute() {
|
|||||||
}
|
}
|
||||||
if (appRoute.kind === "viewer" && viewerRoute) {
|
if (appRoute.kind === "viewer" && viewerRoute) {
|
||||||
return viewerRoute.viewerGrant ? (
|
return viewerRoute.viewerGrant ? (
|
||||||
<ViewerPage
|
<ViewerEntry
|
||||||
key={viewerRoute.viewerGrant}
|
key={viewerRoute.viewerGrant}
|
||||||
{...viewerRoute}
|
route={viewerRoute}
|
||||||
launchedByClient={clientLaunchBootstrap?.launchedByClient}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SiteAccessGate key={viewerRoute.invalidGrant ? "invalid" : "code"}
|
<SiteAccessGate key={viewerRoute.invalidGrant ? "invalid" : "code"}
|
||||||
@@ -202,6 +202,24 @@ function RouteLoader() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invitation and room-code entry share discovery without making an invitation
|
||||||
|
// pass through site authentication. Resolve before mounting media: changing the
|
||||||
|
// negotiated flag later would restart an already healthy Viewer session.
|
||||||
|
function ViewerEntry({ route }: { route: ViewerRoute }) {
|
||||||
|
const [progressSupported, setProgressSupported] = useState<boolean | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void getOptionalRuntimeCapabilities().then(capabilities => {
|
||||||
|
if (active) setProgressSupported(capabilities?.connectionAttemptProgress4 === true);
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, []);
|
||||||
|
return progressSupported === null ? <RouteLoader /> : (
|
||||||
|
<ViewerPage {...route} connectionAttemptProgress4={progressSupported}
|
||||||
|
launchedByClient={clientLaunchBootstrap?.launchedByClient} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StaticRoute({
|
function StaticRoute({
|
||||||
comic,
|
comic,
|
||||||
titleKey,
|
titleKey,
|
||||||
@@ -371,10 +389,7 @@ function SiteAccessGate({
|
|||||||
if (access.kind === "ready") {
|
if (access.kind === "ready") {
|
||||||
if (surface === "viewer" && viewerRoute) {
|
if (surface === "viewer" && viewerRoute) {
|
||||||
return (
|
return (
|
||||||
<ViewerPage
|
<ViewerEntry route={viewerRoute} />
|
||||||
{...viewerRoute}
|
|
||||||
launchedByClient={clientLaunchBootstrap?.launchedByClient}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (surface === "join") {
|
if (surface === "join") {
|
||||||
@@ -385,6 +400,7 @@ function SiteAccessGate({
|
|||||||
launchedByClient={clientLaunchBootstrap?.launchedByClient}
|
launchedByClient={clientLaunchBootstrap?.launchedByClient}
|
||||||
sfuAvailable={capabilities?.sfu === true}
|
sfuAvailable={capabilities?.sfu === true}
|
||||||
natPredictionAvailable={capabilities?.natPrediction === true}
|
natPredictionAvailable={capabilities?.natPrediction === true}
|
||||||
|
connectionAttemptProgress4={capabilities?.connectionAttemptProgress4 === true}
|
||||||
onAuthorizationRequired={() =>
|
onAuthorizationRequired={() =>
|
||||||
setAccess({ kind: "required", error: t("gate.expired") })
|
setAccess({ kind: "required", error: t("gate.expired") })
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -68,9 +68,10 @@ export async function getSiteAccess(): Promise<SiteAccessStatus> {
|
|||||||
return parseSiteAccessStatus(body);
|
return parseSiteAccessStatus(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRuntimeCapabilities(): Promise<RuntimeCapabilities> {
|
export async function getRuntimeCapabilities(signal?: AbortSignal): Promise<RuntimeCapabilities> {
|
||||||
const response = await fetch("/api/capabilities", {
|
const response = await fetch("/api/capabilities", {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
const body = await responseBody(response);
|
const body = await responseBody(response);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -86,6 +87,20 @@ export async function getRuntimeCapabilities(): Promise<RuntimeCapabilities> {
|
|||||||
return parsed.data;
|
return parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Viewer progress is optional feedback: unavailable capability discovery must
|
||||||
|
// not prevent viewing. Host policy discovery remains required.
|
||||||
|
export async function getOptionalRuntimeCapabilities(): Promise<RuntimeCapabilities | null> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 2_000);
|
||||||
|
try {
|
||||||
|
return await getRuntimeCapabilities(controller.signal);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function authenticateSiteAccess(
|
export async function authenticateSiteAccess(
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<SiteAccessStatus> {
|
): Promise<SiteAccessStatus> {
|
||||||
|
|||||||
@@ -367,8 +367,7 @@ export const en: Record<CopyKey, string> = {
|
|||||||
"viewer.msg.stale": "Page updated; refresh to continue",
|
"viewer.msg.stale": "Page updated; refresh to continue",
|
||||||
"viewer.msg.sessionReplaced": "This page's session was taken over by another tab",
|
"viewer.msg.sessionReplaced": "This page's session was taken over by another tab",
|
||||||
"viewer.msg.signalTerminated": "Connection terminated; refresh to continue",
|
"viewer.msg.signalTerminated": "Connection terminated; refresh to continue",
|
||||||
"viewer.notice.signalRecovering": "Server reconnecting; video keeps playing",
|
"viewer.notice.signalRecovering": "Server reconnecting",
|
||||||
"viewer.notice.mediaRecovering": "Media connection recovering",
|
|
||||||
"viewer.notice.hostOffline": "Host connection lost; picture may freeze",
|
"viewer.notice.hostOffline": "Host connection lost; picture may freeze",
|
||||||
"viewer.hint.denied": "Use the host's invite link, or try the room password.",
|
"viewer.hint.denied": "Use the host's invite link, or try the room password.",
|
||||||
"viewer.hint.notFound": "Check that the room code belongs to {site}, or open the host’s invite link.",
|
"viewer.hint.notFound": "Check that the room code belongs to {site}, or open the host’s invite link.",
|
||||||
|
|||||||
@@ -367,8 +367,7 @@ export const zh = {
|
|||||||
"viewer.msg.stale": "页面版本已更新,请刷新后重试",
|
"viewer.msg.stale": "页面版本已更新,请刷新后重试",
|
||||||
"viewer.msg.sessionReplaced": "此页面的会话已被另一个标签页接管",
|
"viewer.msg.sessionReplaced": "此页面的会话已被另一个标签页接管",
|
||||||
"viewer.msg.signalTerminated": "连接已终止,请刷新后重试",
|
"viewer.msg.signalTerminated": "连接已终止,请刷新后重试",
|
||||||
"viewer.notice.signalRecovering": "服务器连接正在恢复,画面仍在播放",
|
"viewer.notice.signalRecovering": "服务器连接正在恢复",
|
||||||
"viewer.notice.mediaRecovering": "媒体连接正在恢复",
|
|
||||||
"viewer.notice.hostOffline": "房主连接已中断,画面可能停住",
|
"viewer.notice.hostOffline": "房主连接已中断,画面可能停住",
|
||||||
"viewer.hint.denied": "请使用房主发来的邀请链接,或输入房间密码。",
|
"viewer.hint.denied": "请使用房主发来的邀请链接,或输入房间密码。",
|
||||||
"viewer.hint.notFound": "请确认这是 {site} 的房间号,或打开房主的邀请链接。",
|
"viewer.hint.notFound": "请确认这是 {site} 的房间号,或打开房主的邀请链接。",
|
||||||
|
|||||||
@@ -481,8 +481,8 @@ export function deriveViewerPresentation(
|
|||||||
state.connection === "reconnecting" || state.connection === "failed";
|
state.connection === "reconnecting" || state.connection === "failed";
|
||||||
return {
|
return {
|
||||||
...presentation(
|
...presentation(
|
||||||
signalRecovering || mediaRecovering ? "recovering" : "playing",
|
mediaRecovering ? "recovering" : "playing",
|
||||||
signalRecovering || mediaRecovering ? "viewer.msg.recovering" : "viewer.msg.playing",
|
mediaRecovering ? "viewer.msg.recovering" : "viewer.msg.playing",
|
||||||
"none",
|
"none",
|
||||||
state,
|
state,
|
||||||
),
|
),
|
||||||
@@ -490,14 +490,14 @@ export function deriveViewerPresentation(
|
|||||||
? "viewer.notice.hostOffline"
|
? "viewer.notice.hostOffline"
|
||||||
: signalRecovering
|
: signalRecovering
|
||||||
? "viewer.notice.signalRecovering"
|
? "viewer.notice.signalRecovering"
|
||||||
: mediaRecovering
|
|
||||||
? "viewer.notice.mediaRecovering"
|
|
||||||
: null,
|
: null,
|
||||||
failureCode: state.host === "offline" ? "HOST_OFFLINE" : null,
|
failureCode: state.host === "offline" ? "HOST_OFFLINE" : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.routeStatus?.state === "failed") {
|
// A failed route explains missing media only while the Host can publish.
|
||||||
|
// Keep the fact: Host reconnection alone does not repair that route.
|
||||||
|
if (state.host === "online" && state.routeStatus?.state === "failed") {
|
||||||
return presentation(
|
return presentation(
|
||||||
"route-failed",
|
"route-failed",
|
||||||
"viewer.msg.routeFailed",
|
"viewer.msg.routeFailed",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const SOURCES = { kind: "ready", processAudio: true, systemAudio: true, captureB
|
|||||||
] } satisfies NativeSourceList;
|
] } satisfies NativeSourceList;
|
||||||
const previewSource = async () => POSTER;
|
const previewSource = async () => POSTER;
|
||||||
const previewCameras: typeof loadCameraPreviews = async (_signal, publish) => publish([
|
const previewCameras: typeof loadCameraPreviews = async (_signal, publish) => publish([
|
||||||
{ id: "built-in", label: "Built-in camera", preview: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180"><rect width="320" height="180" fill="#dce9e5"/><rect x="216" y="16" width="82" height="102" rx="7" fill="#fbfcf3"/><path d="M257 16v102M216 66h82" stroke="#b2cbc2" stroke-width="4"/><path d="M106 147q0-62 54-62t54 62" fill="#449986"/><circle cx="160" cy="57" r="29" fill="#449986"/><path d="M151 55v4m18-4v4" stroke="#203c3a" stroke-width="5" stroke-linecap="round"/><path d="M0 151h320v29H0" fill="#d8b898"/><rect x="232" y="131" width="21" height="23" rx="5" fill="#fff7e9"/></svg>')}` },
|
{ id: "built-in", label: "Built-in camera", preview: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 320"><rect width="180" height="320" fill="#dce9e5"/><rect x="106" y="26" width="58" height="102" rx="7" fill="#fbfcf3"/><path d="M135 26v102M106 76h58" stroke="#b2cbc2" stroke-width="4"/><path d="M36 255q0-100 54-100t54 100" fill="#449986"/><circle cx="90" cy="124" r="29" fill="#449986"/><path d="M81 122v4m18-4v4" stroke="#203c3a" stroke-width="5" stroke-linecap="round"/><path d="M0 259h180v61H0" fill="#d8b898"/><rect x="142" y="241" width="21" height="23" rx="5" fill="#fff7e9"/></svg>')}` },
|
||||||
{ id: "usb", label: "USB Camera", preview: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180"><rect width="320" height="180" fill="#e2c5a8"/><path d="M0 25h320M0 157h320" stroke="#cda987" stroke-width="2"/><rect x="53" y="27" width="163" height="124" rx="5" fill="#fffaf0" transform="rotate(-8 135 89)"/><path d="m97 112 30-35 23 25 18-12 18 19" fill="none" stroke="#689b8a" stroke-width="5" stroke-linejoin="round"/><circle cx="166" cy="57" r="11" fill="#ebbd63"/><path d="m245 64 8 77" stroke="#477b73" stroke-width="7" stroke-linecap="round"/><circle cx="266" cy="35" r="19" fill="#f9f5df"/><circle cx="266" cy="35" r="12" fill="#8c6651"/></svg>')}` },
|
{ id: "usb", label: "USB Camera", preview: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180"><rect width="320" height="180" fill="#e2c5a8"/><path d="M0 25h320M0 157h320" stroke="#cda987" stroke-width="2"/><rect x="53" y="27" width="163" height="124" rx="5" fill="#fffaf0" transform="rotate(-8 135 89)"/><path d="m97 112 30-35 23 25 18-12 18 19" fill="none" stroke="#689b8a" stroke-width="5" stroke-linejoin="round"/><circle cx="166" cy="57" r="11" fill="#ebbd63"/><path d="m245 64 8 77" stroke="#477b73" stroke-width="7" stroke-linecap="round"/><circle cx="266" cy="35" r="19" fill="#f9f5df"/><circle cx="266" cy="35" r="12" fill="#8c6651"/></svg>')}` },
|
||||||
]);
|
]);
|
||||||
const previewMicrophones = async () => [{ id: "headset", label: "USB Headset" }, { id: "desk", label: "Desk Microphone" }];
|
const previewMicrophones = async () => [{ id: "headset", label: "USB Headset" }, { id: "desk", label: "Desk Microphone" }];
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ function hostTerminationKey(reason: SignalingTerminationReason): CopyKey {
|
|||||||
interface HostPageProps {
|
interface HostPageProps {
|
||||||
sfuAvailable?: boolean;
|
sfuAvailable?: boolean;
|
||||||
natPredictionAvailable?: boolean;
|
natPredictionAvailable?: boolean;
|
||||||
|
connectionAttemptProgress4?: boolean;
|
||||||
launchedByClient?: boolean;
|
launchedByClient?: boolean;
|
||||||
onAuthorizationRequired?: () => void;
|
onAuthorizationRequired?: () => void;
|
||||||
}
|
}
|
||||||
@@ -350,6 +351,7 @@ type ShareSourceSelection =
|
|||||||
export function HostPage({
|
export function HostPage({
|
||||||
sfuAvailable = false,
|
sfuAvailable = false,
|
||||||
natPredictionAvailable = false,
|
natPredictionAvailable = false,
|
||||||
|
connectionAttemptProgress4 = false,
|
||||||
launchedByClient = false,
|
launchedByClient = false,
|
||||||
onAuthorizationRequired,
|
onAuthorizationRequired,
|
||||||
}: HostPageProps = {}) {
|
}: HostPageProps = {}) {
|
||||||
@@ -2552,6 +2554,7 @@ export function HostPage({
|
|||||||
qualitySettings: qualitySettingsRef.current,
|
qualitySettings: qualitySettingsRef.current,
|
||||||
routePolicy: routePolicyRef.current,
|
routePolicy: routePolicyRef.current,
|
||||||
viewerPresence: true,
|
viewerPresence: true,
|
||||||
|
...(connectionAttemptProgress4 ? { connectionAttemptProgress4: true } : {}),
|
||||||
displayName: initialDisplayName,
|
displayName: initialDisplayName,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3608,7 +3611,7 @@ export function HostPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="lr-stage-notices" role="status" aria-live="polite">
|
<div className="lr-stage-notices" role="status" aria-live="polite">
|
||||||
{!details?.hasSourceAudio && stream ? (
|
{details?.hasSourceAudio === false && stream ? (
|
||||||
<Pill icon="speakerOff" label={t("host.noAudio")} comic="no-audio" tone="off" />
|
<Pill icon="speakerOff" label={t("host.noAudio")} comic="no-audio" tone="off" />
|
||||||
) : null}
|
) : null}
|
||||||
{hostSfuWarningText ? (
|
{hostSfuWarningText ? (
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ interface ViewerPageProps {
|
|||||||
viewerGrant?: string;
|
viewerGrant?: string;
|
||||||
invalidGrant?: boolean;
|
invalidGrant?: boolean;
|
||||||
launchedByClient?: boolean;
|
launchedByClient?: boolean;
|
||||||
|
connectionAttemptProgress4?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewerQualityEvidence = Extract<
|
type ViewerQualityEvidence = Extract<
|
||||||
@@ -167,6 +168,7 @@ export function ViewerPage({
|
|||||||
viewerGrant,
|
viewerGrant,
|
||||||
invalidGrant = false,
|
invalidGrant = false,
|
||||||
launchedByClient = false,
|
launchedByClient = false,
|
||||||
|
connectionAttemptProgress4 = false,
|
||||||
}: ViewerPageProps) {
|
}: ViewerPageProps) {
|
||||||
const { lang, t, vis, titleFrames } = useCopy();
|
const { lang, t, vis, titleFrames } = useCopy();
|
||||||
const viewerClientId = useMemo(
|
const viewerClientId = useMemo(
|
||||||
@@ -594,6 +596,7 @@ export function ViewerPage({
|
|||||||
: {}),
|
: {}),
|
||||||
displayName: displayNameRef.current,
|
displayName: displayNameRef.current,
|
||||||
viewerPresence: true,
|
viewerPresence: true,
|
||||||
|
...(connectionAttemptProgress4 ? { connectionAttemptProgress4: true } : {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onStatus: (status) => {
|
onStatus: (status) => {
|
||||||
@@ -1583,7 +1586,7 @@ export function ViewerPage({
|
|||||||
? "paused"
|
? "paused"
|
||||||
: message.hostOnline
|
: message.hostOnline
|
||||||
? "online"
|
? "online"
|
||||||
: "stopped",
|
: "unknown",
|
||||||
});
|
});
|
||||||
currentQualitySettings = message.qualitySettings;
|
currentQualitySettings = message.qualitySettings;
|
||||||
currentShareGeneration = message.shareGeneration;
|
currentShareGeneration = message.shareGeneration;
|
||||||
@@ -1936,7 +1939,7 @@ export function ViewerPage({
|
|||||||
viewerRelaySourceKey = null;
|
viewerRelaySourceKey = null;
|
||||||
void nativeClientPromise?.then((client) => client?.close());
|
void nativeClientPromise?.then((client) => client?.close());
|
||||||
};
|
};
|
||||||
}, [roomId, viewerGrant, viewerPasswordAttempt, launchedByClient]);
|
}, [roomId, viewerGrant, viewerPasswordAttempt, launchedByClient, connectionAttemptProgress4]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ export const STATUS_SCENARIOS: readonly StatusScenario[] = [
|
|||||||
scenario("signal-recovering", "信令断开,画面仍在播放", "控制连接正在恢复;媒体仍有当前画面,标题保留观看状态。", [
|
scenario("signal-recovering", "信令断开,画面仍在播放", "控制连接正在恢复;媒体仍有当前画面,标题保留观看状态。", [
|
||||||
...playing, { type: "signal", signal: "reconnecting" },
|
...playing, { type: "signal", signal: "reconnecting" },
|
||||||
]),
|
]),
|
||||||
|
scenario("signal-media-recovering", "信令与媒体同时恢复", "服务器提示与媒体状态各自描述自己的事实;服务器提醒不把恢复中的媒体显示成正常播放。", [
|
||||||
|
...playing,
|
||||||
|
{ type: "signal", signal: "reconnecting" },
|
||||||
|
{ type: "connection", revision: 1, connection: "reconnecting" },
|
||||||
|
]),
|
||||||
|
scenario("host-offline-media-recovering", "房主离线,媒体正在恢复", "保留已证明的画面;电视说明媒体正在恢复,旁边的提醒说明房主连接中断。", [
|
||||||
|
...playing,
|
||||||
|
{ type: "host", host: "offline" },
|
||||||
|
{ type: "connection", revision: 1, connection: "reconnecting" },
|
||||||
|
]),
|
||||||
scenario("host-offline-playing", "房主离线,仍有当前画面", "房主在线状态和正在接收的媒体不是同一件事;先给提示,不直接覆盖画面。", [
|
scenario("host-offline-playing", "房主离线,仍有当前画面", "房主在线状态和正在接收的媒体不是同一件事;先给提示,不直接覆盖画面。", [
|
||||||
...playing, { type: "host", host: "offline" },
|
...playing, { type: "host", host: "offline" },
|
||||||
]),
|
]),
|
||||||
@@ -95,10 +105,28 @@ export const STATUS_SCENARIOS: readonly StatusScenario[] = [
|
|||||||
...receiving, { type: "playback-failed", generation: 1, revision: 1 },
|
...receiving, { type: "playback-failed", generation: 1, revision: 1 },
|
||||||
]),
|
]),
|
||||||
scenario("waiting-host", "等待房主开始分享", "房间存在,当前没有共享源。", [{ type: "access", access: "ready" }]),
|
scenario("waiting-host", "等待房主开始分享", "房间存在,当前没有共享源。", [{ type: "access", access: "ready" }]),
|
||||||
|
scenario("host-absence-snapshot", "重连后只确认房主不在线", "快照没有说明房主为何不在线;用中性等待,不推断主动停播或连接中断。", [
|
||||||
|
...ready, { type: "host", host: "unknown" },
|
||||||
|
]),
|
||||||
scenario("host-offline", "房主离线,没有画面", "没有当前或留存画面,说明房主离线。", [
|
scenario("host-offline", "房主离线,没有画面", "没有当前或留存画面,说明房主离线。", [
|
||||||
...ready, { type: "host", host: "offline" },
|
...ready, { type: "host", host: "offline" },
|
||||||
]),
|
]),
|
||||||
|
scenario("host-offline-route-failed", "房主离线,线路也已失败", "没有可播放画面时先说明房主离线;线路失败证据仍保留,房主回来不代表线路已经恢复。", [
|
||||||
|
...receiving,
|
||||||
|
{ type: "host", host: "offline" },
|
||||||
|
{ type: "route-status", revision: 1, state: "failed" },
|
||||||
|
]),
|
||||||
|
scenario("host-offline-retained", "房主离线,只剩最后画面", "线路失败使当前播放证明失效;保留最后画面,以轻量覆盖层提示房主离线。", [
|
||||||
|
...playing,
|
||||||
|
{ type: "route-status", revision: 1, state: "failed" },
|
||||||
|
{ type: "host", host: "offline" },
|
||||||
|
]),
|
||||||
scenario("stopped", "分享已经停止", "正常结束,不伪装为正在恢复或播放失败。", [...playing, { type: "sharing-stopped" }]),
|
scenario("stopped", "分享已经停止", "正常结束,不伪装为正在恢复或播放失败。", [...playing, { type: "sharing-stopped" }]),
|
||||||
|
scenario("stopped-route-failed", "停止分享后收到线路失败", "停止分享清理画面;随后到达的线路失败不把正常停播变成媒体故障。", [
|
||||||
|
...playing,
|
||||||
|
{ type: "sharing-stopped" },
|
||||||
|
{ type: "route-status", revision: 1, state: "failed" },
|
||||||
|
]),
|
||||||
...denied.map(([failure, name]) => scenario(failure.toLowerCase(), name,
|
...denied.map(([failure, name]) => scenario(failure.toLowerCase(), name,
|
||||||
"服务端准入或会话终止事实优先;旧画面和旧质量样本不能重新覆盖这个状态。",
|
"服务端准入或会话终止事实优先;旧画面和旧质量样本不能重新覆盖这个状态。",
|
||||||
[{ type: "access", access: "denied", failure }])),
|
[{ type: "access", access: "denied", failure }])),
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ body.lr-theater-open .lr-top { visibility: hidden; }
|
|||||||
background: var(--stage);
|
background: var(--stage);
|
||||||
}
|
}
|
||||||
.lr-tv-screen video { display: block; width: 100%; height: 100%; object-fit: contain; background: var(--stage); }
|
.lr-tv-screen video { display: block; width: 100%; height: 100%; object-fit: contain; background: var(--stage); }
|
||||||
.lr-tv-screen.has-entry { aspect-ratio: 16 / 9; }
|
.lr-tv-screen.has-entry { display: grid; aspect-ratio: 16 / 9; }
|
||||||
.lr-tv-screen.is-on { animation: lr-crt .5s cubic-bezier(.2, .8, .3, 1) both; }
|
.lr-tv-screen.is-on { animation: lr-crt .5s cubic-bezier(.2, .8, .3, 1) both; }
|
||||||
.lr-tv-chin {
|
.lr-tv-chin {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -593,6 +593,7 @@ body.lr-theater-open .lr-top { visibility: hidden; }
|
|||||||
background: rgba(255, 255, 255, .1);
|
background: rgba(255, 255, 255, .1);
|
||||||
}
|
}
|
||||||
.lr-source-option-preview {
|
.lr-source-option-preview {
|
||||||
|
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 144px;
|
height: 144px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -127,14 +127,10 @@ export function deriveViewerStatus(
|
|||||||
signal: SignalConnectionState,
|
signal: SignalConnectionState,
|
||||||
route: "p2p" | "sfu" | null = null,
|
route: "p2p" | "sfu" | null = null,
|
||||||
) {
|
) {
|
||||||
// Control recovery does not interrupt a picture that remains proved.
|
const stage = presentation.stage;
|
||||||
const mediaRecovering = presentation.noticeKey === "viewer.notice.mediaRecovering";
|
|
||||||
const playable = presentation.overlay === "none" && !mediaRecovering;
|
|
||||||
const stage = playable ? "playing" : presentation.stage;
|
|
||||||
const activity: StatusDescriptor = {
|
const activity: StatusDescriptor = {
|
||||||
...STAGE_VISUALS[stage],
|
...STAGE_VISUALS[stage],
|
||||||
labelKey: mediaRecovering ? "viewer.notice.mediaRecovering"
|
labelKey: presentation.messageKey,
|
||||||
: playable ? "viewer.msg.playing" : presentation.messageKey,
|
|
||||||
...(stage === "receiving" ? { comic: route === "sfu" ? "connecting-sfu" as const : "connecting-p2p" as const } : {}),
|
...(stage === "receiving" ? { comic: route === "sfu" ? "connecting-sfu" as const : "connecting-p2p" as const } : {}),
|
||||||
};
|
};
|
||||||
let notice: (StatusDescriptor & { comic: ComicKind }) | null = null;
|
let notice: (StatusDescriptor & { comic: ComicKind }) | null = null;
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export const DEFAULT_ROUTE_POLICY = {
|
|||||||
} as const satisfies RoutePolicy;
|
} as const satisfies RoutePolicy;
|
||||||
|
|
||||||
export const runtimeCapabilitiesSchema = z.object({
|
export const runtimeCapabilitiesSchema = z.object({
|
||||||
|
connectionAttemptProgress4: z.boolean().optional(),
|
||||||
sfu: z.boolean().default(false),
|
sfu: z.boolean().default(false),
|
||||||
natPrediction: z.boolean().default(false),
|
natPrediction: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
@@ -372,9 +373,9 @@ export const preparedRouteCandidateSchema = z
|
|||||||
transport: z.enum(["direct", "sfu"]),
|
transport: z.enum(["direct", "sfu"]),
|
||||||
qualityProbe: z.boolean(),
|
qualityProbe: z.boolean(),
|
||||||
connectionAttempt: z.object({
|
connectionAttempt: z.object({
|
||||||
current: z.number().int().min(1).max(3),
|
current: z.number().int().min(1).max(4),
|
||||||
total: z.literal(3),
|
total: z.union([z.literal(3), z.literal(4)]),
|
||||||
}).strict().optional(),
|
}).strict().refine(({ current, total }) => current <= total).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
export type PreparedRouteCandidate = z.infer<
|
export type PreparedRouteCandidate = z.infer<
|
||||||
@@ -816,6 +817,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [
|
|||||||
qualitySettings: qualitySettingsSchema.optional(),
|
qualitySettings: qualitySettingsSchema.optional(),
|
||||||
routePolicy: routePolicySchema.default(DEFAULT_ROUTE_POLICY),
|
routePolicy: routePolicySchema.default(DEFAULT_ROUTE_POLICY),
|
||||||
viewerPresence: z.literal(true).optional(),
|
viewerPresence: z.literal(true).optional(),
|
||||||
|
connectionAttemptProgress4: z.literal(true).optional(),
|
||||||
displayName: displayNameSchema.optional(),
|
displayName: displayNameSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
@@ -830,6 +832,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [
|
|||||||
viewerPassword: viewerPasswordSchema.optional(),
|
viewerPassword: viewerPasswordSchema.optional(),
|
||||||
displayName: displayNameSchema.optional(),
|
displayName: displayNameSchema.optional(),
|
||||||
viewerPresence: z.literal(true).optional(),
|
viewerPresence: z.literal(true).optional(),
|
||||||
|
connectionAttemptProgress4: z.literal(true).optional(),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
authenticateSiteAccess,
|
authenticateSiteAccess,
|
||||||
createRoom,
|
createRoom,
|
||||||
getSiteAccess,
|
getSiteAccess,
|
||||||
|
getOptionalRuntimeCapabilities,
|
||||||
replaceOwnedRoom,
|
replaceOwnedRoom,
|
||||||
} from "../src/client/lib/api.ts";
|
} from "../src/client/lib/api.ts";
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +64,34 @@ afterEach(() => {
|
|||||||
setCopy({ lang: "zh", vis: false });
|
setCopy({ lang: "zh", vis: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("optional Viewer capability discovery", () => {
|
||||||
|
it("returns supported progress and tolerates an unavailable or malformed descriptor", async () => {
|
||||||
|
for (const [body, status, expected] of [
|
||||||
|
[{ connectionAttemptProgress4: true }, 200, true],
|
||||||
|
[{}, 200, undefined],
|
||||||
|
[{}, 503, undefined],
|
||||||
|
[{ connectionAttemptProgress4: 4 }, 200, undefined],
|
||||||
|
] as const) {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })));
|
||||||
|
expect((await getOptionalRuntimeCapabilities())?.connectionAttemptProgress4).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abandons stalled discovery without preventing viewing", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let observed: AbortSignal | undefined;
|
||||||
|
vi.stubGlobal("fetch", vi.fn((_url: string, options: RequestInit) => new Promise((_resolve, reject) => {
|
||||||
|
observed = options.signal!;
|
||||||
|
observed.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
||||||
|
})));
|
||||||
|
const discovery = getOptionalRuntimeCapabilities();
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
await expect(discovery).resolves.toBeNull();
|
||||||
|
expect(observed?.aborted).toBe(true);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function routeAuthenticated(
|
function routeAuthenticated(
|
||||||
shareGeneration: string | null = null,
|
shareGeneration: string | null = null,
|
||||||
qualitySettings: QualitySettings = DEFAULT_QUALITY_SETTINGS,
|
qualitySettings: QualitySettings = DEFAULT_QUALITY_SETTINGS,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("media status projection", () => {
|
|||||||
it.each(["reconnecting", "failed"] as const)("shows media recovery only on the television: %s", (connection) => {
|
it.each(["reconnecting", "failed"] as const)("shows media recovery only on the television: %s", (connection) => {
|
||||||
const result = deriveViewerStatus(presentation({ type: "connection", revision: 1, connection }), "connected");
|
const result = deriveViewerStatus(presentation({ type: "connection", revision: 1, connection }), "connected");
|
||||||
expect(result.television).toMatchObject({
|
expect(result.television).toMatchObject({
|
||||||
tone: "warn", icon: "refresh", labelKey: "viewer.notice.mediaRecovering",
|
tone: "warn", icon: "refresh", labelKey: "viewer.msg.recovering",
|
||||||
});
|
});
|
||||||
expect(result.notice).toBeNull();
|
expect(result.notice).toBeNull();
|
||||||
expect(result.overlay).toBeNull();
|
expect(result.overlay).toBeNull();
|
||||||
@@ -68,6 +68,59 @@ describe("media status projection", () => {
|
|||||||
expect(result.overlay).toBeNull();
|
expect(result.overlay).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["reconnecting", "failed"] as const)("does not let coexisting notices hide media %s", connection => {
|
||||||
|
for (const host of ["online", "offline"] as const) {
|
||||||
|
for (const signal of ["connected", "reconnecting"] as const) {
|
||||||
|
const state = [
|
||||||
|
...playingActions,
|
||||||
|
{ type: "host", host } as const,
|
||||||
|
{ type: "signal", signal } as const,
|
||||||
|
{ type: "connection", revision: 1, connection } as const,
|
||||||
|
].reduce(reduceViewerPresentation, INITIAL_VIEWER_PRESENTATION_STATE);
|
||||||
|
const status = deriveViewerStatus(deriveViewerPresentation(state), signal);
|
||||||
|
expect(status.television).toMatchObject({ tone: "warn", comic: "recovering" });
|
||||||
|
expect(status.overlay).toBeNull();
|
||||||
|
expect(status.notice?.labelKey ?? null).toBe(host === "offline"
|
||||||
|
? "viewer.notice.hostOffline" : signal === "reconnecting" ? "viewer.notice.signalRecovering" : null);
|
||||||
|
const recovered = reduceViewerPresentation(state, { type: "frame-presented", generation: 1, revision: 1, proofEpoch: 0 });
|
||||||
|
const healthy = deriveViewerStatus(deriveViewerPresentation(recovered), signal);
|
||||||
|
expect(healthy.television).toMatchObject({ tone: "live", labelKey: "viewer.msg.playing" });
|
||||||
|
expect(healthy.notice).toEqual(status.notice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["unknown", "viewer.msg.waitingHost", "waiting-for-host", "viewerWaiting"],
|
||||||
|
["stopped", "viewer.msg.waitingHost", "waiting-for-host", "viewerWaiting"],
|
||||||
|
["offline", "viewer.msg.hostOffline", "host-offline", "viewerUnavailable"],
|
||||||
|
["paused", "viewer.msg.hostPaused", "host-paused", "paused"],
|
||||||
|
] as const)("explains absent Host %s instead of exhausted routes in either event order", (host, labelKey, comic, titleFrameKey) => {
|
||||||
|
const absence: ViewerPresentationAction = { type: "host", host };
|
||||||
|
const failure: ViewerPresentationAction = { type: "route-status", revision: 1, state: "failed" };
|
||||||
|
for (const hadPicture of [false, true]) {
|
||||||
|
for (const kind of ["p2p", "sfu"] as const) {
|
||||||
|
for (const order of [[absence, failure], [failure, absence]]) {
|
||||||
|
const state = [
|
||||||
|
...(hadPicture ? playingActions : playingActions.slice(0, 4)),
|
||||||
|
{ type: "route", revision: 1, phase: "active", kind } as const,
|
||||||
|
...order,
|
||||||
|
].reduce(reduceViewerPresentation, INITIAL_VIEWER_PRESENTATION_STATE);
|
||||||
|
const status = deriveViewerStatus(deriveViewerPresentation(state), "connected", kind);
|
||||||
|
expect(status).toMatchObject({
|
||||||
|
television: { labelKey, comic },
|
||||||
|
overlay: { mode: hadPicture ? "status" : "blocking", status: { labelKey, comic } },
|
||||||
|
titleFrameKey,
|
||||||
|
});
|
||||||
|
// Host return cannot by itself turn an exhausted route into a healthy one.
|
||||||
|
expect(state.routeStatus).toEqual({ revision: 1, state: "failed" });
|
||||||
|
const returned = reduceViewerPresentation(state, { type: "host", host: "online" });
|
||||||
|
expect(deriveViewerPresentation(returned).stage).toBe("route-failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps Host source activity separate from signaling and ignores a previous share's pause", () => {
|
it("keeps Host source activity separate from signaling and ignores a previous share's pause", () => {
|
||||||
const sharing = deriveHostStatus({ phase: "live", paused: false, signal: "reconnecting", roomReady: true });
|
const sharing = deriveHostStatus({ phase: "live", paused: false, signal: "reconnecting", roomReady: true });
|
||||||
expect(sharing.television.tone).toBe("live");
|
expect(sharing.television.tone).toBe("live");
|
||||||
|
|||||||
+22
-6
@@ -35,6 +35,17 @@ const qualitySettings = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
describe("runtime capabilities", () => {
|
describe("runtime capabilities", () => {
|
||||||
|
it("keeps progress opt-in optional and strict for either signaling role", () => {
|
||||||
|
for (const role of ["host", "viewer"] as const) {
|
||||||
|
const identity = { type: "authenticate", protocol: SIGNALING_PROTOCOL, roomId,
|
||||||
|
clientId: "client_12345678", role, ...(role === "host" ? { token } : {}) };
|
||||||
|
expect(clientMessageSchema.safeParse(identity).success).toBe(true);
|
||||||
|
for (const connectionAttemptProgress4 of [true, false, null, 4, "true"]) {
|
||||||
|
expect(clientMessageSchema.safeParse({ ...identity, connectionAttemptProgress4 }).success)
|
||||||
|
.toBe(connectionAttemptProgress4 === true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
it("defaults missing capabilities off and ignores unknown descriptors", () => {
|
it("defaults missing capabilities off and ignores unknown descriptors", () => {
|
||||||
expect(runtimeCapabilitiesSchema.parse({})).toEqual({ sfu: false, natPrediction: false });
|
expect(runtimeCapabilitiesSchema.parse({})).toEqual({ sfu: false, natPrediction: false });
|
||||||
expect(runtimeCapabilitiesSchema.parse({ sfu: true, extra: { enabled: true } }))
|
expect(runtimeCapabilitiesSchema.parse({ sfu: true, extra: { enabled: true } }))
|
||||||
@@ -43,11 +54,14 @@ describe("runtime capabilities", () => {
|
|||||||
.toEqual({ sfu: false, natPrediction: true });
|
.toEqual({ sfu: false, natPrediction: true });
|
||||||
expect(runtimeCapabilitiesSchema.parse({ sfu: true, natPrediction: true }))
|
expect(runtimeCapabilitiesSchema.parse({ sfu: true, natPrediction: true }))
|
||||||
.toEqual({ sfu: true, natPrediction: true });
|
.toEqual({ sfu: true, natPrediction: true });
|
||||||
|
expect(runtimeCapabilitiesSchema.parse({ connectionAttemptProgress4: true }))
|
||||||
|
.toEqual({ sfu: false, natPrediction: false, connectionAttemptProgress4: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects malformed known capabilities and non-object responses", () => {
|
it("rejects malformed known capabilities and non-object responses", () => {
|
||||||
for (const value of [null, [], true, { sfu: null }, { sfu: "true" },
|
for (const value of [null, [], true, { sfu: null }, { sfu: "true" },
|
||||||
{ natPrediction: null }, { natPrediction: 1 }]) {
|
{ natPrediction: null }, { natPrediction: 1 },
|
||||||
|
{ connectionAttemptProgress4: null }, { connectionAttemptProgress4: 4 }]) {
|
||||||
expect(runtimeCapabilitiesSchema.safeParse(value).success).toBe(false);
|
expect(runtimeCapabilitiesSchema.safeParse(value).success).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1492,14 +1506,16 @@ describe("server signaling protocol", () => {
|
|||||||
childPeerId: "child_12345678", connectionId: "connection_12345678",
|
childPeerId: "child_12345678", connectionId: "connection_12345678",
|
||||||
transport: "direct", qualityProbe: false,
|
transport: "direct", qualityProbe: false,
|
||||||
};
|
};
|
||||||
for (const current of [1, 2, 3]) {
|
for (const total of [3, 4]) {
|
||||||
expect(preparedRouteCandidateSchema.safeParse({
|
for (let current = 1; current <= total; current++) {
|
||||||
...candidate, connectionAttempt: { current, total: 3 },
|
expect(preparedRouteCandidateSchema.safeParse({
|
||||||
}).success).toBe(true);
|
...candidate, connectionAttempt: { current, total },
|
||||||
|
}).success).toBe(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const connectionAttempt of [
|
for (const connectionAttempt of [
|
||||||
{ current: 0, total: 3 }, { current: 4, total: 3 },
|
{ current: 0, total: 3 }, { current: 4, total: 3 },
|
||||||
{ current: 1, total: 4 }, { current: 1.5, total: 3 },
|
{ current: 5, total: 4 }, { current: 1, total: 5 }, { current: 1.5, total: 3 },
|
||||||
]) {
|
]) {
|
||||||
expect(preparedRouteCandidateSchema.safeParse({
|
expect(preparedRouteCandidateSchema.safeParse({
|
||||||
...candidate, connectionAttempt,
|
...candidate, connectionAttempt,
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ describe("Viewer presentation reducer", () => {
|
|||||||
{ type: "signal", signal: "reconnecting" },
|
{ type: "signal", signal: "reconnecting" },
|
||||||
);
|
);
|
||||||
expect(deriveViewerPresentation(state)).toMatchObject({
|
expect(deriveViewerPresentation(state)).toMatchObject({
|
||||||
stage: "recovering",
|
stage: "playing",
|
||||||
overlay: "none",
|
overlay: "none",
|
||||||
noticeKey: "viewer.notice.signalRecovering",
|
noticeKey: "viewer.notice.signalRecovering",
|
||||||
});
|
});
|
||||||
@@ -599,8 +599,8 @@ describe("Viewer presentation reducer", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(deriveViewerPresentation(reconnecting)).toMatchObject({
|
expect(deriveViewerPresentation(reconnecting)).toMatchObject({
|
||||||
stage: "recovering",
|
stage: "playing",
|
||||||
messageKey: "viewer.msg.recovering",
|
messageKey: "viewer.msg.playing",
|
||||||
overlay: "none",
|
overlay: "none",
|
||||||
noticeKey: "viewer.notice.signalRecovering",
|
noticeKey: "viewer.notice.signalRecovering",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ it.each(["host-paused", "preview-paused", "route-failed", "source-failed", "room
|
|||||||
it.each(STATUS_SCENARIOS)("composes feedback from the actual $id state", scenario => {
|
it.each(STATUS_SCENARIOS)("composes feedback from the actual $id state", scenario => {
|
||||||
const presentation = deriveViewerPresentation(scenario.state);
|
const presentation = deriveViewerPresentation(scenario.state);
|
||||||
const { overlay } = deriveViewerStatus(presentation, scenario.state.signal);
|
const { overlay } = deriveViewerStatus(presentation, scenario.state.signal);
|
||||||
const waiting = ["joining", "preparing-p2p", "preparing-sfu", "waiting-sfu", "allocating",
|
const waiting = presentation.overlay !== "none" &&
|
||||||
"receiving", "recovering", "waiting-host", "stopped"].includes(scenario.id);
|
["joining", "preparing-p2p", "preparing-sfu", "waiting-sfu", "allocating",
|
||||||
|
"receiving", "recovering", "waiting-host"].includes(presentation.stage);
|
||||||
expect(overlay?.waiting ?? false).toBe(waiting);
|
expect(overlay?.waiting ?? false).toBe(waiting);
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
const html = renderToStaticMarkup(createElement(StageOverlay, {
|
const html = renderToStaticMarkup(createElement(StageOverlay, {
|
||||||
|
|||||||
Reference in new issue
Block a user