feat(ui): add illustrated hints and playback gestures

## Problem and outcome / 问题与结果

App startup rejected short or non-ASCII site passwords, viewers lacked picture-click playback gestures, and text-mode hints hid the existing illustrations. This phase accepts site passwords verbatim, combines shared illustrations with localized captions in tooltips and stage overlays, and adds desktop single-click play/pause and double-click fullscreen through the existing playback owner. Pure-visual mode remains available.

App ZIP names are now stable per platform, allowing the website to use GitHub's native latest-download URLs. The App update link selects the ZIP matching its compiled OS and architecture from the checked release metadata, falling back to the release page if the package is unavailable. Verified Gitee links retain their explicit release version. The Windows packaging workflow also stops restoring a redundant Go toolchain cache that caused setup-go to fail.

## Release notes

### 中文

- 中英文操作提示和画面状态覆盖层加入动态漫画,说明更直观;纯视觉模式继续保留。
- 观众可单击画面暂停或继续播放,双击进入或退出全屏;触屏仍通过单击显示或隐藏播放栏。
- 修复设置短密码、中文密码时 App 或 Server 无法启动的问题。站点密码仍可留空。
- 官网各系统的 GitHub 下载按钮直接获取最新 ZIP 包,并保留 Gitee 备用下载线路。
- App 内的更新入口直接下载对应系统和架构的 ZIP 包。

### English

- Chinese and English tooltips and picture status overlays now pair concise explanations with animated illustrations. Pure-visual mode remains available.
- Viewers can click the picture to pause or resume and double-click to enter or exit fullscreen. Touch taps continue to show or hide playback controls.
- Fixed App and Server startup rejecting short or non-ASCII site passwords. Site passwords remain optional.
- Each platform's GitHub download button now fetches the latest ZIP package, with Gitee retained as an alternative download source.
- App update links download the ZIP matching the installed platform and architecture.

## Verification / 验证

- `npm run check`: 59 test files / 774 tests, TypeScript and production build passed.
- `npm run check:client-core`: Go tests, formatting, vet and supported local target builds passed for the included runtime changes. Native macOS packaging remains on its macOS runner.
- Desktop and 390px touch playback checks passed, including double-click without an intermediate pause and control/keyboard interaction.
- Illustrated tooltip checks passed in Chinese, English and pure-visual modes, light/dark themes, 260/360/390px layouts, keyboard, touch hold and reduced motion. User accepted the resulting UI.
- Real stage overlay components passed 46 checks across the three modes and 260/390/1280px layouts, including keyboard play activation, reduced motion, and no overlay over healthy playback with a quality limitation.
- Bounded release review confirmed JSON password authentication retains old bodyless Bearer compatibility and ZIP names match packaging, publisher, mirror and website consumers.
- After checking both providers' real API shapes, 27 focused update tests and type-checking passed; anonymous GitHub and Gitee metadata each selected all three actual platform ZIPs without downloading them.
- Documentation, repository hygiene and diff checks passed.

## Impact / 影响

Compatible feature release. Signaling, Native control, capture protocol and persistent room schema are unchanged. The new latest-download filenames must be published before the matching website deploys; the existing CI-to-Website sequence provides that order. Production service updates use the immutable Server artifact and bounded rollback. Broader physical macOS/Linux, network and endurance acceptance stays under the existing documented deferrals.
This commit is contained in:
AAA authored and GitHub committed 2026-09-13 01:04:13 +08:00
1 parent 0dde5da859
commit 322e5d91b2
52 files changed
+715 -516

No files matched your search

-15
View File
@@ -66,11 +66,6 @@ jobs:
PIIK_BUILD_VERSION: ${{ steps.release.outputs.version }}
VITE_PIIK_VERSION: ${{ steps.release.outputs.version }}
VITE_PIIK_REVISION: ${{ github.sha }}
- name: Cache Go toolchain
uses: actions/cache@v4
with:
path: ${{ runner.tool_cache }}/go
key: ${{ runner.os }}-go-1.26.6
- name: Set up Go
uses: actions/setup-go@v6
with:
@@ -102,11 +97,6 @@ jobs:
with:
node-version-file: .node-version
cache: npm
- name: Cache Go toolchain
uses: actions/cache@v4
with:
path: ${{ runner.tool_cache }}/go
key: ${{ runner.os }}-go-1.26.6
- name: Set up Go
uses: actions/setup-go@v6
with:
@@ -153,11 +143,6 @@ jobs:
with:
node-version-file: .node-version
cache: npm
- name: Cache Go toolchain
uses: actions/cache@v4
with:
path: ${{ runner.tool_cache }}/go
key: ${{ runner.os }}-go-1.26.6
- name: Set up Go
uses: actions/setup-go@v6
with:
+2 -2
View File
@@ -75,8 +75,8 @@ persistent quality evidence and route replacement. See
and [status](../../docs/status.md) for its acceptance limits.
The App configuration keeps an optional Local site-access password. Leave it
blank for an open Local site, or set a visible-ASCII password (8 to 128 bytes)
in the launcher before starting. When present, the App passes it to its own
blank for an open Local site, or enter a password in the launcher before
starting. When present, the App passes it to its own
Host page in a URL fragment; the page uses the existing SiteAccess endpoint and
removes the fragment before continuing. Viewer invitations keep using the
existing room-scoped grant.
+2 -1
View File
@@ -126,7 +126,8 @@ SHA, or a development build can choose the official release. These notices are
distinct; a different SHA alone is not called newer.
The request sends no current revision, credentials, room data, or media data;
network errors, private-repository responses, and missing releases are treated
as no notice. It never downloads, replaces, or interrupts a running share.
as no notice. Checking does not download an archive, replace files, or interrupt
a running share; the user can follow the update link to download the App ZIP.
An operator can perform the corresponding read-only Server check:
+2 -2
View File
@@ -32,8 +32,8 @@ over a reused comic's default; text and pure-visual tooltips retain that context
or a required user action. Ordinary quality warnings never cover playback.
Initial connection guidance is not an error even though it occupies the stage.
- Television: one media-status icon sits inside the lower television frame
(`lr-tv-chin`). Visual mode explains it with the existing comic tooltip;
text modes expose the specific localized status in the same paper tooltip.
(`lr-tv-chin`). Every mode explains it with the same comic tooltip;
text modes add the specific localized status beneath the scene.
Both reuse `Tooltip` for hover, keyboard focus, touch hold and viewport
placement; no native `title` competes with the custom status hint. There is no
permanent text bubble, additional round lamp, or repeated control-menu caption.
+5 -5
View File
@@ -133,11 +133,11 @@ are not automatically included in the website artifact.
The [website operations guide](../operations/website.md) owns Pages publication,
public destinations and the separate P2P-only demo. The primary action leads
to the platform download section. Build static package links from a published
GitHub release, showing its version and each supported platform. Gitee links must
match that release and its source revision. Keep release notes separate; when a
package is unverified, label the fallback as a release-page link. Archive names
include a revision. Verify destinations as part of publication.
to the platform download section. Each supported platform has a direct ZIP link
using GitHub's native latest-release asset route. Build verified Gitee links from
the matching release and source revision, showing the mirror's specific version.
Keep release notes separate; when a mirror package is unverified, label its
fallback as a release-page link. Verify destinations as part of publication.
Demo, App and existing-site walkthroughs
use real action names and explain the first sharing and viewing steps. Keep
platform requirements and connection limitations beside the relevant steps.
+8
View File
@@ -39,6 +39,14 @@ order and interaction ownership consistent across languages, themes and viewport
sizes. Reflow or bounded scrolling must not hide a primary action, truncate an
essential value, overlap controls or change meaning.
Tooltips and state overlays reuse the same illustrative scene in every mode.
Chinese and English add a concise caption beneath it; pure-visual mode keeps the
scene and its playful pictograms as an explicit alternative. Names, addresses and other literal
values without a corresponding scene remain text. Illustration and caption
explain the same action or current fact, with one tone and motion owner. Mount
scenes only while their tooltip or overlay is shown; captions remain readable
throughout.
Shared buttons and chips use pill shapes, a quiet lower edge and a short lift
on hover or keyboard focus, then compress on press. Inputs and option tiles
share softer corners. Keep a distinct focus ring and a persistent selected
+2 -2
View File
@@ -48,8 +48,8 @@ Run these commands from that directory as a regular user:
The server reads `.env` automatically and stores rooms in `rooms.sqlite` in the
working directory. A blank `SITE_ACCESS_PASSWORD` allows
entry without a site passphrase; set 8–128 visible ASCII characters to require
one. Room invitations and access settings still apply.
entry without a site passphrase; enter a passphrase to require one.
Room invitations and access settings still apply.
### 2. Enable HTTPS
+1 -1
View File
@@ -47,7 +47,7 @@ SITE_ACCESS_PASSWORD=
程序会自动读取 `.env`,并将房间数据保存在工作目录的 `rooms.sqlite` 中。
`SITE_ACCESS_PASSWORD` 留空时,进入站点无须口令;
如需设置口令,可填写 8–128 个可见 ASCII 字符。房间邀请与加入权限仍由房主管理。
如需设置口令,填写你想使用的口令即可。房间邀请与加入权限仍由房主管理。
### 2. 配置 HTTPS
+12 -8
View File
@@ -73,14 +73,18 @@ film's real product components into an isolated demonstration frame; publishing
ranges for seeking the soundtrack. Keep asset links relative so both a custom
domain and GitHub's `/Piik/` project path work.
Downloads are static links to the latest published GitHub packages, with explicit
Windows x64, macOS Apple silicon and Linux x64 ZIP cards. `PIIK_WEBSITE_RELEASE_DATA`
can point the build at a temporary JSON containing the GitHub release and the
same-tag Gitee release with its attachment list. CI supplies this input; the
builder validates version, source identity and platform URLs. A missing/incomplete
GitHub release fails deployment, preserving the current site. An unavailable
mirror retains a labeled release-page link. Local builds without metadata use
release-page links as well; no visitor API call or hard-coded package SHA is needed.
The Windows x64, macOS Apple silicon and Linux x64 ZIP cards use GitHub's native
[`releases/latest/download/asset-name.zip` links](https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases).
Fixed App filenames follow [versioning](../reference/versioning.md#release-and-recovery),
so these primary links also work in local builds and follow new releases without
a website rebuild. `PIIK_WEBSITE_RELEASE_DATA` points the build at temporary JSON
containing the published GitHub release and same-tag Gitee attachment metadata.
CI supplies this input; the builder validates source identity, uploaded ZIPs,
sizes and platform URLs. A missing/incomplete GitHub release fails deployment,
preserving the current site. Verified Gitee links show their specific version
and refresh after publication. Without matching metadata, Gitee retains its
labeled release-page link. The page makes no visitor API request or inferred
version claim when metadata is absent.
## Publish
+6 -1
View File
@@ -86,9 +86,14 @@ after two idle seconds, following [Media Chrome's default](https://github.com/mu
Pause or unavailable playback keeps it visible. Control hover, keyboard focus,
dragging and an open tooltip hold it open; mouse movement reveals it, and a
touch tap on the picture toggles only visibility. One presentation-only idle
timer owns this behavior; it never changes playback or route state. Controls
timer owns visibility; it never changes playback or route state. Controls
follow the shared [motion grammar](../design/visual-language.md#motion-grammar).
A desktop primary click on the picture toggles local playback after a brief
double-click window; double-click toggles the available fullscreen action without
first toggling playback. Controls, overlays and keyboard actions keep their own
immediate behavior.
Volume starts on native audio output. An explicit setting away from 100% activates
a local Web Audio stream source and gain, from silence to 200% amplitude. This also
avoids iPhone's system-owned `video.volume`. This output
+2 -2
View File
@@ -25,7 +25,7 @@ service secret store or an untracked access-restricted environment file.
| `PORT` | Positive TCP port, default `8787`; the tracked release wrapper supports only that default. |
| `PUBLIC_BASE_URL` | Exact public HTTP(S) origin; production requires HTTPS. |
| `ALLOWED_ORIGINS` | Comma-separated exact HTTP(S) origins; wildcard is invalid. |
| `SITE_ACCESS_PASSWORD` | Optional in every environment. Unset or empty allows entry without a site password. A non-empty value must contain 8-128 visible ASCII bytes; room ownership and Viewer admission remain independent. |
| `SITE_ACCESS_PASSWORD` | Optional in every environment. Unset or empty allows entry without a site password. A configured value is matched exactly, including spaces and Unicode; there are no password length or character rules. General HTTP request limits still apply. Room ownership and Viewer admission remain independent. |
| `ROOM_DATABASE_PATH` | Hosted defaults to `rooms.sqlite` in its working directory when unset or blank. An explicit absolute file path selects another SQLite file; `:memory:` opts into process-memory room authority. App Local remains in memory. |
| `MAX_VIEWERS_PER_ROOM` | `1..20`, default `8`. |
| `ENDPOINT_MEDIA_COPY_CAPACITY` | Shared endpoint steady-copy cap `1..3`, default `2`. |
@@ -75,7 +75,7 @@ schema field. The user settings are:
| Setting | Contract |
| --- | --- |
| `site` | Saved Piik Site origin; omit it for Local mode. The launcher or `--site` updates it. |
| `localAccessPassword` | Empty by default. Optional password for the App's Local room authority, using the same 8-128 visible ASCII bounds. It is separate from a hosted site's password. |
| `localAccessPassword` | Empty by default. Optional password for the App's Local room authority, with the same exact-match behavior as `SITE_ACCESS_PASSWORD`. It is separate from a hosted site's password. |
Command-line options select entry and local runtime behavior:
+12 -1
View File
@@ -88,7 +88,18 @@ revision. Do not maintain independent hand-edited App/Server/Web version files.
Archive layout and checks stay in the
existing packagers; a new build never replaces an already-published package.
Update checks compare actual release precedence and link to the matching release.
App archive names are fixed per target: `piik-app-{target}.zip`. Each published
tag owns its archive bytes; the full source SHA remains in `REVISION` and the
release descriptor. Fixed names allow GitHub's native latest-asset links;
[website operations](../operations/website.md#preview) owns download presentation
and verification of the matching Gitee mirror.
Update checks compare actual release precedence. The App reports its compiled
OS/architecture and links to that platform's ZIP in the selected release metadata;
the release page remains the fallback when no matching attachment is available.
Fixed archive names and published names with that release's SHA suffix are
accepted only with the provider's exact release download URL. Downloads start on
click; checking for an update does not fetch the archive.
Newer version wins even when the source SHA is unchanged. Same version with a
different known SHA reports a different official build, not a newer one. A
development build with a known differing SHA offers the official release as a
+18 -8
View File
@@ -1,6 +1,6 @@
# Current TODO Ledger
Last reviewed: 2026-09-12
Last reviewed: 2026-09-13
Only **Now** is executable. Product modules own behavior; Git/PRs own completed
history. A parked idea is not implementation authority.
@@ -8,7 +8,9 @@ history. A parked idea is not implementation authority.
## Now
- [ ] **Post-launch monitoring.** Collect App/Server feedback and verify the
public download path after product releases. Preserve the owner's
public download path and deployed services after product releases. Follow the
[deployment runbook](./deployment.md) for the private service and keep public
demo deployment separate. Preserve the owner's
[device/network deferrals](./verification-status.md#candidate-evidence-boundary).
Keep fixes on a maintenance branch until acceptance. The public release is the
@@ -80,12 +82,20 @@ Host is paused. No preservation policy has been accepted for that transition.
direction with original Piik assets. Assess extension boundaries and cost
before scheduling any theme/plugin API. Design experiments remain in Git
history, outside the main source tree.
8. **Visual presentation and community translations.** In a later version,
explore bringing the pure-visual mode's illustrations and motion into the
Chinese and English interfaces. Evaluate whether a separate pure-visual mode
remains useful, and prepare the existing locale catalogs for community
translations. No removal or new translation framework is scheduled for this
release.
8. **Community translations.** Prepare the existing locale catalogs for
community contributions when scheduled. No new translation framework is
needed for the current Chinese, English and optional pure-visual modes.
9. **Website illustration polish.** Match the Host's held prop to the four
existing hero activities: gamepad, paintbrush, camera and remote. Keep the
accepted bright cast, floating hands and shared activity timing.
10. **First-share startup quality.** Verify that the existing five-frame startup
protection for `motion + balanced` still takes effect on every active sender
and shared-encoding path. Compare with the documented
[startup regression](./research/realtime-quality-adaptation.md) and check
whether later audits or refactors bypassed a necessary guard. Do not remove
the workaround without evidence that first-share quality remains correct.
11. **32-bit App packages.** Deferred until suitable native capture dependencies
are available. Windows x86 core compilation alone does not establish App
support: the pinned capture SDK currently has no Windows x86 package.
Complete native packaging and real launch/capture acceptance before
advertising a 32-bit target.
+2 -2
View File
@@ -202,14 +202,14 @@ func TestLaunchURLPreservesLocalAccessInsideThePrivateFragment(t *testing.T) {
func TestLaunchURLEncodesAndClearsOptionalLocalAccess(t *testing.T) {
value := launchURLWithLocalAccess(
"http://localhost:8787/#retained=yes&client-access=old",
"a+b&c?d=e",
" 中文 a+b&c?d=e ",
)
parsed, err := url.Parse(value)
if err != nil {
t.Fatal(err)
}
fragment, err := url.ParseQuery(parsed.Fragment)
if err != nil || fragment.Get("client-access") != "a+b&c?d=e" ||
if err != nil || fragment.Get("client-access") != " 中文 a+b&c?d=e " ||
fragment.Get("piik-client") != "1" || fragment.Get("retained") != "yes" {
t.Fatalf("encoded local launch fragment = %q, %v", parsed.Fragment, err)
}
+1 -30
View File
@@ -9,19 +9,12 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
serverconfig "github.com/TNTcraftHIM/Piik/internal/server/config"
)
const (
currentVersion = 1
minAccessPasswordBytes = 8
maxAccessPasswordBytes = 128
)
var passwordPattern = regexp.MustCompile(`^[\x21-\x7e]{8,128}$`)
const currentVersion = 1
type Config struct {
Version int `json:"version"`
@@ -106,20 +99,6 @@ func NormalizeSite(value string) (string, error) {
return serverconfig.Origin(parsed), nil
}
// NormalizeLocalAccessPassword keeps the local authority open by default while
// retaining a bounded, user-chosen password when one is supplied.
func NormalizeLocalAccessPassword(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
if len(value) < minAccessPasswordBytes || len(value) > maxAccessPasswordBytes ||
!passwordPattern.MatchString(value) {
return "", errors.New("Local access password must contain 8 to 128 visible ASCII bytes")
}
return value, nil
}
func load(path string) (Config, error) {
payload, err := os.ReadFile(path)
if err != nil {
@@ -132,10 +111,6 @@ func load(path string) (Config, error) {
config.Version != currentVersion {
return Config{}, errors.New("App configuration is invalid")
}
normalizedPassword, passwordErr := NormalizeLocalAccessPassword(config.LocalAccessPassword)
if passwordErr != nil || normalizedPassword != config.LocalAccessPassword {
return Config{}, errors.New("App configuration is invalid")
}
if config.Site, err = NormalizeSite(config.Site); err != nil {
return Config{}, errors.New("App configuration is invalid")
}
@@ -146,10 +121,6 @@ func encode(config Config) ([]byte, error) {
if config.Version != currentVersion {
return nil, errors.New("App configuration is invalid")
}
password, passwordErr := NormalizeLocalAccessPassword(config.LocalAccessPassword)
if passwordErr != nil || password != config.LocalAccessPassword {
return nil, errors.New("App configuration is invalid")
}
site, err := NormalizeSite(config.Site)
if err != nil || site != config.Site {
return nil, errors.New("App configuration is invalid")
+9 -9
View File
@@ -23,15 +23,15 @@ func TestLoadOrCreatePersistsOneValidConfiguration(t *testing.T) {
}
}
func TestUserChosenLocalAccessPasswordIsBounded(t *testing.T) {
for _, value := range []string{"", "valid-local-password", strings.Repeat("x", 128)} {
if normalized, err := NormalizeLocalAccessPassword(value); err != nil || normalized != value {
t.Fatalf("NormalizeLocalAccessPassword(%q) = %q, %v", value, normalized, err)
func TestUserChosenLocalAccessPasswordIsPreserved(t *testing.T) {
path := filepath.Join(t.TempDir(), "client.json")
for _, value := range []string{"", "x", "中文", " ", " 中文 +& ", strings.Repeat("x", 256)} {
if err := Save(path, Config{Version: currentVersion, LocalAccessPassword: value}); err != nil {
t.Fatal(err)
}
}
for _, value := range []string{"short", strings.Repeat("x", 129), "has space"} {
if _, err := NormalizeLocalAccessPassword(value); err == nil {
t.Fatalf("invalid local password accepted: %q", value)
loaded, err := LoadOrCreate(path)
if err != nil || loaded.LocalAccessPassword != value {
t.Fatalf("saved password changed: %v", err)
}
}
}
@@ -86,7 +86,7 @@ func TestLoadOrCreateRejectsUnknownOrMalformedState(t *testing.T) {
for name, payload := range map[string]string{
"unknown": `{"version":1,"localAccessPassword":"abcdefghijklmnopqrstuvwxyzABCDEF","extra":true}`,
"version": `{"version":2,"localAccessPassword":"abcdefghijklmnopqrstuvwxyzABCDEF"}`,
"password": `{"version":1,"localAccessPassword":"short"}`,
"password": `{"version":1,"localAccessPassword":123}`,
"trailing": `{"version":1,"localAccessPassword":"abcdefghijklmnopqrstuvwxyzABCDEF"} trailing`,
} {
t.Run(name, func(t *testing.T) {
+3 -12
View File
@@ -9,6 +9,7 @@ import (
"net"
"net/http"
"net/url"
"runtime"
"strings"
"sync"
"time"
@@ -78,10 +79,6 @@ func Start(parent context.Context, assets fs.FS, site, version, revision, localA
return nil, errors.New("App launcher Site is invalid")
}
site = normalizedSite
normalizedPassword, err := appconfig.NormalizeLocalAccessPassword(localAccessPassword)
if err != nil {
return nil, errors.New("App launcher local access password is invalid")
}
if assets == nil {
return nil, errors.New("App launcher assets are unavailable")
}
@@ -100,7 +97,7 @@ func Start(parent context.Context, assets fs.FS, site, version, revision, localA
assets: assets,
static: http.FileServer(http.FS(assets)),
site: site,
localAccessPassword: normalizedPassword,
localAccessPassword: localAccessPassword,
version: version,
revision: strings.TrimSpace(revision),
selection: make(chan Selection, 1),
@@ -205,6 +202,7 @@ func (server *Server) handleState(response http.ResponseWriter, request *http.Re
"localAccessPassword": server.localAccessPassword,
"version": server.version,
"revision": server.revision,
"packageTarget": runtime.GOOS + "-" + runtime.GOARCH,
"defaultMode": func() Mode {
if server.site != "" {
return ModeSite
@@ -313,13 +311,6 @@ func decodeSelection(reader io.Reader) (Selection, error) {
return Selection{}, errors.New("local launch cannot include a Site")
}
selection.Site = ""
normalizedPassword, err := appconfig.NormalizeLocalAccessPassword(
selection.LocalAccessPassword,
)
if err != nil {
return Selection{}, errors.New("local access password is invalid")
}
selection.LocalAccessPassword = normalizedPassword
case ModeSite:
if selection.LocalAccessPassword != "" {
return Selection{}, errors.New("Site launch cannot include a local access password")
+60 -49
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"io/fs"
"net/http"
"runtime"
"strings"
"testing"
"testing/fstest"
@@ -92,60 +93,70 @@ func TestLauncherIncludesTheInjectedBuildVersionAndRevision(t *testing.T) {
}
defer response.Body.Close()
var state struct {
Version string `json:"version"`
Revision string `json:"revision"`
Version string `json:"version"`
Revision string `json:"revision"`
PackageTarget string `json:"packageTarget"`
}
if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&state) != nil ||
state.Version != version || state.Revision != revision {
state.Version != version || state.Revision != revision ||
state.PackageTarget != runtime.GOOS+"-"+runtime.GOARCH {
t.Fatalf("launcher build = %d, %+v", response.StatusCode, state)
}
}
func TestLauncherCarriesAndNormalizesAnOptionalLocalPassword(t *testing.T) {
server := startFixtureWithPassword(t, "", "existing-pass")
origin := strings.TrimSuffix(server.URL(), "/client")
stateResponse, err := http.Get(origin + "/api/client-launcher")
if err != nil {
t.Fatal(err)
}
var state struct {
LocalAccessPassword string `json:"localAccessPassword"`
}
if stateResponse.StatusCode != http.StatusOK ||
json.NewDecoder(stateResponse.Body).Decode(&state) != nil {
_ = stateResponse.Body.Close()
t.Fatal("launcher state was unavailable")
}
_ = stateResponse.Body.Close()
if state.LocalAccessPassword != "existing-pass" {
t.Fatal("launcher did not expose the saved Local access setting")
}
result := make(chan *http.Response, 1)
go func() {
response, err := http.Post(
origin+"/api/client-launcher/launch",
"application/json",
bytes.NewBufferString(`{"mode":"local","language":"zh","localAccessPassword":"new-local-pass"}`),
)
if err != nil {
t.Error(err)
return
}
result <- response
}()
select {
case selection := <-server.Selection():
if selection.Mode != ModeLocal || selection.Language != "zh" || selection.LocalAccessPassword != "new-local-pass" {
t.Fatalf("selection = %+v", selection)
}
server.SetResult("http://localhost:8787/#piik-client=1", nil)
case <-time.After(time.Second):
t.Fatal("launcher did not emit a selection")
}
response := <-result
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("launch status = %d", response.StatusCode)
func TestLauncherPreservesAnOptionalLocalPassword(t *testing.T) {
for _, password := range []string{"", "x", "中文", " ", " 中文 +& ", strings.Repeat("x", 256)} {
t.Run(password, func(t *testing.T) {
server := startFixtureWithPassword(t, "", password)
origin := strings.TrimSuffix(server.URL(), "/client")
stateResponse, err := http.Get(origin + "/api/client-launcher")
if err != nil {
t.Fatal(err)
}
var state struct {
LocalAccessPassword string `json:"localAccessPassword"`
}
if stateResponse.StatusCode != http.StatusOK ||
json.NewDecoder(stateResponse.Body).Decode(&state) != nil {
_ = stateResponse.Body.Close()
t.Fatal("launcher state was unavailable")
}
_ = stateResponse.Body.Close()
if state.LocalAccessPassword != password {
t.Fatal("launcher did not expose the saved Local access setting")
}
result := make(chan *http.Response, 1)
payload, err := json.Marshal(Selection{Mode: ModeLocal, Language: "zh", LocalAccessPassword: password})
if err != nil {
t.Fatal(err)
}
go func() {
response, err := http.Post(
origin+"/api/client-launcher/launch",
"application/json",
bytes.NewReader(payload),
)
if err != nil {
t.Error(err)
return
}
result <- response
}()
select {
case selection := <-server.Selection():
if selection.Mode != ModeLocal || selection.Language != "zh" || selection.LocalAccessPassword != password {
t.Fatalf("selection = %+v", selection)
}
server.SetResult("http://localhost:8787/#piik-client=1", nil)
case <-time.After(time.Second):
t.Fatal("launcher did not emit a selection")
}
response := <-result
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("launch status = %d", response.StatusCode)
}
})
}
}
@@ -173,7 +184,7 @@ func TestLauncherRejectsInvalidSelections(t *testing.T) {
"site-path": `{"mode":"site","language":"en","site":"https://share.example/path"}`,
"site-password": `{"mode":"site","language":"en","site":"https://share.example","localAccessPassword":"valid-pass"}`,
"unknown-field": `{"mode":"local","language":"en","extra":true}`,
"short-password": `{"mode":"local","language":"en","localAccessPassword":"short"}`,
"password-type": `{"mode":"local","language":"en","localAccessPassword":123}`,
"missing-language": `{"mode":"local"}`,
"unknown-language": `{"mode":"local","language":"other"}`,
} {
+45 -2
View File
@@ -632,7 +632,7 @@ func TestLocalPasswordUsesConfiguredDestinationCookieAcrossLANAndPublicLink(t *t
}
}
func TestSiteAccessRequiresAnAllowedOriginAndAnEmptyLoginBody(t *testing.T) {
func TestSiteAccessRequiresAnAllowedOriginAndAValidLoginBody(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
forbidden := `{"error":"Forbidden"}`
@@ -647,13 +647,53 @@ func TestSiteAccessRequiresAnAllowedOriginAndAnEmptyLoginBody(t *testing.T) {
server.do(http.MethodPost, "/api/site-access",
withBearer(testAccessPassword), withOrigin(allowedOrigin),
withBody("text/plain;charset=UTF-8", "{}")).
expect(http.StatusBadRequest, `{"error":"Request body is not accepted"}`)
expect(http.StatusBadRequest, `{"error":"Invalid site access request"}`)
for _, payload := range []string{
`{`, `{}`, `null`, `[]`, `{"password":null}`, `{"password":123}`,
`{"password":"wrong","extra":true}`, `{"password":"wrong"} {}`,
`{"password":"` + strings.Repeat("x", maxJSONRequestBytes) + `"}`,
} {
server.do(http.MethodPost, "/api/site-access", withOrigin(allowedOrigin),
withBearer(testAccessPassword), withJSON(payload)).
expect(http.StatusBadRequest, `{"error":"Invalid site access request"}`)
}
server.do(http.MethodPost, "/api/site-access", withOrigin(allowedOrigin),
withBearer(testAccessPassword), withJSON(`{"password":"wrong"}`)).
expectStatus(http.StatusUnauthorized)
server.do(http.MethodDelete, "/api/site-access", withOrigin(allowedOrigin)).
expect(http.StatusMethodNotAllowed, `{"error":"Method not allowed"}`).
expectHeader("Allow", "GET, POST")
}
func TestLocalSiteAcceptsTheExactPasswordThroughJSON(t *testing.T) {
for index, password := range []string{"x", "中文", " ", " 中文 +& ", strings.Repeat("x", 256)} {
t.Run(fmt.Sprint(index), func(t *testing.T) {
configuration, err := config.Local(config.LocalOptions{
Port: freePort(t), PublicAddress: "192.0.2.10", SiteAccessPassword: password,
})
if err != nil {
t.Fatal(err)
}
server := start(t, Options{Config: configuration})
origin := config.Origin(configuration.PublicBaseURL)
for _, wrong := range []string{"", password + "!"} {
payload, _ := json.Marshal(map[string]string{"password": wrong})
server.do(http.MethodPost, "/api/site-access", withOrigin(origin), withJSON(string(payload))).
expectStatus(http.StatusUnauthorized).expectHeader("Set-Cookie", "")
}
payload, _ := json.Marshal(map[string]string{"password": password})
login := server.do(http.MethodPost, "/api/site-access", withOrigin(origin), withJSON(string(payload))).
expect(http.StatusOK, `{"required":true,"authenticated":true}`)
cookie := cookiePair(t, login.header.Get("Set-Cookie"))
server.do(http.MethodGet, "/api/site-access", withCookie(cookie)).
expect(http.StatusOK, `{"required":true,"authenticated":true}`)
server.do(http.MethodPost, "/api/rooms", withOrigin(origin), withCookie(cookie),
withJSON(`{"codeEntryPolicy":"open"}`)).expectStatus(http.StatusCreated)
})
}
}
func TestSiteAccessRejectsAnExpiredOrModifiedCookie(t *testing.T) {
var now atomic.Int64
now.Store(1_000)
@@ -686,6 +726,9 @@ func TestSiteAccessIsImmediateWhenTheAccessPasswordIsEmpty(t *testing.T) {
server.do(http.MethodPost, "/api/site-access", withOrigin(allowedOrigin)).
expect(http.StatusOK, `{"required":false,"authenticated":true}`).
expectHeader("Set-Cookie", "")
server.do(http.MethodPost, "/api/site-access", withOrigin(allowedOrigin), withJSON(`{"password":""}`)).
expect(http.StatusOK, `{"required":false,"authenticated":true}`).
expectHeader("Set-Cookie", "")
})
}
}
+14 -3
View File
@@ -1,6 +1,7 @@
package app
import (
"encoding/json"
"errors"
"net/http"
"net/url"
@@ -168,15 +169,25 @@ func (s *Server) handleSiteAccess(writer http.ResponseWriter, request *http.Requ
sendJSON(writer, http.StatusForbidden, errorBody{"Forbidden"})
return
}
// Released clients send a bodyless Bearer request. JSON preserves passwords
// that cannot be represented in an HTTP header and takes precedence when sent.
provided := bearerToken(request)
if hasRequestBody(request) {
sendJSON(writer, http.StatusBadRequest, errorBody{"Request body is not accepted"})
return
body, err := readJSONBody(request)
var fields map[string]json.RawMessage
var password *string
if err != nil || json.Unmarshal(body, &fields) != nil || len(fields) != 1 ||
json.Unmarshal(fields["password"], &password) != nil || password == nil {
sendJSON(writer, http.StatusBadRequest, errorBody{"Invalid site access request"})
return
}
provided = *password
}
if !access.required() {
sendJSON(writer, http.StatusOK, siteAccessBody{Required: false, Authenticated: true})
return
}
if provided := bearerToken(request); provided == "" || !access.passwordMatches(provided) {
if !access.passwordMatches(provided) {
writer.Header().Set("WWW-Authenticate", "Bearer")
sendJSON(writer, http.StatusUnauthorized, errorBody{"Unauthorized"})
return
+3 -17
View File
@@ -28,16 +28,11 @@ const (
// Bounds and defaults for Hosted configuration.
const (
minSiteAccessPasswordBytes = 8
maxSiteAccessPasswordBytes = 128
defaultMaxViewersPerRoom = 8
defaultPort = 8787
maxPort = 65_535
defaultMaxViewersPerRoom = 8
defaultPort = 8787
maxPort = 65_535
)
// VISIBLE_ASCII_PATTERN.
var visibleASCIIPattern = regexp.MustCompile(`^[\x21-\x7e]+$`)
const removedTurnReason = "ordinary ICE accepts STUN_URLS only"
// removedEnvironmentVariables names settings with no current equivalent. An
@@ -184,15 +179,6 @@ func Load(env map[string]string) (Config, error) {
return Config{}, err
}
// Buffer.byteLength is the UTF-8 length, which is len() in Go. The pattern
// already restricts the value to one byte per character.
if siteAccessPassword != "" &&
(!visibleASCIIPattern.MatchString(siteAccessPassword) ||
len(siteAccessPassword) < minSiteAccessPasswordBytes ||
len(siteAccessPassword) > maxSiteAccessPasswordBytes) {
return Config{}, fmt.Errorf("SITE_ACCESS_PASSWORD must contain %d to %d visible ASCII bytes",
minSiteAccessPasswordBytes, maxSiteAccessPasswordBytes)
}
if environment == EnvironmentProduction && len(stunURLs) == 0 {
return Config{}, errors.New("STUN is required in production")
}
+11 -10
View File
@@ -113,6 +113,17 @@ func TestLoadProductionAllowsAnUnsetOrEmptySiteAccessPassword(t *testing.T) {
}
}
func TestLoadPreservesTheChosenSiteAccessPassword(t *testing.T) {
for _, base := range []map[string]string{nil, productionBase} {
for _, password := range []string{"", "x", "中文", " ", " 中文 +& ", strings.Repeat("x", 256)} {
configuration := mustLoad(t, env(base, map[string]string{"SITE_ACCESS_PASSWORD": password}))
if configuration.SiteAccessPassword != password {
t.Fatal("site password changed")
}
}
}
}
func TestLoadRoomStorage(t *testing.T) {
directory := t.TempDir()
t.Chdir(directory)
@@ -393,16 +404,6 @@ func TestLoadRejects(t *testing.T) {
"SITE_ACCESS_PASSWORD": "host-password-12",
}, "STUN is required in production"},
// Site access password bounds (UTF-8 bytes).
{"site password too short", map[string]string{"SITE_ACCESS_PASSWORD": strings.Repeat("x", 7)},
"SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"},
{"site password outside ASCII", map[string]string{"SITE_ACCESS_PASSWORD": "密码密码密码密码"},
"SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"},
{"site password with spaces", map[string]string{"SITE_ACCESS_PASSWORD": "contains spaces"},
"SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"},
{"site password too long", map[string]string{"SITE_ACCESS_PASSWORD": strings.Repeat("x", 129)},
"SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"},
// STUN lists.
{"STUN path", map[string]string{"STUN_URLS": "stun:stun.test/path"},
"STUN_URLS contains an invalid STUN URL"},
+1 -10
View File
@@ -5,16 +5,12 @@ import (
"fmt"
"net"
"net/url"
"regexp"
"slices"
"strings"
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
// localPasswordPattern admits 8..128 one-byte visible ASCII characters.
var localPasswordPattern = regexp.MustCompile(`^[\x21-\x7e]{8,128}$`)
// LocalOptions describes App-selected Local composition. It never reads the
// process environment.
type LocalOptions struct {
@@ -44,11 +40,6 @@ func Local(options LocalOptions) (Config, error) {
if publicAddress == "0.0.0.0" || strings.HasPrefix(publicAddress, "127.") {
return Config{}, errors.New("Local server public address must be reachable from the LAN")
}
siteAccessPassword := strings.TrimFunc(options.SiteAccessPassword, protocol.IsJSWhitespace)
if siteAccessPassword != "" && !localPasswordPattern.MatchString(siteAccessPassword) {
return Config{}, errors.New("Local access password must contain 8 to 128 visible ASCII bytes")
}
allowedAddresses := []string{publicAddress}
for _, address := range options.AllowedAddresses {
normalized, err := localIPv4(address, "allowed address")
@@ -97,7 +88,7 @@ func Local(options LocalOptions) (Config, error) {
ListenHost: "0.0.0.0",
PublicBaseURL: publicBaseURL,
AllowedOrigins: allowedOrigins,
SiteAccessPassword: siteAccessPassword,
SiteAccessPassword: options.SiteAccessPassword,
MaxViewersPerRoom: protocol.MaxViewersPerRoomLimit,
EndpointMediaCopyCapacity: protocol.DefaultEndpointMediaCopyCapacity,
STUNURLs: stunURLs,
+9 -8
View File
@@ -136,6 +136,15 @@ func TestLocalDefaults(t *testing.T) {
}
}
func TestLocalPreservesTheChosenPassword(t *testing.T) {
for _, password := range []string{"", "x", "中文", " ", " 中文 +& ", strings.Repeat("x", 256)} {
config := mustLocal(t, LocalOptions{PublicAddress: "192.0.2.10", SiteAccessPassword: password})
if config.SiteAccessPassword != password {
t.Fatal("Local password changed")
}
}
}
func TestLocalRejects(t *testing.T) {
cases := []struct {
name string
@@ -153,14 +162,6 @@ func TestLocalRejects(t *testing.T) {
{"unspecified public address", LocalOptions{PublicAddress: "0.0.0.0",
SiteAccessPassword: "valid-password"},
"Local server public address must be reachable from the LAN"},
{"short password", LocalOptions{PublicAddress: "192.168.1.2", SiteAccessPassword: "short"},
"Local access password must contain 8 to 128 visible ASCII bytes"},
{"password outside ASCII", LocalOptions{PublicAddress: "192.168.1.2",
SiteAccessPassword: "密码密码密码密码"},
"Local access password must contain 8 to 128 visible ASCII bytes"},
{"long password", LocalOptions{PublicAddress: "192.168.1.2",
SiteAccessPassword: strings.Repeat("x", 129)},
"Local access password must contain 8 to 128 visible ASCII bytes"},
{"port above the TCP ceiling", LocalOptions{Port: 70_000, PublicAddress: "192.168.1.2"},
"Local server port must be an integer between 1 and 65535"},
{"negative port", LocalOptions{Port: -1, PublicAddress: "192.168.1.2"},
+4 -11
View File
@@ -39,22 +39,15 @@ let homepage = (await readFile(new URL("index.html", source), "utf8"))
if (process.env.PIIK_WEBSITE_RELEASE_DATA) {
const { websiteDownloads } = await tsImport("../site/downloads.ts", { parentURL: import.meta.url });
const release = websiteDownloads(JSON.parse(await readFile(process.env.PIIK_WEBSITE_RELEASE_DATA, "utf8")));
homepage = homepage.replace(/<p class="release-note" id="download-status">[\s\S]*?<\/p>/,
`<p class="release-note" id="download-status"><span class="release-label">${release.version}</span>` +
`<span lang="en">Download the ZIP for your system, then extract it.</span>` +
`<span lang="zh-CN">选择对应系统,下载 ZIP 后解压。</span>` +
`<a href="${release.notes}"><span lang="en">Release notes</span><span lang="zh-CN">版本说明</span></a></p>`);
homepage = homepage.replace(/<a\b([^>]*data-download="([^"]+)"[^>]*data-provider="([^"]+)"[^>]*)>([\s\S]*?)<\/a\s*>/g,
(original, attributes, target, provider, content) => {
const item = release.packages.find(item => item.target === target);
const href = provider === 'github' ? item?.url : item?.mirrorURL;
if (!href) return original;
const label = provider === 'github'
? { en: 'Download ZIP · GitHub', zh: '下载 ZIP · GitHub' }
: { en: 'Gitee alternative (ZIP)', zh: 'Gitee 备用下载(ZIP)' };
return `<a${attributes.replace(/href="[^"]*"/, `href="${href}"`)}>` + content
.replace(/<span lang="en">[\s\S]*?<\/span\s*>/, `<span lang="en">${label.en}</span>`)
.replace(/<span lang="zh-CN">[\s\S]*?<\/span\s*>/, `<span lang="zh-CN">${label.zh}</span>`) + '</a>';
if (provider === 'gitee') content = content
.replace(/<span lang="en">[\s\S]*?<\/span\s*>/, `<span lang="en">Gitee alternative · ${release.version} ZIP</span>`)
.replace(/<span lang="zh-CN">[\s\S]*?<\/span\s*>/, `<span lang="zh-CN">Gitee 备用 · ${release.version} ZIP</span>`);
return `<a${attributes.replace(/href="[^"]*"/, `href="${href}"`)}>${content}</a>`;
});
}
await writeFile(new URL("index.html", output), homepage.replace(
+1 -2
View File
@@ -368,8 +368,7 @@ try {
run(process.execPath, assembleArguments, repositoryRoot);
mkdirSync(outputRoot, { recursive: false, mode: 0o700 });
const shortRevision = revision.slice(0, 7);
const archiveName = `piik-app-${target.id}-${shortRevision}.zip`;
const archiveName = `piik-app-${target.id}.zip`;
const archive = join(outputRoot, archiveName);
createZip(packageRoot, archive);
const extractedRoot = join(temporaryRoot, "extracted");
+5 -5
View File
@@ -19,19 +19,19 @@ export function websiteDownloads(data: { github: unknown; mirror?: unknown }) {
? assets(data.mirror) : [];
return {
version: release.version,
notes: release.url,
packages: targets.map(target => {
const name = `piik-app-${target}-${release.revision!.slice(0, 7)}.zip`;
const url = `https://github.com/TNTcraftHIM/Piik/releases/download/${release.version}/${name}`;
const name = `piik-app-${target}.zip`;
const publishedURL = `https://github.com/TNTcraftHIM/Piik/releases/download/${release.version}/${name}`;
const matches = primaryAssets.filter(asset => asset.name === name);
const asset = matches[0];
if (matches.length !== 1 || asset?.state !== "uploaded" || asset.browser_download_url !== url ||
if (matches.length !== 1 || asset?.state !== "uploaded" || asset.browser_download_url !== publishedURL ||
!Number.isSafeInteger(asset.size) || Number(asset.size) <= 0) {
throw new Error(`Website download is missing or invalid: ${target}`);
}
const mirrorURL = `https://gitee.com/TNTcraftHIM/Piik/releases/download/${release.version}/${name}`;
const mirrored = mirrorAssets.filter(candidate => candidate.name === name);
return { target, url, mirrorURL: mirrored.length === 1 && mirrored[0]!.size === asset.size &&
return { target, url: `https://github.com/TNTcraftHIM/Piik/releases/latest/download/${name}`,
mirrorURL: mirrored.length === 1 && mirrored[0]!.size === asset.size &&
mirrored[0]!.browser_download_url === mirrorURL ? mirrorURL : null };
}),
};
+12 -11
View File
@@ -716,8 +716,9 @@
</p>
</div>
<p class="release-note" id="download-status">
<span lang="en">Choose Piik App for your system on the release page.</span>
<span lang="zh-CN">在发布页选择对应系统的 Piik App 程序包。</span>
<span lang="en">Download the ZIP for your system, then extract it.</span>
<span lang="zh-CN">选择对应系统,下载 ZIP 后解压。</span>
<a href="https://github.com/TNTcraftHIM/Piik/releases/latest"><span lang="en">Release notes</span><span lang="zh-CN">版本说明</span></a>
</p>
<div class="download-cards">
<article class="download-card" aria-labelledby="download-windows">
@@ -733,10 +734,10 @@
<a
class="button download-button"
data-download="windows-amd64" data-provider="github"
href="https://github.com/TNTcraftHIM/Piik/releases"
href="https://github.com/TNTcraftHIM/Piik/releases/latest/download/piik-app-windows-amd64.zip"
aria-describedby="download-status"
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">View GitHub releases</span
><span lang="zh-CN">查看 GitHub 版本</span></a
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">Download ZIP · GitHub</span
><span lang="zh-CN">下载 ZIP · GitHub</span></a
><a data-download="windows-amd64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases"
><span lang="en">View Gitee mirror</span><span lang="zh-CN">查看 Gitee 镜像</span></a
>
@@ -763,10 +764,10 @@
<a
class="button download-button"
data-download="darwin-arm64" data-provider="github"
href="https://github.com/TNTcraftHIM/Piik/releases"
href="https://github.com/TNTcraftHIM/Piik/releases/latest/download/piik-app-darwin-arm64.zip"
aria-describedby="download-status"
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">View GitHub releases</span
><span lang="zh-CN">查看 GitHub 版本</span></a
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">Download ZIP · GitHub</span
><span lang="zh-CN">下载 ZIP · GitHub</span></a
><a data-download="darwin-arm64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases"
><span lang="en">View Gitee mirror</span><span lang="zh-CN">查看 Gitee 镜像</span></a
>
@@ -792,10 +793,10 @@
<a
class="button download-button"
data-download="linux-amd64" data-provider="github"
href="https://github.com/TNTcraftHIM/Piik/releases"
href="https://github.com/TNTcraftHIM/Piik/releases/latest/download/piik-app-linux-amd64.zip"
aria-describedby="download-status"
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">View GitHub releases</span
><span lang="zh-CN">查看 GitHub 版本</span></a
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">Download ZIP · GitHub</span
><span lang="zh-CN">下载 ZIP · GitHub</span></a
><a data-download="linux-amd64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases"
><span lang="en">View Gitee mirror</span><span lang="zh-CN">查看 Gitee 镜像</span></a
>
+1 -1
View File
@@ -369,7 +369,7 @@ function SiteAccessGate({
event.preventDefault();
const submittedPassword = password;
setPassword("");
if (!submittedPassword.trim()) {
if (!submittedPassword) {
setAccess({ kind: "required", error: t("gate.hint") });
return;
}
@@ -67,7 +67,6 @@ export function LauncherForm({
<input
type="text"
value={localAccessPassword}
maxLength={128}
autoComplete="off"
spellCheck={false}
placeholder={vis ? "" : t("client.launch.localAccess")}
@@ -5,6 +5,7 @@ import { Btn } from "./primitives";
import { Tooltip } from "./Tooltip";
import { usePlaybackControlsVisibility } from "./use-playback-controls-visibility";
import { leavePictureInPicture, usePictureInPicture } from "./use-picture-in-picture";
import { bindPlaybackGestures } from "./playback-gestures";
import "./playback-controls.css";
type FullscreenVideo = HTMLVideoElement & {
@@ -51,8 +52,22 @@ export function PlaybackControls({
video.pause();
}
};
const togglePlaybackRef = useRef(togglePlayback);
togglePlaybackRef.current = togglePlayback;
const toggleFullscreen = () => {
if (!canFullscreen || theaterMode) return;
const video = videoRef.current as FullscreenVideo | null;
if (fullscreen) {
void document.exitFullscreen().catch(() => undefined);
} else if (document.fullscreenEnabled && video?.parentElement) {
void video.parentElement.requestFullscreen()
.then(() => leavePictureInPicture(video))
.catch(() => undefined);
} else {
audioRef.current?.useNativeControls();
video?.webkitEnterFullscreen?.();
}
};
const actionsRef = useRef({ togglePlayback, toggleFullscreen });
actionsRef.current = { togglePlayback, toggleFullscreen };
useEffect(() => {
const video = videoRef.current as FullscreenVideo | null;
@@ -64,7 +79,7 @@ export function PlaybackControls({
const onKey = (event: KeyboardEvent) => {
if (event.code !== "Space" || event.target !== video) return;
event.preventDefault();
togglePlaybackRef.current();
actionsRef.current.togglePlayback();
};
syncPlayback();
syncFullscreen();
@@ -72,7 +87,11 @@ export function PlaybackControls({
for (const event of ["play", "pause", "ended", "emptied"]) video.addEventListener(event, syncPlayback);
video.addEventListener("keydown", onKey);
document.addEventListener("fullscreenchange", syncFullscreen);
const unbindGestures = bindPlaybackGestures(video,
() => actionsRef.current.togglePlayback(),
() => actionsRef.current.toggleFullscreen());
return () => {
unbindGestures();
output.dispose();
audioRef.current = null;
for (const event of ["play", "pause", "ended", "emptied"]) video.removeEventListener(event, syncPlayback);
@@ -90,19 +109,6 @@ export function PlaybackControls({
return () => track?.removeEventListener("ended", syncAudio);
}, [stream, audioTrackKey]);
const toggleFullscreen = () => {
const video = videoRef.current as FullscreenVideo | null;
if (fullscreen) {
void document.exitFullscreen().catch(() => undefined);
} else if (document.fullscreenEnabled && video?.parentElement) {
void video.parentElement.requestFullscreen()
.then(() => leavePictureInPicture(video))
.catch(() => undefined);
} else {
audioRef.current?.useNativeControls();
video?.webkitEnterFullscreen?.();
}
};
const silent = audio.muted || audio.level === 0;
const percent = Math.round(audio.level * 100);
+16 -17
View File
@@ -106,29 +106,28 @@ export function StageOverlay({
(Boolean(spin) || comic === "waiting-for-host" || comic === "recovering"));
const content = (
<>
{vis && comic ? <Comic kind={comic} theme="stage" tone={tone}
{comic ? <Comic kind={comic} theme="stage" tone={tone}
motion={transition || spin ? "progress" : undefined} /> : null}
<span className="lr-tv-status-content">
{showMascot ? (
<BrandLoader />
) : (
<span className={`lr-tv-big${spin ? " lr-spin" : ""}${onActivate ? " is-action is-ripple" : ""}`}>
<Glyph name={icon} size={30} draw="stage-overlay" />
</span>
)}
{vis ? progress && (
<span className="lr-tv-progress" aria-label={progress}>
<Glyph name="refresh" size={17} className="lr-spin" />
<span className="lr-tv-msg">{progress}</span>
</span>
) :
<span className="lr-tv-msg">{message}</span>}
{(vis || !comic || onActivate) && (showMascot ? (
<BrandLoader />
) : (
<span className={`lr-tv-big${spin ? " lr-spin" : ""}${onActivate ? " is-action is-ripple" : ""}`}>
<Glyph name={icon} size={30} draw="stage-overlay" />
</span>
))}
{vis ? progress && (
<span className="lr-tv-progress" aria-label={progress}>
<Glyph name="refresh" size={17} className="lr-spin" />
<span className="lr-tv-msg">{progress}</span>
</span>
) : <span className="lr-tv-msg">{message}</span>}
</span>
</>
);
if (onActivate) {
return (
<button type="button" className={`lr-tv-overlay${dim ? " is-dim" : ""}${vis && comic ? " has-comic" : ""}`} onClick={onActivate} aria-label={message}>
<button type="button" className={`lr-tv-overlay${dim ? " is-dim" : ""}${comic ? " has-comic" : ""}`} onClick={onActivate} aria-label={message}>
{content}
</button>
);
@@ -137,7 +136,7 @@ export function StageOverlay({
<div
// Passive state layer: it must not intercept clicks meant for the
// playback controls it covers (it owns no controls itself).
className={`lr-tv-overlay is-passive${dim ? " is-dim" : ""}${vis && comic ? " has-comic" : ""}`}
className={`lr-tv-overlay is-passive${dim ? " is-dim" : ""}${comic ? " has-comic" : ""}`}
role="status"
aria-label={message}
>
+8 -7
View File
@@ -1,5 +1,5 @@
// Shared tooltip: a floating paper panel showing text or a comic above (or below)
// its trigger. Visibility is three-channel, matching platform conventions:
// Shared tooltip: the same comic in every mode, with a localized caption in
// text modes. Literal names and values can stand alone. Visibility has three channels:
// hover on fine pointers, keyboard focus via :has(:focus-visible), and the
// Material long-press on touch (500ms hold → open, ~1.5s after release →
// auto-hide, context menu and the trailing synthetic click suppressed).
@@ -358,14 +358,15 @@ export function Tooltip({
<span ref={tipRef} id={tooltipId} popover="manual"
data-tone={resolvedTone}
style={{ ...comicStyle(resolvedTone, resolvedMotion), left: position.left, top: position.top, "--tooltip-caret": `${position.caret}px` } as CSSProperties}
className={`lr-comic-tip${text !== undefined ? " is-text" : ""}${placeClass}`} role="tooltip" aria-hidden={!interactionOpen}>
{panelMounted
? text ?? (kind
className={`lr-comic-tip${text !== undefined ? " is-text" : ""}${kind ? " has-comic" : ""}${placeClass}`} role="tooltip" aria-hidden={!interactionOpen}>
{panelMounted ? <>
{kind
? isHintKind(kind)
? <HintComic kind={kind} size={240} tone={resolvedTone} motion={resolvedMotion} />
: <Comic kind={kind} theme="paper" size={240} tone={resolvedTone} motion={resolvedMotion} />
: null)
: null}
: null}
{text !== undefined ? <span className="lr-comic-tip-caption">{text}</span> : null}
</> : null}
</span>
</span>
);
+2 -2
View File
@@ -1,6 +1,6 @@
// Control hint scenes (操作提示漫画): small 2-panel before→after idioms used
// as hover/focus tooltips on controls in pure-visual mode, where no human
// language may appear. Same cast, palette, 320x96 canvas, and motion
// as hover/focus tooltips in every mode, with captions added by Tooltip.
// Same cast, palette, 320x96 canvas, and motion
// constitution as the state comics. Sets live in set1..set4.tsx;
// each must define exactly its SetNKind keys — the merge below then proves
// completeness at compile time. JSX-free shell: this module is also loaded
@@ -0,0 +1,47 @@
// A native double-click follows two clicks. Defer only the picture shortcut;
// visible controls and keyboard actions remain immediate.
const SINGLE_CLICK_MS = 500;
export function bindPlaybackGestures(
video: HTMLVideoElement,
togglePlayback: () => void,
toggleFullscreen: () => void,
): () => void {
const listeners = new AbortController();
const options = { signal: listeners.signal };
let timer: ReturnType<typeof setTimeout> | undefined;
let pointerType = "";
const cancel = () => {
clearTimeout(timer);
timer = undefined;
};
// Beginning another action retires an uncommitted picture click, including
// the second press of a double-click or an action in the control bar.
video.parentElement?.addEventListener("pointerdown", (event) => {
pointerType = event.pointerType;
cancel();
}, options);
video.parentElement?.addEventListener("keydown", cancel, options);
video.parentElement?.addEventListener("pointercancel", cancel, options);
video.addEventListener("click", (event) => {
if (event.defaultPrevented || event.button !== 0 || event.detail !== 1 || pointerType === "touch") return;
cancel();
const source = video.srcObject;
timer = setTimeout(() => {
timer = undefined;
if (video.srcObject === source) togglePlayback();
}, SINGLE_CLICK_MS);
}, options);
video.addEventListener("dblclick", (event) => {
if (event.defaultPrevented || event.button !== 0 || pointerType === "touch") return;
cancel();
toggleFullscreen();
}, options);
for (const event of ["play", "pause", "emptied"]) video.addEventListener(event, cancel, options);
return () => {
cancel();
listeners.abort();
};
}
+2 -2
View File
@@ -78,7 +78,7 @@ export function Btn({
controls?: string;
onClick?: () => void;
type?: "button" | "submit";
/** Vis mode: 2-panel hint comic on hover/focus; native title stays off. */
/** Shared hint comic; text modes add the localized caption. */
hint?: HintKind;
hintTone?: ComicTone;
hintMotion?: ComicMotion;
@@ -143,7 +143,7 @@ export function Chip({
disabled?: boolean;
onClick?: () => void;
title: string;
/** Vis mode: 2-panel hint comic on hover/focus; native title stays off. */
/** Shared hint comic; text modes add the localized caption. */
hint?: HintKind;
name?: string;
value?: string;
+2 -1
View File
@@ -93,8 +93,9 @@ export async function authenticateSiteAccess(
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${password.trim()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ password }),
});
const body = await responseBody(response);
if (!response.ok) {
+26 -6
View File
@@ -29,7 +29,23 @@ function normalizeVersion(value: unknown): string | null {
return version ? `v${version}` : null;
}
export function parseReleaseMetadata(value: unknown): PublishedRelease | null {
function packageDownloadURL(release: PublishedRelease, assets: unknown, target?: string): string {
if (!target || !/^[a-z0-9]+-[a-z0-9]+$/.test(target) || !Array.isArray(assets)) return release.url;
const names = [`piik-app-${target}.zip`];
if (release.revision) names.push(`piik-app-${target}-${release.revision.slice(0, 7)}.zip`);
for (const name of names) {
const matches = assets.filter(asset => asset?.name === name);
if (!matches.length) continue;
const asset = matches[0];
const expectedURL = `${release.url.replace("/releases/tag/", "/releases/download/")}/${name}`;
return matches.length === 1 && (release.url.startsWith(MIRROR_RELEASE_PAGE) ||
(Number.isSafeInteger(asset.size) && asset.size > 0 && asset.state === "uploaded")) &&
asset.browser_download_url === expectedURL ? asset.browser_download_url : release.url;
}
return release.url;
}
export function parseReleaseMetadata(value: unknown, packageTarget?: string): PublishedRelease | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const release = value as Record<string, unknown>;
if ((release.draft !== undefined && release.draft !== false) ||
@@ -37,15 +53,16 @@ export function parseReleaseMetadata(value: unknown): PublishedRelease | null {
const version = normalizeVersion(release.tag_name);
if (!version || version !== release.tag_name || !/^v\d+\.\d+\.\d+$/.test(version) ||
release.html_url !== RELEASE_PAGE + version) return null;
return {
const normalized = {
version,
// GitHub permits a branch here; only our publisher's full SHA is provenance.
revision: normalizeReleaseRevision(release.target_commitish),
url: RELEASE_PAGE + version,
};
return { ...normalized, url: packageDownloadURL(normalized, release.assets, packageTarget) };
}
export function parseMirrorReleaseMetadata(value: unknown): PublishedRelease | null {
export function parseMirrorReleaseMetadata(value: unknown, packageTarget?: string): PublishedRelease | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const release = value as Record<string, unknown>;
if (release.prerelease !== false || typeof release.body !== "string") return null;
@@ -56,7 +73,9 @@ export function parseMirrorReleaseMetadata(value: unknown): PublishedRelease | n
tag_name: release.tag_name, html_url: RELEASE_PAGE + String(release.tag_name),
target_commitish: sources[0][1],
});
return normalized ? { ...normalized, url: MIRROR_RELEASE_PAGE + normalized.version } : null;
if (!normalized) return null;
const mirror = { ...normalized, url: MIRROR_RELEASE_PAGE + normalized.version };
return { ...mirror, url: packageDownloadURL(mirror, release.assets, packageTarget) };
}
export function releaseUpdateNotice(
@@ -85,6 +104,7 @@ export async function checkReleaseUpdate(
mirrorAPIURL?: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
packageTarget?: string;
} = {},
): Promise<ReleaseUpdateNotice | null> {
if (!normalizeVersion(current.version) && !normalizeReleaseRevision(current.revision)) return null;
@@ -117,13 +137,13 @@ export async function checkReleaseUpdate(
if (!response.ok) break;
const data: unknown = await response.json();
if (!source.mirror) {
latest = parseReleaseMetadata(data);
latest = parseReleaseMetadata(data, options.packageTarget);
if (latest) return releaseUpdateNotice(current, latest);
break;
}
if (!Array.isArray(data) || data.length > 100) break;
for (const value of data) {
const candidate = parseMirrorReleaseMetadata(value);
const candidate = parseMirrorReleaseMetadata(value, options.packageTarget);
if (candidate && (!latest || compare(candidate.version, latest.version) > 0)) latest = candidate;
}
if (data.length < 100) {
+1 -5
View File
@@ -9,7 +9,6 @@ import { z } from "zod";
import { createOpaqueId } from "./opaque-id";
const CLIENT_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
const CLIENT_ACCESS_BOOTSTRAP_PATTERN = /^[\x21-\x7e]{8,128}$/;
const CLIENT_LAUNCH_STORAGE_KEY = "piik:client-launch:v1";
const HOST_ROOM_STORAGE_KEY = "piik:host-room:v1";
const HOST_ROOM_PREFERENCE_STORAGE_KEY = "piik:host-room-preference:v1";
@@ -144,10 +143,7 @@ export function takeClientLaunchBootstrap(): ClientLaunchBootstrap {
// The launch fragment still enables the current load when storage is blocked.
}
const result: ClientLaunchBootstrap = {
accessToken:
accessValue && CLIENT_ACCESS_BOOTSTRAP_PATTERN.test(accessValue)
? accessValue
: null,
accessToken: accessValue || null,
launchedByClient,
presentation: launchedFromFragment && (lang === "zh" || lang === "en") &&
(mode === "vis" || mode === "text") &&
+1 -1
View File
@@ -75,7 +75,7 @@ export const en: Record<CopyKey, string> = {
"client.launch.siteHint": "Use an existing Piik Site",
"client.launch.siteAddress": "Piik Site address",
"client.launch.localAccess": "Site passphrase (optional)",
"client.launch.localAccessHint": "If set, visitors enter this passphrase before accessing your site. Use 8–128 English letters, numbers or keyboard symbols, without spaces.",
"client.launch.localAccessHint": "If set, visitors enter this passphrase before accessing your site.",
"client.launch.go": "Open Piik",
"client.launch.starting": "Preparing Piik",
"client.launch.error": "Piik could not start; check the setting and retry",
+1 -1
View File
@@ -73,7 +73,7 @@ export const zh = {
"client.launch.siteHint": "使用已有的 Piik 站点",
"client.launch.siteAddress": "Piik 站点地址",
"client.launch.localAccess": "站点口令(可选)",
"client.launch.localAccessHint": "设置后,访问此站点需要先输入口令。使用 8–128 位英文字母、数字或半角符号,不含空格。",
"client.launch.localAccessHint": "设置后,访问此站点需要先输入口令。",
"client.launch.go": "进入 Piik",
"client.launch.starting": "正在准备 Piik",
"client.launch.error": "暂时无法启动,请检查设置后重试",
+3 -2
View File
@@ -17,10 +17,11 @@ import {
const launcherStateSchema = z.object({
site: z.string(),
localAccessPassword: z.string().max(128),
localAccessPassword: z.string(),
defaultMode: z.enum(["local", "site"]),
revision: z.string(),
version: z.string().default("development"),
packageTarget: z.string().optional(),
});
const launcherResultSchema = z.object({ target: z.string().url() }).strict();
@@ -50,7 +51,7 @@ export function AppLauncherPage() {
void checkReleaseUpdate({
version: state.version,
revision: state.revision,
})
}, { packageTarget: state.packageTarget })
.then((notice) => {
if (current && notice) setUpdate(notice);
})
+7 -6
View File
@@ -49,7 +49,7 @@ export function ControlsPreview() {
<Btn icon="stop" tone="danger" title="host.stop" cap="host.stop" hint="hint-share-stop" onClick={notify} />
<Btn icon={paused ? "play" : "pause"} title={paused ? "host.resume" : "host.pause"} cap={paused ? "host.resume" : "host.pause"}
hint={paused ? "hint-resume" : "hint-pause"} draw="preview-pause" pressed={paused} onClick={() => setPaused(!paused)} />
<Btn icon="gauge" tone="on" title="host.details" cap="host.details" pressed onClick={notify} />
<Btn icon="gauge" tone="on" title="host.details" cap="host.details" hint="hint-details" pressed onClick={notify} />
<Btn icon="refresh" title="viewer.reconnect" cap="viewer.reconnect" hint="hint-reconnect" disabled />
<Btn icon="cast" title="host.starting" cap="host.starting" busy disabled hint="hint-share-start" />
</div>
@@ -58,13 +58,13 @@ export function ControlsPreview() {
<Btn icon="link" title="host.invite.copy" hint="hint-copy-invite" onClick={notify} />
<Btn icon="linkOff" title="host.invite.revoke" hint="hint-revoke-invite" onClick={notify} />
<Btn icon="network" title="host.topology" hint="hint-topology" onClick={notify} />
<Btn icon="sliders" title="host.advanced" onClick={notify} />
<Btn icon="sliders" title="host.advanced" hint="hint-advanced" onClick={notify} />
</div>
</section>
<section id="option-preview" className="cp-card">
<header><span className="cp-number">02</span><h2>{en ? "Pick, toggle, adjust" : "选一个,再拨一下。"}</h2></header>
<div className="lr-tiles" role="group" aria-label={t("host.quality")}>
{(["720p30", "1080p30", "1080p60"] as const).map((value, index) => <Tooltip key={value} kind="hint-quality" text={t(`host.quality.${value}`)}>
{(["720p30", "1080p30", "1080p60"] as const).map((value, index) => <Tooltip key={value} kind="hint-quality" text={vis ? undefined : t(`host.quality.${value}`)}>
<button type="button" className={`lr-tile${preset === value ? " is-selected" : ""}`} aria-pressed={preset === value}
aria-label={t(`host.quality.${value}`)} onClick={() => setPreset(value)}>
<Glyph name={["mountain", "balance", "zap"][index]!} size={23} /><small>{value.replace("p", "p · ")}</small>
@@ -77,7 +77,7 @@ export function ControlsPreview() {
</div>
<div className="cp-tools">
<span className="lr-toggle" role="group" aria-label={t("host.policy")} data-selected={policy}>
{(["open", "private"] as const).map(value => <Tooltip key={value} kind={value === "open" ? "hint-policy-open" : "hint-policy-private"} text={t(`host.policy.${value}`)}>
{(["open", "private"] as const).map(value => <Tooltip key={value} kind={value === "open" ? "hint-policy-open" : "hint-policy-private"} text={vis ? undefined : t(`host.policy.${value}`)}>
<button type="button" className={policy === value ? "is-selected" : undefined} aria-pressed={policy === value}
aria-label={t(`host.policy.${value}`)} onClick={() => setPolicy(value)}>
<Glyph name={value === "open" ? "globe" : "lock"} size={19} />{vis ? null : <span className="lr-cap">{t(`host.policy.${value}`)}</span>}
@@ -95,7 +95,8 @@ export function ControlsPreview() {
<label className="lr-input"><Glyph name="key" size={17} /><input type={passwordVisible ? "text" : "password"}
placeholder={t("join.password")} aria-label={t("join.password")} aria-invalid={invalid} aria-describedby={invalid ? "preview-input-error" : undefined} autoComplete="off" />
</label>
<Btn icon={passwordVisible ? "eyeOff" : "eye"} title={passwordVisible ? "host.password.hide" : "host.password.show"} onClick={() => setPasswordVisible(!passwordVisible)} />
<Btn icon={passwordVisible ? "eyeOff" : "eye"} title={passwordVisible ? "host.password.hide" : "host.password.show"}
hint={passwordVisible ? "hint-password-hide" : "hint-password-show"} onClick={() => setPasswordVisible(!passwordVisible)} />
<SwitchItem checked={invalid} onChange={setInvalid} label={en ? "Show error" : "看看错误态"} />
</div>
{invalid ? <p id="preview-input-error" className="cp-input-error" role="alert">{en ? "That password did not match. Try again." : "密码没对上,再试一次。"}</p> : null}
@@ -134,7 +135,7 @@ export function ControlsPreview() {
{sourceOpen ? <CaptureSourcePicker nativeSources={sourceState === "ready" ? SOURCES : { kind: sourceState }}
onBrowser={() => { setSourceOpen(false); notify(); }} onNative={() => { setSourceOpen(false); notify(); }}
onPreview={previewSource} onRefresh={() => setSourceState("ready")} onCancel={() => setSourceOpen(false)} />
: <div className="cp-stage-action"><Btn icon="cast" tone="primary" title="host.start" cap="host.start" onClick={() => setSourceOpen(true)} /></div>}
: <div className="cp-stage-action"><Btn icon="cast" tone="primary" title="host.start" cap="host.start" hint="hint-share-start" onClick={() => setSourceOpen(true)} /></div>}
</StageTv></div>
</section>
<section id="playback-preview" className="cp-section">
+85 -143
View File
@@ -4,9 +4,7 @@ import {
useMemo,
useRef,
useState,
type CSSProperties,
type FormEvent,
type ReactNode,
} from "react";
import {
DEFAULT_QUALITY_SETTINGS,
@@ -43,7 +41,6 @@ import {
} from "../components/living/ViewerOverview";
import type { ComicKind } from "../components/living/Comic";
import { Tooltip } from "../components/living/Tooltip";
import type { HintKind } from "../components/living/hints";
import {
StageOverlay,
StageTv,
@@ -3313,32 +3310,6 @@ export function HostPage({
);
useDocumentTitle([room?.roomId, titleContent[0]], titleContent.slice(1));
// These Btn triggers already own their text tooltip; add only their visual comic.
// wrapStyle adds a layout span around the tooltip wrapper (vis mode only)
// for triggers whose flex context would otherwise stretch the wrapper away
// from the trigger it must hug.
const hintWrap = (
kind: HintKind,
node: ReactNode,
align: "start" | "center" | "end" = "center",
wrapStyle?: CSSProperties,
): ReactNode =>
vis ? (
wrapStyle ? (
<span style={wrapStyle}>
<Tooltip kind={kind} align={align}>
{node}
</Tooltip>
</span>
) : (
<Tooltip kind={kind} align={align}>
{node}
</Tooltip>
)
) : (
node
);
return (
<div className="lr-app">
<AppHeader
@@ -3630,19 +3601,16 @@ export function HostPage({
type="submit"
disabled={displayNameDraft === displayName}
/>
{hintWrap(
"hint-close",
<Btn
icon="x"
title="host.nameCancel"
onClick={() => {
setDisplayNameDraft(displayName);
setDisplayNameError(null);
setEditingDisplayName(false);
}}
/>,
"end",
)}
<Btn
icon="x"
title="host.nameCancel"
hint="hint-close"
onClick={() => {
setDisplayNameDraft(displayName);
setDisplayNameError(null);
setEditingDisplayName(false);
}}
/>
</form>
) : (
<>
@@ -3650,20 +3618,17 @@ export function HostPage({
name={labeledHostPresence?.label ?? displayName}
identity={hostIdentity}
/>
{hintWrap(
"hint-rename",
<Btn
icon="pencil"
cap="common.edit"
title="host.nameEdit"
onClick={() => {
setDisplayNameDraft(displayName);
setDisplayNameError(null);
setEditingDisplayName(true);
}}
/>,
"end",
)}
<Btn
icon="pencil"
cap="common.edit"
title="host.nameEdit"
hint="hint-rename"
onClick={() => {
setDisplayNameDraft(displayName);
setDisplayNameError(null);
setEditingDisplayName(true);
}}
/>
</>
)}
{displayNameError ? (
@@ -3677,47 +3642,41 @@ export function HostPage({
) : null}
</div>
<div className="lr-row-group lr-group-actions lr-host-diagnostics-slot">
{hintWrap(
showConnectionDetails ? "hint-collapse" : "hint-details",
<Btn
icon="gauge"
cap={
showConnectionDetails
? "host.details.hide"
: "host.details"
}
title={
showConnectionDetails
? "host.details.hide"
: "host.details"
}
tone={showConnectionDetails ? "on" : undefined}
expanded={showConnectionDetails}
controls="host-details-panel host-viewer-overview"
disabled={!hostDiagnosticsAvailable}
onClick={() =>
setShowConnectionDetails((current) => !current)
}
/>,
"start",
)}
{hintWrap(
showTopology ? "hint-collapse" : "hint-topology",
<Btn
icon="network"
cap="host.topology"
title={
showTopology
? "host.topology.hide"
: "host.topology.show"
}
tone={showTopology ? "on" : undefined}
expanded={showTopology}
controls="room-topology"
onClick={() => setShowTopology((current) => !current)}
/>,
"end",
)}
<Btn
icon="gauge"
cap={
showConnectionDetails
? "host.details.hide"
: "host.details"
}
title={
showConnectionDetails
? "host.details.hide"
: "host.details"
}
hint={showConnectionDetails ? "hint-collapse" : "hint-details"}
tone={showConnectionDetails ? "on" : undefined}
expanded={showConnectionDetails}
controls="host-details-panel host-viewer-overview"
disabled={!hostDiagnosticsAvailable}
onClick={() =>
setShowConnectionDetails((current) => !current)
}
/>
<Btn
icon="network"
cap="host.topology"
title={
showTopology
? "host.topology.hide"
: "host.topology.show"
}
hint={showTopology ? "hint-collapse" : "hint-topology"}
tone={showTopology ? "on" : undefined}
expanded={showTopology}
controls="room-topology"
onClick={() => setShowTopology((current) => !current)}
/>
</div>
{phase === "live" || phase === "starting" ? (
<div className="lr-row-group lr-group-actions lr-host-share-slot">
@@ -3732,41 +3691,32 @@ export function HostPage({
disabled={switchingSource || changingQuality}
onClick={toggleSharingPause}
/>
{hintWrap(
"hint-switch-source",
<Btn
icon="switchSource"
cap={switchingSource ? "host.switching" : "host.switchSource"}
title="host.switchSource"
disabled={switchingSource || changingQuality}
onClick={() => void switchSource()}
/>,
"end",
)}
{hintWrap(
"hint-share-stop",
<Btn
icon="stop"
tone="danger"
cap="host.stop"
title="host.stop"
onClick={() => endSharing({ key: "host.stopNotice" })}
/>,
"end",
)}
<Btn
icon="switchSource"
cap={switchingSource ? "host.switching" : "host.switchSource"}
title="host.switchSource"
hint="hint-switch-source"
disabled={switchingSource || changingQuality}
onClick={() => void switchSource()}
/>
<Btn
icon="stop"
tone="danger"
cap="host.stop"
title="host.stop"
hint="hint-share-stop"
onClick={() => endSharing({ key: "host.stopNotice" })}
/>
</>
) : (
hintWrap(
"hint-share-stop",
<Btn
icon="x"
tone="danger"
cap="host.cancelStart"
title="host.cancelStart"
onClick={() => endSharing({ key: "host.startCancelled" })}
/>,
"end",
)
<Btn
icon="x"
tone="danger"
cap="host.cancelStart"
title="host.cancelStart"
hint="hint-close"
onClick={() => endSharing({ key: "host.startCancelled" })}
/>
)}
</div>
) : null}
@@ -4167,28 +4117,20 @@ export function HostPage({
</div>
</RowGroup>
<span className="lr-spacer" />
{hintWrap(
showAdvanced ? "hint-collapse" : "hint-advanced",
{/* Keep the tooltip trigger compact when mobile rows stretch. */}
<span style={{ display: "flex", justifyContent: "flex-end" }}>
<Btn
icon="sliders"
busy={changingQuality}
cap="host.advanced"
title={showAdvanced ? "host.advanced.hide" : "host.advanced"}
hint={showAdvanced ? "hint-collapse" : "hint-advanced"}
tone={showAdvanced ? "on" : undefined}
expanded={showAdvanced}
controls="host-advanced-door"
onClick={() => setShowAdvanced((current) => !current)}
/>,
"end",
// Mobile stacks `.lr-row` into a stretch column, which would
// stretch the tooltip wrapper to full row width and leave its
// wrapper-centered caret (and end-aligned panel) floating in
// empty card space away from the button. Take the stretch on a
// layout span instead and park the wrapper at the row's end,
// matching the desktop row-right placement; in the desktop row
// layout the span shrink-wraps and this is a no-op.
{ display: "flex", justifyContent: "flex-end" },
)}
/>
</span>
</Row>
<div
id="host-advanced-door"
+1 -1
View File
@@ -57,7 +57,7 @@ export function StatusPreviewPage() {
<main className="sp-main">
<header className="sp-heading">
<div><p className="sp-kicker">PIIK · 状态语义提案</p><h1>同一事实,各有分寸。</h1>
<p>标题说正在做什么,顶部说明信令,电视下沿图标说明画面;悬停查看文字或漫画详情。</p></div>
<p>标题说正在做什么,顶部说明信令,电视下沿图标说明画面;悬停查看图示与文字说明。</p></div>
<label className="sp-motion"><input type="checkbox" checked={reducedMotion}
onChange={(event) => setReducedMotion(event.target.checked)} />减少动态效果</label>
</header>
+1
View File
@@ -68,6 +68,7 @@ export function TooltipPreviewPage() {
<ol>{WELCOME_LINES.map(({ key }, index) => <li key={key}><span>{t(key)}</span><WelcomeCipher line={index} /></li>)}</ol>
</details>
<h2 id="comic-preview">语义规则 / Semantic grammar</h2>
<p>{en ? "Aa pairs the scene with a caption; ✦ shows the pure-visual version." : "Aa 查看图示与文字说明,✦ 查看纯视觉版本。"}</p>
<div className="lr-tooltip-preview-grid">
{EXAMPLES.map((example) => <PreviewCard key={example.label} {...example} text={t(example.text)} />)}
</div>
+30 -53
View File
@@ -5,7 +5,6 @@ import {
useReducer,
useRef,
useState,
type ReactNode,
} from "react";
import {
DEFAULT_QUALITY_SETTINGS,
@@ -31,8 +30,6 @@ import { PawnDetail, RouteGlyph } from "../components/living/PawnDetail";
import { Lcd } from "../components/living/RoomChip";
import { RouteTree } from "../components/living/RouteTree";
import { Comic, type ComicKind } from "../components/living/Comic";
import { Tooltip } from "../components/living/Tooltip";
import type { HintKind } from "../components/living/hints";
import {
StageOverlay,
StageTv,
@@ -2369,20 +2366,6 @@ export function ViewerPage({
)
.map((viewer) => viewer.peerId);
// Btn owns its text tooltip; these wrappers supply only its visual comic.
const hintWrap = (
kind: HintKind,
node: ReactNode,
align: "start" | "center" | "end" = "center",
): ReactNode =>
vis ? (
<Tooltip kind={kind} align={align}>
{node}
</Tooltip>
) : (
node
);
return (
<div className="lr-app">
<style>{`
@@ -2581,20 +2564,17 @@ export function ViewerPage({
name={displayName}
identity={selfPeerId ?? viewerClientId}
/>
{hintWrap(
"hint-rename",
<Btn
icon="pencil"
cap="common.edit"
title="host.nameEdit"
onClick={() => {
setDisplayNameDraft(displayName);
setDisplayNameError(false);
setEditingDisplayName(true);
}}
/>,
"end",
)}
<Btn
icon="pencil"
cap="common.edit"
title="host.nameEdit"
hint="hint-rename"
onClick={() => {
setDisplayNameDraft(displayName);
setDisplayNameError(false);
setEditingDisplayName(true);
}}
/>
</>
)}
{displayNameError && (
@@ -2632,28 +2612,25 @@ export function ViewerPage({
setShowConnectionDetails((current) => !current)
}
/>
{hintWrap(
labeledHostPresence && showTopology ? "hint-collapse" : "hint-topology",
<Btn
icon="network"
cap="host.topology"
title={
labeledHostPresence && showTopology
? "host.topology.hide"
: "host.topology.show"
}
tone={
labeledHostPresence && showTopology ? "on" : undefined
}
expanded={Boolean(labeledHostPresence && showTopology)}
controls="room-topology"
disabled={!labeledHostPresence}
onClick={() =>
setShowTopology((current) => !current)
}
/>,
"start",
)}
<Btn
icon="network"
cap="host.topology"
title={
labeledHostPresence && showTopology
? "host.topology.hide"
: "host.topology.show"
}
hint={labeledHostPresence && showTopology ? "hint-collapse" : "hint-topology"}
tone={
labeledHostPresence && showTopology ? "on" : undefined
}
expanded={Boolean(labeledHostPresence && showTopology)}
controls="room-topology"
disabled={!labeledHostPresence}
onClick={() =>
setShowTopology((current) => !current)
}
/>
</span>
</div>
</div>
+25 -7
View File
@@ -303,9 +303,24 @@ body.lr-theater-open { overflow: hidden; }
.lr-tooltip-preview-trigger { width: 36px; height: 36px; padding: 0; border: 1px solid var(--wall-2); border-radius: 999px; background: transparent; color: var(--ink); cursor: help; }
.lr-tooltip-preview-art { min-height: 106px; display: grid; place-items: center; overflow: hidden; }
.lr-tooltip-preview-art svg { max-width: 100%; height: auto; }
.lr-tv-overlay.has-comic { gap: 8px; padding: 10px; }
.lr-tv-overlay.has-comic {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
}
.lr-tv-overlay.has-comic > svg { min-height: 0; flex-shrink: 1; }
.lr-tv-overlay.has-comic .lr-tv-status-content {
flex: none;
flex-direction: row;
gap: 8px;
max-width: 100%;
}
.lr-tv-overlay.has-comic .lr-tv-msg { line-height: 1.35; }
.lr-tv-overlay.has-comic .lr-brand-loader,
.lr-tv-overlay.has-comic .lr-tv-big { width: 40px; height: 40px; }
.lr-tv-overlay.has-comic .lr-tv-big { width: 40px; height: 40px; flex-shrink: 0; }
.lr-tv-overlay.has-comic .lr-brand-mark { width: 32px; height: 32px; }
.lr-tv-big {
@@ -1750,6 +1765,13 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
overflow-wrap: anywhere;
text-wrap: pretty;
}
.lr-comic-tip.has-comic.is-text { width: 272px; }
.lr-comic-tip > svg {
display: block;
max-width: 100%;
height: auto;
margin-inline: auto;
}
/* Keep the panel reachable across the small gap while reading it. */
.lr-comic-tip::after {
content: "";
@@ -1830,12 +1852,8 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
.lr-comic-tip-caption {
display: block;
margin-top: 6px;
font-size: 11px;
font-weight: 600;
text-align: center;
opacity: .72;
}
.lr-comic-tip > svg + .lr-comic-tip-caption { margin-top: 8px; }
/* ---------- reduced motion: everything collapses to instant ---------- */
+10 -11
View File
@@ -672,7 +672,7 @@ describe("client session identity", () => {
});
describe("site access API", () => {
it("checks and authenticates site access without a request body", async () => {
it("checks site access and sends the exact password in a Unicode-capable JSON body", async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
@@ -693,7 +693,7 @@ describe("site access API", () => {
required: true,
authenticated: false,
});
await expect(authenticateSiteAccess(" instance-password ")).resolves.toEqual({
await expect(authenticateSiteAccess(" 中文 +& ")).resolves.toEqual({
required: true,
authenticated: true,
});
@@ -704,10 +704,9 @@ describe("site access API", () => {
]);
const post = fetchMock.mock.calls[1][1];
expect(post?.method).toBe("POST");
expect(post?.body).toBeUndefined();
expect(new Headers(post?.headers).get("Authorization")).toBe(
"Bearer instance-password",
);
expect(JSON.parse(String(post?.body))).toEqual({ password: " 中文 +& " });
expect(new Headers(post?.headers).get("Content-Type")).toBe("application/json");
expect(new Headers(post?.headers).has("Authorization")).toBe(false);
});
it("uses a clear Chinese message for a rejected password", async () => {
@@ -910,10 +909,10 @@ describe("room codes", () => {
);
});
it("removes a malformed App bootstrap without authenticating it", () => {
it("removes an empty App bootstrap without authenticating it", () => {
const replaceState = vi.fn();
vi.stubGlobal("window", {
location: { hash: "#client-access=short", pathname: "/", search: "" },
location: { hash: "#client-access=", pathname: "/", search: "" },
history: { state: null, replaceState },
});
@@ -921,18 +920,18 @@ describe("room codes", () => {
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
});
it("accepts a bounded user-chosen App access value", () => {
it.each(["x", "中文", " ", " 中文 +& ", "x".repeat(256)])("preserves a chosen App access value: %s", (password) => {
const replaceState = vi.fn();
vi.stubGlobal("window", {
location: {
hash: "#client-access=a%2Bb%26c%3Fd%3De",
hash: `#${new URLSearchParams({ "client-access": password })}`,
pathname: "/",
search: "",
},
history: { state: null, replaceState },
});
expect(takeClientLaunchBootstrap().accessToken).toBe("a+b&c?d=e");
expect(takeClientLaunchBootstrap().accessToken).toBe(password);
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
});
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { bindPlaybackGestures } from "../src/client/components/living/playback-gestures";
function picture() {
const screen = new EventTarget();
const video = Object.assign(new EventTarget(), {
parentElement: screen,
srcObject: {} as object | null,
});
const playback = vi.fn();
const fullscreen = vi.fn();
const dispose = bindPlaybackGestures(video as unknown as HTMLVideoElement, playback, fullscreen);
const pointer = (pointerType = "mouse") => screen.dispatchEvent(Object.assign(new Event("pointerdown"), { pointerType }));
const click = (detail = 1, button = 0) => video.dispatchEvent(Object.assign(new Event("click"), { detail, button }));
const doubleClick = () => video.dispatchEvent(Object.assign(new Event("dblclick"), { detail: 2, button: 0 }));
return { screen, video, playback, fullscreen, dispose, pointer, click, doubleClick };
}
afterEach(() => vi.useRealTimers());
describe("Viewer picture gestures", () => {
it("waits for a single click and gives double-click fullscreen priority without changing playback", () => {
vi.useFakeTimers();
const view = picture();
view.pointer();
view.click();
vi.advanceTimersByTime(499);
expect(view.playback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(view.playback).toHaveBeenCalledOnce();
view.playback.mockClear();
view.pointer();
view.click();
vi.advanceTimersByTime(300);
view.pointer();
// The second press cancels the single even if its release comes later.
vi.advanceTimersByTime(300);
view.click(2);
view.doubleClick();
vi.runAllTimers();
expect(view.playback).not.toHaveBeenCalled();
expect(view.fullscreen).toHaveBeenCalledOnce();
view.dispose();
});
it("retires pending clicks after another action, playback change, media replacement or unmount", () => {
vi.useFakeTimers();
const view = picture();
for (const [target, event] of [
[view.screen, "pointerdown"], [view.screen, "keydown"], [view.screen, "pointercancel"],
[view.video, "play"], [view.video, "pause"], [view.video, "emptied"],
] as const) {
view.click();
target.dispatchEvent(new Event(event));
vi.runAllTimers();
}
view.click();
view.video.srcObject = {};
vi.runAllTimers();
view.click();
view.dispose();
vi.runAllTimers();
view.click();
view.doubleClick();
vi.runAllTimers();
expect(view.playback).not.toHaveBeenCalled();
expect(view.fullscreen).not.toHaveBeenCalled();
});
it("leaves touch taps, control-bar clicks, auxiliary buttons and keyboard-generated clicks alone", () => {
vi.useFakeTimers();
const view = picture();
view.pointer("touch");
view.click();
view.doubleClick();
view.pointer();
view.screen.dispatchEvent(Object.assign(new Event("click"), { detail: 1, button: 0 }));
view.screen.dispatchEvent(Object.assign(new Event("dblclick"), { detail: 2, button: 0 }));
view.click(1, 2);
view.click(0);
vi.runAllTimers();
expect(view.playback).not.toHaveBeenCalled();
expect(view.fullscreen).not.toHaveBeenCalled();
view.dispose();
});
});
+58
View File
@@ -12,8 +12,66 @@ const release = {
target_commitish: latestRevision,
html_url: "https://github.com/TNTcraftHIM/Piik/releases/tag/v1.2.0",
};
const packageAsset = (target: string, suffix = "") => {
const name = `piik-app-${target}${suffix}.zip`;
return { name, size: 1024, state: "uploaded",
browser_download_url: `https://github.com/TNTcraftHIM/Piik/releases/download/${release.tag_name}/${name}` };
};
describe("App release update notice", () => {
it.each(["windows-amd64", "darwin-arm64", "linux-amd64"])("links the published %s ZIP without downloading it during the check", async (packageTarget) => {
const assets = ["windows-amd64", "darwin-arm64", "linux-amd64"].map(target => packageAsset(target));
const fetchImpl = vi.fn<typeof fetch>(async () => Response.json({ ...release, assets }));
await expect(checkReleaseUpdate({ version: "v1.1.0", revision: currentRevision }, { fetchImpl, packageTarget }))
.resolves.toMatchObject({ kind: "update-available", version: release.tag_name,
url: assets.find(asset => asset.name === `piik-app-${packageTarget}.zip`)!.browser_download_url });
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(String(fetchImpl.mock.calls[0][0])).toBe(DEFAULT_RELEASE_API_URL);
});
it("uses a listed older SHA-suffixed ZIP and prefers the fixed name when both exist", () => {
const older = packageAsset("windows-amd64", `-${latestRevision.slice(0, 7)}`);
expect(parseReleaseMetadata({ ...release, assets: [older] }, "windows-amd64")?.url)
.toBe(older.browser_download_url);
const current = packageAsset("windows-amd64");
expect(parseReleaseMetadata({ ...release, assets: [older, current] }, "windows-amd64")?.url)
.toBe(current.browser_download_url);
});
it("keeps the release page when a matching safe ZIP is missing, without trying another provider", async () => {
const asset = packageAsset("windows-amd64");
for (const assets of [[], [packageAsset("linux-amd64")], [packageAsset("windows-amd64", "-aaaaaaa")],
[{ ...asset, state: "new" }], [{ ...asset, size: 0 }], [{ ...asset, size: undefined }], [asset, asset],
[{ ...asset, browser_download_url: asset.browser_download_url.replace("github.com", "evil.example") }],
[{ ...asset, browser_download_url: asset.browser_download_url.replace("v1.2.0", "v1.1.0") }],
]) {
const fetchImpl = vi.fn<typeof fetch>(async () => Response.json({ ...release, assets }));
await expect(checkReleaseUpdate({ version: "v1.1.0", revision: currentRevision },
{ fetchImpl, packageTarget: "windows-amd64" })).resolves.toMatchObject({ url: release.html_url });
expect(fetchImpl).toHaveBeenCalledTimes(1);
}
expect(parseReleaseMetadata({ ...release, assets: [asset] }, "windows-386")?.url).toBe(release.html_url);
expect(parseReleaseMetadata({ ...release, assets: [asset] })?.url).toBe(release.html_url);
});
it.each(["windows-amd64", "darwin-arm64", "linux-amd64"])("uses a listed mirror %s ZIP only after the existing primary check fails", async (packageTarget) => {
const name = `piik-app-${packageTarget}.zip`;
const mirrorAsset = { name,
browser_download_url: `https://gitee.com/TNTcraftHIM/Piik/releases/download/${release.tag_name}/${name}` };
const mirrored = { tag_name: release.tag_name, prerelease: false,
body: `<!-- piik-source: ${latestRevision} -->`, assets: [mirrorAsset] };
const fetchImpl = vi.fn<typeof fetch>(async (input) => String(input) === DEFAULT_RELEASE_API_URL
? new Response(null, { status: 503 }) : Response.json([mirrored]));
await expect(checkReleaseUpdate({ version: "v1.1.0", revision: currentRevision },
{ fetchImpl, packageTarget })).resolves.toMatchObject({ url: mirrorAsset.browser_download_url });
expect(fetchImpl).toHaveBeenCalledTimes(2);
for (const assets of [[], [mirrorAsset, mirrorAsset],
[{ ...mirrorAsset, browser_download_url: mirrorAsset.browser_download_url.replace("gitee.com", "evil.example") }],
[{ ...mirrorAsset, browser_download_url: mirrorAsset.browser_download_url.replace("v1.2.0", "v1.1.0") }],
]) expect(parseMirrorReleaseMetadata({ ...mirrored, assets }, packageTarget)?.url)
.toBe(`https://gitee.com/TNTcraftHIM/Piik/releases/tag/${release.tag_name}`);
});
it("checks a release without sending installed identity or credentials", async () => {
const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
expect(String(input)).toBe(DEFAULT_RELEASE_API_URL);
+27 -4
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { websiteDownloads } from "../site/downloads";
const revision = "a".repeat(40);
@@ -7,7 +8,7 @@ const github = {
tag_name: version, target_commitish: revision, draft: false, prerelease: false,
html_url: `https://github.com/TNTcraftHIM/Piik/releases/tag/${version}`,
assets: ["windows-amd64", "darwin-arm64", "linux-amd64"].map(target => {
const name = `piik-app-${target}-${revision.slice(0, 7)}.zip`;
const name = `piik-app-${target}.zip`;
return { name, state: "uploaded", size: 1024,
browser_download_url: `https://github.com/TNTcraftHIM/Piik/releases/download/${version}/${name}` };
}),
@@ -22,21 +23,43 @@ describe("website package downloads", () => {
it("links each uploaded platform package and rejects an incomplete or mismatched GitHub release", () => {
const result = websiteDownloads({ github, mirror });
expect(result.version).toBe(version);
expect(result.notes).toBe(github.html_url);
expect(result.packages.map(item => item.url)).toEqual(github.assets.map(asset => asset.browser_download_url));
expect(result.packages.map(item => item.url)).toEqual(github.assets.map(asset =>
`https://github.com/TNTcraftHIM/Piik/releases/latest/download/${asset.name}`));
expect(result.packages.map(item => item.mirrorURL)).toEqual(mirror.assets.map(asset => asset.browser_download_url));
for (const changed of [
{ target_commitish: "main" }, { prerelease: true }, { draft: true },
{ assets: github.assets.slice(1) }, { assets: [...github.assets, github.assets[0]] },
...[
{ state: "new" }, { size: 0 },
{ name: github.assets[0]!.name.replace("aaaaaaa", "bbbbbbb") },
{ name: github.assets[0]!.name.replace(".zip", "-aaaaaaa.zip") },
{ name: github.assets[0]!.name.replace(".zip", ".tar.gz") },
{ browser_download_url: github.assets[0]!.browser_download_url.replace("github.com", "evil.example") },
{ browser_download_url: github.assets[0]!.browser_download_url.replace(version, "v1.1.0") },
].map(change => ({ assets: [{ ...github.assets[0], ...change }, ...github.assets.slice(1)] })),
]) expect(() => websiteDownloads({ github: { ...github, ...changed } })).toThrow();
});
it("keeps GitHub URLs valid across releases and without build metadata", () => {
const nextVersion = "v1.2.1";
const next = websiteDownloads({ github: { ...github, tag_name: nextVersion,
target_commitish: "b".repeat(40), html_url: github.html_url.replace(version, nextVersion),
assets: github.assets.map(asset => ({ ...asset,
browser_download_url: asset.browser_download_url.replace(version, nextVersion) })),
}, mirror });
const current = websiteDownloads({ github, mirror });
expect(next.packages.map(item => item.url)).toEqual(current.packages.map(item => item.url));
expect(next.packages.every(item => item.mirrorURL === null)).toBe(true);
const html = readFileSync(new URL("../site/index.html", import.meta.url), "utf8");
for (const item of current.packages) {
const link = html.match(new RegExp(`<a\\b[^>]*data-download="${item.target}"[^>]*data-provider="github"[^>]*>[\\s\\S]*?<\\/a\\s*>`))?.[0];
expect(link).toContain(`href="${item.url}"`);
expect(link).toContain("Download ZIP");
expect(link).toContain("下载 ZIP");
}
expect(html.match(/id="download-status">[\s\S]*?<\/p>/)?.[0]).not.toMatch(/v\d+\.\d+\.\d+/);
});
it("keeps mirror navigation as the fallback until the same release and attachment are confirmed", () => {
for (const changed of [undefined, null, { ...mirror, prerelease: true },
{ ...mirror, body: `<!-- piik-source: ${"b".repeat(40)} -->` },