diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml index f60fb16..4182138 100644 --- a/.github/workflows/repository-hygiene.yml +++ b/.github/workflows/repository-hygiene.yml @@ -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: diff --git a/cmd/piik-app/README.md b/cmd/piik-app/README.md index bcd5b37..592111b 100644 --- a/cmd/piik-app/README.md +++ b/cmd/piik-app/README.md @@ -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. diff --git a/docs/deployment.md b/docs/deployment.md index f730ec1..2e25fdb 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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: diff --git a/docs/design/media-status.md b/docs/design/media-status.md index edaf545..118efc8 100644 --- a/docs/design/media-status.md +++ b/docs/design/media-status.md @@ -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. diff --git a/docs/design/public-introduction.md b/docs/design/public-introduction.md index 7da30bc..50025bb 100644 --- a/docs/design/public-introduction.md +++ b/docs/design/public-introduction.md @@ -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. diff --git a/docs/design/visual-language.md b/docs/design/visual-language.md index b1bcf15..0827796 100644 --- a/docs/design/visual-language.md +++ b/docs/design/visual-language.md @@ -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 diff --git a/docs/operations/self-hosting.md b/docs/operations/self-hosting.md index e54be75..fd2728a 100644 --- a/docs/operations/self-hosting.md +++ b/docs/operations/self-hosting.md @@ -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 diff --git a/docs/operations/self-hosting.zh-CN.md b/docs/operations/self-hosting.zh-CN.md index 98a19ed..79312c1 100644 --- a/docs/operations/self-hosting.zh-CN.md +++ b/docs/operations/self-hosting.zh-CN.md @@ -47,7 +47,7 @@ SITE_ACCESS_PASSWORD= 程序会自动读取 `.env`,并将房间数据保存在工作目录的 `rooms.sqlite` 中。 `SITE_ACCESS_PASSWORD` 留空时,进入站点无须口令; -如需设置口令,可填写 8–128 个可见 ASCII 字符。房间邀请与加入权限仍由房主管理。 +如需设置口令,填写你想使用的口令即可。房间邀请与加入权限仍由房主管理。 ### 2. 配置 HTTPS diff --git a/docs/operations/website.md b/docs/operations/website.md index ef4439c..227c2eb 100644 --- a/docs/operations/website.md +++ b/docs/operations/website.md @@ -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 diff --git a/docs/product/presentation-lifecycle.md b/docs/product/presentation-lifecycle.md index 3894c5b..ad6eb08 100644 --- a/docs/product/presentation-lifecycle.md +++ b/docs/product/presentation-lifecycle.md @@ -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 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a52a900..0cf7548 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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: diff --git a/docs/reference/versioning.md b/docs/reference/versioning.md index 373548c..5419e0f 100644 --- a/docs/reference/versioning.md +++ b/docs/reference/versioning.md @@ -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 diff --git a/docs/todo.md b/docs/todo.md index 7fd2744..1fd082f 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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. diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 5850d42..fbf57ae 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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) } diff --git a/internal/app/config/config.go b/internal/app/config/config.go index 30b7aa1..5393993 100644 --- a/internal/app/config/config.go +++ b/internal/app/config/config.go @@ -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") diff --git a/internal/app/config/config_test.go b/internal/app/config/config_test.go index a395af6..4fc79e5 100644 --- a/internal/app/config/config_test.go +++ b/internal/app/config/config_test.go @@ -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) { diff --git a/internal/app/launcher/server.go b/internal/app/launcher/server.go index cdf6993..4980af3 100644 --- a/internal/app/launcher/server.go +++ b/internal/app/launcher/server.go @@ -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") diff --git a/internal/app/launcher/server_test.go b/internal/app/launcher/server_test.go index 7dc22b0..14df8b5 100644 --- a/internal/app/launcher/server_test.go +++ b/internal/app/launcher/server_test.go @@ -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"}`, } { diff --git a/internal/server/app/app_test.go b/internal/server/app/app_test.go index 4834831..250df78 100644 --- a/internal/server/app/app_test.go +++ b/internal/server/app/app_test.go @@ -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", "") }) } } diff --git a/internal/server/app/routes.go b/internal/server/app/routes.go index e6072c3..110fdf9 100644 --- a/internal/server/app/routes.go +++ b/internal/server/app/routes.go @@ -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 diff --git a/internal/server/config/config.go b/internal/server/config/config.go index 36c6852..027e3d2 100644 --- a/internal/server/config/config.go +++ b/internal/server/config/config.go @@ -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") } diff --git a/internal/server/config/config_test.go b/internal/server/config/config_test.go index 7296ca2..ea074a3 100644 --- a/internal/server/config/config_test.go +++ b/internal/server/config/config_test.go @@ -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"}, diff --git a/internal/server/config/local.go b/internal/server/config/local.go index b893657..6207dfc 100644 --- a/internal/server/config/local.go +++ b/internal/server/config/local.go @@ -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, diff --git a/internal/server/config/local_test.go b/internal/server/config/local_test.go index 57817e7..ac8f530 100644 --- a/internal/server/config/local_test.go +++ b/internal/server/config/local_test.go @@ -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"}, diff --git a/scripts/build-website.mjs b/scripts/build-website.mjs index 5abedb0..5e54803 100644 --- a/scripts/build-website.mjs +++ b/scripts/build-website.mjs @@ -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(/

[\s\S]*?<\/p>/, - `

${release.version}` + - `Download the ZIP for your system, then extract it.` + - `选择对应系统,下载 ZIP 后解压。` + - `Release notes版本说明

`); homepage = homepage.replace(/]*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 `` + content - .replace(/[\s\S]*?<\/span\s*>/, `${label.en}`) - .replace(/[\s\S]*?<\/span\s*>/, `${label.zh}`) + ''; + if (provider === 'gitee') content = content + .replace(/[\s\S]*?<\/span\s*>/, `Gitee alternative · ${release.version} ZIP`) + .replace(/[\s\S]*?<\/span\s*>/, `Gitee 备用 · ${release.version} ZIP`); + return `${content}`; }); } await writeFile(new URL("index.html", output), homepage.replace( diff --git a/scripts/package-client-candidate.mjs b/scripts/package-client-candidate.mjs index 8687377..35d58d9 100644 --- a/scripts/package-client-candidate.mjs +++ b/scripts/package-client-candidate.mjs @@ -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"); diff --git a/site/downloads.ts b/site/downloads.ts index d2e063e..d666983 100644 --- a/site/downloads.ts +++ b/site/downloads.ts @@ -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 }; }), }; diff --git a/site/index.html b/site/index.html index dbc828c..e060419 100644 --- a/site/index.html +++ b/site/index.html @@ -716,8 +716,9 @@

- Choose Piik App for your system on the release page. - 在发布页选择对应系统的 Piik App 程序包。 + Download the ZIP for your system, then extract it. + 选择对应系统,下载 ZIP 后解压。 + Release notes版本说明

@@ -733,10 +734,10 @@ View GitHub releases查看 GitHub 版本Download ZIP · GitHub下载 ZIP · GitHubView Gitee mirror查看 Gitee 镜像 @@ -763,10 +764,10 @@ View GitHub releases查看 GitHub 版本Download ZIP · GitHub下载 ZIP · GitHubView Gitee mirror查看 Gitee 镜像 @@ -792,10 +793,10 @@ View GitHub releases查看 GitHub 版本Download ZIP · GitHub下载 ZIP · GitHubView Gitee mirror查看 Gitee 镜像 diff --git a/src/client/App.tsx b/src/client/App.tsx index bee0ec0..6f6443d 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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; } diff --git a/src/client/components/living/LauncherForm.tsx b/src/client/components/living/LauncherForm.tsx index 200298e..4d93fa3 100644 --- a/src/client/components/living/LauncherForm.tsx +++ b/src/client/components/living/LauncherForm.tsx @@ -67,7 +67,6 @@ export function LauncherForm({ { + 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); diff --git a/src/client/components/living/Stage.tsx b/src/client/components/living/Stage.tsx index b67575e..7bb68a6 100644 --- a/src/client/components/living/Stage.tsx +++ b/src/client/components/living/Stage.tsx @@ -106,29 +106,28 @@ export function StageOverlay({ (Boolean(spin) || comic === "waiting-for-host" || comic === "recovering")); const content = ( <> - {vis && comic ? : null} - {showMascot ? ( - - ) : ( - - - - )} - {vis ? progress && ( - - - {progress} - - ) : - {message}} + {(vis || !comic || onActivate) && (showMascot ? ( + + ) : ( + + + + ))} + {vis ? progress && ( + + + {progress} + + ) : {message}} ); if (onActivate) { return ( - ); @@ -137,7 +136,7 @@ export function StageOverlay({
diff --git a/src/client/components/living/Tooltip.tsx b/src/client/components/living/Tooltip.tsx index ebc8e36..b0640e4 100644 --- a/src/client/components/living/Tooltip.tsx +++ b/src/client/components/living/Tooltip.tsx @@ -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({ - {panelMounted - ? text ?? (kind + className={`lr-comic-tip${text !== undefined ? " is-text" : ""}${kind ? " has-comic" : ""}${placeClass}`} role="tooltip" aria-hidden={!interactionOpen}> + {panelMounted ? <> + {kind ? isHintKind(kind) ? : - : null) - : null} + : null} + {text !== undefined ? {text} : null} + : null} ); diff --git a/src/client/components/living/hints/index.tsx b/src/client/components/living/hints/index.tsx index 2583ddc..15fd000 100644 --- a/src/client/components/living/hints/index.tsx +++ b/src/client/components/living/hints/index.tsx @@ -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 diff --git a/src/client/components/living/playback-gestures.ts b/src/client/components/living/playback-gestures.ts new file mode 100644 index 0000000..cd16bb1 --- /dev/null +++ b/src/client/components/living/playback-gestures.ts @@ -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 | 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(); + }; +} diff --git a/src/client/components/living/primitives.tsx b/src/client/components/living/primitives.tsx index 95bf40c..6f9f82a 100644 --- a/src/client/components/living/primitives.tsx +++ b/src/client/components/living/primitives.tsx @@ -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; diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 7039cc2..78bfd56 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -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) { diff --git a/src/client/lib/release-update.ts b/src/client/lib/release-update.ts index a83478f..3fd20b3 100644 --- a/src/client/lib/release-update.ts +++ b/src/client/lib/release-update.ts @@ -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; 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; 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 { 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) { diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts index a2fc7fa..b77dc1f 100644 --- a/src/client/lib/session.ts +++ b/src/client/lib/session.ts @@ -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") && diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts index 092cd18..091593e 100644 --- a/src/client/locales/en.ts +++ b/src/client/locales/en.ts @@ -75,7 +75,7 @@ export const en: Record = { "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", diff --git a/src/client/locales/zh.ts b/src/client/locales/zh.ts index 219c6eb..b063dc2 100644 --- a/src/client/locales/zh.ts +++ b/src/client/locales/zh.ts @@ -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": "暂时无法启动,请检查设置后重试", diff --git a/src/client/pages/AppLauncherPage.tsx b/src/client/pages/AppLauncherPage.tsx index 77eca1c..52941a5 100644 --- a/src/client/pages/AppLauncherPage.tsx +++ b/src/client/pages/AppLauncherPage.tsx @@ -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); }) diff --git a/src/client/pages/ControlsPreview.tsx b/src/client/pages/ControlsPreview.tsx index 3caf5ca..391954f 100644 --- a/src/client/pages/ControlsPreview.tsx +++ b/src/client/pages/ControlsPreview.tsx @@ -49,7 +49,7 @@ export function ControlsPreview() { setPaused(!paused)} /> - +
@@ -58,13 +58,13 @@ export function ControlsPreview() { - +
02

{en ? "Pick, toggle, adjust" : "选一个,再拨一下。"}

- {(["720p30", "1080p30", "1080p60"] as const).map((value, index) => + {(["720p30", "1080p30", "1080p60"] as const).map((value, index) =>
- {(["open", "private"] as const).map(value => + {(["open", "private"] as const).map(value =>
{invalid ? : null} @@ -134,7 +135,7 @@ export function ControlsPreview() { {sourceOpen ? { setSourceOpen(false); notify(); }} onNative={() => { setSourceOpen(false); notify(); }} onPreview={previewSource} onRefresh={() => setSourceState("ready")} onCancel={() => setSourceOpen(false)} /> - :
setSourceOpen(true)} />
} + :
setSourceOpen(true)} />
}
diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index a401858..3423d2b 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -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 ? ( - - - {node} - - - ) : ( - - {node} - - ) - ) : ( - node - ); - return (
- {hintWrap( - "hint-close", - { - setDisplayNameDraft(displayName); - setDisplayNameError(null); - setEditingDisplayName(false); - }} - />, - "end", - )} + { + setDisplayNameDraft(displayName); + setDisplayNameError(null); + setEditingDisplayName(false); + }} + /> ) : ( <> @@ -3650,20 +3618,17 @@ export function HostPage({ name={labeledHostPresence?.label ?? displayName} identity={hostIdentity} /> - {hintWrap( - "hint-rename", - { - setDisplayNameDraft(displayName); - setDisplayNameError(null); - setEditingDisplayName(true); - }} - />, - "end", - )} + { + setDisplayNameDraft(displayName); + setDisplayNameError(null); + setEditingDisplayName(true); + }} + /> )} {displayNameError ? ( @@ -3677,47 +3642,41 @@ export function HostPage({ ) : null}
- {hintWrap( - showConnectionDetails ? "hint-collapse" : "hint-details", - - setShowConnectionDetails((current) => !current) - } - />, - "start", - )} - {hintWrap( - showTopology ? "hint-collapse" : "hint-topology", - setShowTopology((current) => !current)} - />, - "end", - )} + + setShowConnectionDetails((current) => !current) + } + /> + setShowTopology((current) => !current)} + />
{phase === "live" || phase === "starting" ? (
@@ -3732,41 +3691,32 @@ export function HostPage({ disabled={switchingSource || changingQuality} onClick={toggleSharingPause} /> - {hintWrap( - "hint-switch-source", - void switchSource()} - />, - "end", - )} - {hintWrap( - "hint-share-stop", - endSharing({ key: "host.stopNotice" })} - />, - "end", - )} + void switchSource()} + /> + endSharing({ key: "host.stopNotice" })} + /> ) : ( - hintWrap( - "hint-share-stop", - endSharing({ key: "host.startCancelled" })} - />, - "end", - ) + endSharing({ key: "host.startCancelled" })} + /> )}
) : null} @@ -4167,28 +4117,20 @@ export function HostPage({ - {hintWrap( - showAdvanced ? "hint-collapse" : "hint-advanced", + {/* Keep the tooltip trigger compact when mobile rows stretch. */} + 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" }, - )} + /> +

PIIK · 状态语义提案

同一事实,各有分寸。

-

标题说正在做什么,顶部说明信令,电视下沿图标说明画面;悬停查看文字或漫画详情。

+

标题说正在做什么,顶部说明信令,电视下沿图标说明画面;悬停查看图示与文字说明。

diff --git a/src/client/pages/TooltipPreviewPage.tsx b/src/client/pages/TooltipPreviewPage.tsx index 3c4c878..7882a81 100644 --- a/src/client/pages/TooltipPreviewPage.tsx +++ b/src/client/pages/TooltipPreviewPage.tsx @@ -68,6 +68,7 @@ export function TooltipPreviewPage() {
    {WELCOME_LINES.map(({ key }, index) =>
  1. {t(key)}
  2. )}

语义规则 / Semantic grammar

+

{en ? "Aa pairs the scene with a caption; ✦ shows the pure-visual version." : "Aa 查看图示与文字说明,✦ 查看纯视觉版本。"}

{EXAMPLES.map((example) => )}
diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 5e80353..c7559d6 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -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 ? ( - - {node} - - ) : ( - node - ); - return (