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版本说明
{en ? "That password did not match. Try again." : "密码没对上,再试一次。"}
: null} @@ -134,7 +135,7 @@ export function ControlsPreview() { {sourceOpen ?PIIK · 状态语义提案
标题说正在做什么,顶部说明信令,电视下沿图标说明画面;悬停查看文字或漫画详情。
标题说正在做什么,顶部说明信令,电视下沿图标说明画面;悬停查看图示与文字说明。
{en ? "Aa pairs the scene with a caption; ✦ shows the pure-visual version." : "Aa 查看图示与文字说明,✦ 查看纯视觉版本。"}