feat(ui): add illustrated hints and playback gestures
## Problem and outcome / 问题与结果 App startup rejected short or non-ASCII site passwords, viewers lacked picture-click playback gestures, and text-mode hints hid the existing illustrations. This phase accepts site passwords verbatim, combines shared illustrations with localized captions in tooltips and stage overlays, and adds desktop single-click play/pause and double-click fullscreen through the existing playback owner. Pure-visual mode remains available. App ZIP names are now stable per platform, allowing the website to use GitHub's native latest-download URLs. The App update link selects the ZIP matching its compiled OS and architecture from the checked release metadata, falling back to the release page if the package is unavailable. Verified Gitee links retain their explicit release version. The Windows packaging workflow also stops restoring a redundant Go toolchain cache that caused setup-go to fail. ## Release notes ### 中文 - 中英文操作提示和画面状态覆盖层加入动态漫画,说明更直观;纯视觉模式继续保留。 - 观众可单击画面暂停或继续播放,双击进入或退出全屏;触屏仍通过单击显示或隐藏播放栏。 - 修复设置短密码、中文密码时 App 或 Server 无法启动的问题。站点密码仍可留空。 - 官网各系统的 GitHub 下载按钮直接获取最新 ZIP 包,并保留 Gitee 备用下载线路。 - App 内的更新入口直接下载对应系统和架构的 ZIP 包。 ### English - Chinese and English tooltips and picture status overlays now pair concise explanations with animated illustrations. Pure-visual mode remains available. - Viewers can click the picture to pause or resume and double-click to enter or exit fullscreen. Touch taps continue to show or hide playback controls. - Fixed App and Server startup rejecting short or non-ASCII site passwords. Site passwords remain optional. - Each platform's GitHub download button now fetches the latest ZIP package, with Gitee retained as an alternative download source. - App update links download the ZIP matching the installed platform and architecture. ## Verification / 验证 - `npm run check`: 59 test files / 774 tests, TypeScript and production build passed. - `npm run check:client-core`: Go tests, formatting, vet and supported local target builds passed for the included runtime changes. Native macOS packaging remains on its macOS runner. - Desktop and 390px touch playback checks passed, including double-click without an intermediate pause and control/keyboard interaction. - Illustrated tooltip checks passed in Chinese, English and pure-visual modes, light/dark themes, 260/360/390px layouts, keyboard, touch hold and reduced motion. User accepted the resulting UI. - Real stage overlay components passed 46 checks across the three modes and 260/390/1280px layouts, including keyboard play activation, reduced motion, and no overlay over healthy playback with a quality limitation. - Bounded release review confirmed JSON password authentication retains old bodyless Bearer compatibility and ZIP names match packaging, publisher, mirror and website consumers. - After checking both providers' real API shapes, 27 focused update tests and type-checking passed; anonymous GitHub and Gitee metadata each selected all three actual platform ZIPs without downloading them. - Documentation, repository hygiene and diff checks passed. ## Impact / 影响 Compatible feature release. Signaling, Native control, capture protocol and persistent room schema are unchanged. The new latest-download filenames must be published before the matching website deploys; the existing CI-to-Website sequence provides that order. Production service updates use the immutable Server artifact and bounded rollback. Broader physical macOS/Linux, network and endurance acceptance stays under the existing documented deferrals.
This commit is contained in:
52 files changed
+715
-516
No files matched your search
@@ -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:
|
||||
|
||||
@@ -75,8 +75,8 @@ persistent quality evidence and route replacement. See
|
||||
and [status](../../docs/status.md) for its acceptance limits.
|
||||
|
||||
The App configuration keeps an optional Local site-access password. Leave it
|
||||
blank for an open Local site, or set a visible-ASCII password (8 to 128 bytes)
|
||||
in the launcher before starting. When present, the App passes it to its own
|
||||
blank for an open Local site, or enter a password in the launcher before
|
||||
starting. When present, the App passes it to its own
|
||||
Host page in a URL fragment; the page uses the existing SiteAccess endpoint and
|
||||
removes the fragment before continuing. Viewer invitations keep using the
|
||||
existing room-scoped grant.
|
||||
|
||||
+2
-1
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ SITE_ACCESS_PASSWORD=
|
||||
|
||||
程序会自动读取 `.env`,并将房间数据保存在工作目录的 `rooms.sqlite` 中。
|
||||
`SITE_ACCESS_PASSWORD` 留空时,进入站点无须口令;
|
||||
如需设置口令,可填写 8–128 个可见 ASCII 字符。房间邀请与加入权限仍由房主管理。
|
||||
如需设置口令,填写你想使用的口令即可。房间邀请与加入权限仍由房主管理。
|
||||
|
||||
### 2. 配置 HTTPS
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -88,7 +88,18 @@ revision. Do not maintain independent hand-edited App/Server/Web version files.
|
||||
Archive layout and checks stay in the
|
||||
existing packagers; a new build never replaces an already-published package.
|
||||
|
||||
Update checks compare actual release precedence and link to the matching release.
|
||||
App archive names are fixed per target: `piik-app-{target}.zip`. Each published
|
||||
tag owns its archive bytes; the full source SHA remains in `REVISION` and the
|
||||
release descriptor. Fixed names allow GitHub's native latest-asset links;
|
||||
[website operations](../operations/website.md#preview) owns download presentation
|
||||
and verification of the matching Gitee mirror.
|
||||
|
||||
Update checks compare actual release precedence. The App reports its compiled
|
||||
OS/architecture and links to that platform's ZIP in the selected release metadata;
|
||||
the release page remains the fallback when no matching attachment is available.
|
||||
Fixed archive names and published names with that release's SHA suffix are
|
||||
accepted only with the provider's exact release download URL. Downloads start on
|
||||
click; checking for an update does not fetch the archive.
|
||||
Newer version wins even when the source SHA is unchanged. Same version with a
|
||||
different known SHA reports a different official build, not a newer one. A
|
||||
development build with a known differing SHA offers the official release as a
|
||||
|
||||
+18
-8
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"}`,
|
||||
} {
|
||||
|
||||
@@ -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", "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -39,22 +39,15 @@ let homepage = (await readFile(new URL("index.html", source), "utf8"))
|
||||
if (process.env.PIIK_WEBSITE_RELEASE_DATA) {
|
||||
const { websiteDownloads } = await tsImport("../site/downloads.ts", { parentURL: import.meta.url });
|
||||
const release = websiteDownloads(JSON.parse(await readFile(process.env.PIIK_WEBSITE_RELEASE_DATA, "utf8")));
|
||||
homepage = homepage.replace(/<p class="release-note" id="download-status">[\s\S]*?<\/p>/,
|
||||
`<p class="release-note" id="download-status"><span class="release-label">${release.version}</span>` +
|
||||
`<span lang="en">Download the ZIP for your system, then extract it.</span>` +
|
||||
`<span lang="zh-CN">选择对应系统,下载 ZIP 后解压。</span>` +
|
||||
`<a href="${release.notes}"><span lang="en">Release notes</span><span lang="zh-CN">版本说明</span></a></p>`);
|
||||
homepage = homepage.replace(/<a\b([^>]*data-download="([^"]+)"[^>]*data-provider="([^"]+)"[^>]*)>([\s\S]*?)<\/a\s*>/g,
|
||||
(original, attributes, target, provider, content) => {
|
||||
const item = release.packages.find(item => item.target === target);
|
||||
const href = provider === 'github' ? item?.url : item?.mirrorURL;
|
||||
if (!href) return original;
|
||||
const label = provider === 'github'
|
||||
? { en: 'Download ZIP · GitHub', zh: '下载 ZIP · GitHub' }
|
||||
: { en: 'Gitee alternative (ZIP)', zh: 'Gitee 备用下载(ZIP)' };
|
||||
return `<a${attributes.replace(/href="[^"]*"/, `href="${href}"`)}>` + content
|
||||
.replace(/<span lang="en">[\s\S]*?<\/span\s*>/, `<span lang="en">${label.en}</span>`)
|
||||
.replace(/<span lang="zh-CN">[\s\S]*?<\/span\s*>/, `<span lang="zh-CN">${label.zh}</span>`) + '</a>';
|
||||
if (provider === 'gitee') content = content
|
||||
.replace(/<span lang="en">[\s\S]*?<\/span\s*>/, `<span lang="en">Gitee alternative · ${release.version} ZIP</span>`)
|
||||
.replace(/<span lang="zh-CN">[\s\S]*?<\/span\s*>/, `<span lang="zh-CN">Gitee 备用 · ${release.version} ZIP</span>`);
|
||||
return `<a${attributes.replace(/href="[^"]*"/, `href="${href}"`)}>${content}</a>`;
|
||||
});
|
||||
}
|
||||
await writeFile(new URL("index.html", output), homepage.replace(
|
||||
|
||||
@@ -368,8 +368,7 @@ try {
|
||||
run(process.execPath, assembleArguments, repositoryRoot);
|
||||
|
||||
mkdirSync(outputRoot, { recursive: false, mode: 0o700 });
|
||||
const shortRevision = revision.slice(0, 7);
|
||||
const archiveName = `piik-app-${target.id}-${shortRevision}.zip`;
|
||||
const archiveName = `piik-app-${target.id}.zip`;
|
||||
const archive = join(outputRoot, archiveName);
|
||||
createZip(packageRoot, archive);
|
||||
const extractedRoot = join(temporaryRoot, "extracted");
|
||||
|
||||
+5
-5
@@ -19,19 +19,19 @@ export function websiteDownloads(data: { github: unknown; mirror?: unknown }) {
|
||||
? assets(data.mirror) : [];
|
||||
return {
|
||||
version: release.version,
|
||||
notes: release.url,
|
||||
packages: targets.map(target => {
|
||||
const name = `piik-app-${target}-${release.revision!.slice(0, 7)}.zip`;
|
||||
const url = `https://github.com/TNTcraftHIM/Piik/releases/download/${release.version}/${name}`;
|
||||
const name = `piik-app-${target}.zip`;
|
||||
const publishedURL = `https://github.com/TNTcraftHIM/Piik/releases/download/${release.version}/${name}`;
|
||||
const matches = primaryAssets.filter(asset => asset.name === name);
|
||||
const asset = matches[0];
|
||||
if (matches.length !== 1 || asset?.state !== "uploaded" || asset.browser_download_url !== url ||
|
||||
if (matches.length !== 1 || asset?.state !== "uploaded" || asset.browser_download_url !== publishedURL ||
|
||||
!Number.isSafeInteger(asset.size) || Number(asset.size) <= 0) {
|
||||
throw new Error(`Website download is missing or invalid: ${target}`);
|
||||
}
|
||||
const mirrorURL = `https://gitee.com/TNTcraftHIM/Piik/releases/download/${release.version}/${name}`;
|
||||
const mirrored = mirrorAssets.filter(candidate => candidate.name === name);
|
||||
return { target, url, mirrorURL: mirrored.length === 1 && mirrored[0]!.size === asset.size &&
|
||||
return { target, url: `https://github.com/TNTcraftHIM/Piik/releases/latest/download/${name}`,
|
||||
mirrorURL: mirrored.length === 1 && mirrored[0]!.size === asset.size &&
|
||||
mirrored[0]!.browser_download_url === mirrorURL ? mirrorURL : null };
|
||||
}),
|
||||
};
|
||||
|
||||
+12
-11
@@ -716,8 +716,9 @@
|
||||
</p>
|
||||
</div>
|
||||
<p class="release-note" id="download-status">
|
||||
<span lang="en">Choose Piik App for your system on the release page.</span>
|
||||
<span lang="zh-CN">在发布页选择对应系统的 Piik App 程序包。</span>
|
||||
<span lang="en">Download the ZIP for your system, then extract it.</span>
|
||||
<span lang="zh-CN">选择对应系统,下载 ZIP 后解压。</span>
|
||||
<a href="https://github.com/TNTcraftHIM/Piik/releases/latest"><span lang="en">Release notes</span><span lang="zh-CN">版本说明</span></a>
|
||||
</p>
|
||||
<div class="download-cards">
|
||||
<article class="download-card" aria-labelledby="download-windows">
|
||||
@@ -733,10 +734,10 @@
|
||||
<a
|
||||
class="button download-button"
|
||||
data-download="windows-amd64" data-provider="github"
|
||||
href="https://github.com/TNTcraftHIM/Piik/releases"
|
||||
href="https://github.com/TNTcraftHIM/Piik/releases/latest/download/piik-app-windows-amd64.zip"
|
||||
aria-describedby="download-status"
|
||||
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">View GitHub releases</span
|
||||
><span lang="zh-CN">查看 GitHub 版本</span></a
|
||||
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">Download ZIP · GitHub</span
|
||||
><span lang="zh-CN">下载 ZIP · GitHub</span></a
|
||||
><a data-download="windows-amd64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases"
|
||||
><span lang="en">View Gitee mirror</span><span lang="zh-CN">查看 Gitee 镜像</span></a
|
||||
>
|
||||
@@ -763,10 +764,10 @@
|
||||
<a
|
||||
class="button download-button"
|
||||
data-download="darwin-arm64" data-provider="github"
|
||||
href="https://github.com/TNTcraftHIM/Piik/releases"
|
||||
href="https://github.com/TNTcraftHIM/Piik/releases/latest/download/piik-app-darwin-arm64.zip"
|
||||
aria-describedby="download-status"
|
||||
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">View GitHub releases</span
|
||||
><span lang="zh-CN">查看 GitHub 版本</span></a
|
||||
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">Download ZIP · GitHub</span
|
||||
><span lang="zh-CN">下载 ZIP · GitHub</span></a
|
||||
><a data-download="darwin-arm64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases"
|
||||
><span lang="en">View Gitee mirror</span><span lang="zh-CN">查看 Gitee 镜像</span></a
|
||||
>
|
||||
@@ -792,10 +793,10 @@
|
||||
<a
|
||||
class="button download-button"
|
||||
data-download="linux-amd64" data-provider="github"
|
||||
href="https://github.com/TNTcraftHIM/Piik/releases"
|
||||
href="https://github.com/TNTcraftHIM/Piik/releases/latest/download/piik-app-linux-amd64.zip"
|
||||
aria-describedby="download-status"
|
||||
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">View GitHub releases</span
|
||||
><span lang="zh-CN">查看 GitHub 版本</span></a
|
||||
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">Download ZIP · GitHub</span
|
||||
><span lang="zh-CN">下载 ZIP · GitHub</span></a
|
||||
><a data-download="linux-amd64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases"
|
||||
><span lang="en">View Gitee mirror</span><span lang="zh-CN">查看 Gitee 镜像</span></a
|
||||
>
|
||||
|
||||
+1
-1
@@ -369,7 +369,7 @@ function SiteAccessGate({
|
||||
event.preventDefault();
|
||||
const submittedPassword = password;
|
||||
setPassword("");
|
||||
if (!submittedPassword.trim()) {
|
||||
if (!submittedPassword) {
|
||||
setAccess({ kind: "required", error: t("gate.hint") });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ export function LauncherForm({
|
||||
<input
|
||||
type="text"
|
||||
value={localAccessPassword}
|
||||
maxLength={128}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={vis ? "" : t("client.launch.localAccess")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Btn } from "./primitives";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { usePlaybackControlsVisibility } from "./use-playback-controls-visibility";
|
||||
import { leavePictureInPicture, usePictureInPicture } from "./use-picture-in-picture";
|
||||
import { bindPlaybackGestures } from "./playback-gestures";
|
||||
import "./playback-controls.css";
|
||||
|
||||
type FullscreenVideo = HTMLVideoElement & {
|
||||
@@ -51,8 +52,22 @@ export function PlaybackControls({
|
||||
video.pause();
|
||||
}
|
||||
};
|
||||
const togglePlaybackRef = useRef(togglePlayback);
|
||||
togglePlaybackRef.current = togglePlayback;
|
||||
const toggleFullscreen = () => {
|
||||
if (!canFullscreen || theaterMode) return;
|
||||
const video = videoRef.current as FullscreenVideo | null;
|
||||
if (fullscreen) {
|
||||
void document.exitFullscreen().catch(() => undefined);
|
||||
} else if (document.fullscreenEnabled && video?.parentElement) {
|
||||
void video.parentElement.requestFullscreen()
|
||||
.then(() => leavePictureInPicture(video))
|
||||
.catch(() => undefined);
|
||||
} else {
|
||||
audioRef.current?.useNativeControls();
|
||||
video?.webkitEnterFullscreen?.();
|
||||
}
|
||||
};
|
||||
const actionsRef = useRef({ togglePlayback, toggleFullscreen });
|
||||
actionsRef.current = { togglePlayback, toggleFullscreen };
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current as FullscreenVideo | null;
|
||||
@@ -64,7 +79,7 @@ export function PlaybackControls({
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space" || event.target !== video) return;
|
||||
event.preventDefault();
|
||||
togglePlaybackRef.current();
|
||||
actionsRef.current.togglePlayback();
|
||||
};
|
||||
syncPlayback();
|
||||
syncFullscreen();
|
||||
@@ -72,7 +87,11 @@ export function PlaybackControls({
|
||||
for (const event of ["play", "pause", "ended", "emptied"]) video.addEventListener(event, syncPlayback);
|
||||
video.addEventListener("keydown", onKey);
|
||||
document.addEventListener("fullscreenchange", syncFullscreen);
|
||||
const unbindGestures = bindPlaybackGestures(video,
|
||||
() => actionsRef.current.togglePlayback(),
|
||||
() => actionsRef.current.toggleFullscreen());
|
||||
return () => {
|
||||
unbindGestures();
|
||||
output.dispose();
|
||||
audioRef.current = null;
|
||||
for (const event of ["play", "pause", "ended", "emptied"]) video.removeEventListener(event, syncPlayback);
|
||||
@@ -90,19 +109,6 @@ export function PlaybackControls({
|
||||
return () => track?.removeEventListener("ended", syncAudio);
|
||||
}, [stream, audioTrackKey]);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const video = videoRef.current as FullscreenVideo | null;
|
||||
if (fullscreen) {
|
||||
void document.exitFullscreen().catch(() => undefined);
|
||||
} else if (document.fullscreenEnabled && video?.parentElement) {
|
||||
void video.parentElement.requestFullscreen()
|
||||
.then(() => leavePictureInPicture(video))
|
||||
.catch(() => undefined);
|
||||
} else {
|
||||
audioRef.current?.useNativeControls();
|
||||
video?.webkitEnterFullscreen?.();
|
||||
}
|
||||
};
|
||||
const silent = audio.muted || audio.level === 0;
|
||||
const percent = Math.round(audio.level * 100);
|
||||
|
||||
|
||||
@@ -106,29 +106,28 @@ export function StageOverlay({
|
||||
(Boolean(spin) || comic === "waiting-for-host" || comic === "recovering"));
|
||||
const content = (
|
||||
<>
|
||||
{vis && comic ? <Comic kind={comic} theme="stage" tone={tone}
|
||||
{comic ? <Comic kind={comic} theme="stage" tone={tone}
|
||||
motion={transition || spin ? "progress" : undefined} /> : null}
|
||||
<span className="lr-tv-status-content">
|
||||
{showMascot ? (
|
||||
<BrandLoader />
|
||||
) : (
|
||||
<span className={`lr-tv-big${spin ? " lr-spin" : ""}${onActivate ? " is-action is-ripple" : ""}`}>
|
||||
<Glyph name={icon} size={30} draw="stage-overlay" />
|
||||
</span>
|
||||
)}
|
||||
{vis ? progress && (
|
||||
<span className="lr-tv-progress" aria-label={progress}>
|
||||
<Glyph name="refresh" size={17} className="lr-spin" />
|
||||
<span className="lr-tv-msg">{progress}</span>
|
||||
</span>
|
||||
) :
|
||||
<span className="lr-tv-msg">{message}</span>}
|
||||
{(vis || !comic || onActivate) && (showMascot ? (
|
||||
<BrandLoader />
|
||||
) : (
|
||||
<span className={`lr-tv-big${spin ? " lr-spin" : ""}${onActivate ? " is-action is-ripple" : ""}`}>
|
||||
<Glyph name={icon} size={30} draw="stage-overlay" />
|
||||
</span>
|
||||
))}
|
||||
{vis ? progress && (
|
||||
<span className="lr-tv-progress" aria-label={progress}>
|
||||
<Glyph name="refresh" size={17} className="lr-spin" />
|
||||
<span className="lr-tv-msg">{progress}</span>
|
||||
</span>
|
||||
) : <span className="lr-tv-msg">{message}</span>}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
if (onActivate) {
|
||||
return (
|
||||
<button type="button" className={`lr-tv-overlay${dim ? " is-dim" : ""}${vis && comic ? " has-comic" : ""}`} onClick={onActivate} aria-label={message}>
|
||||
<button type="button" className={`lr-tv-overlay${dim ? " is-dim" : ""}${comic ? " has-comic" : ""}`} onClick={onActivate} aria-label={message}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
@@ -137,7 +136,7 @@ export function StageOverlay({
|
||||
<div
|
||||
// Passive state layer: it must not intercept clicks meant for the
|
||||
// playback controls it covers (it owns no controls itself).
|
||||
className={`lr-tv-overlay is-passive${dim ? " is-dim" : ""}${vis && comic ? " has-comic" : ""}`}
|
||||
className={`lr-tv-overlay is-passive${dim ? " is-dim" : ""}${comic ? " has-comic" : ""}`}
|
||||
role="status"
|
||||
aria-label={message}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Shared tooltip: a floating paper panel showing text or a comic above (or below)
|
||||
// its trigger. Visibility is three-channel, matching platform conventions:
|
||||
// Shared tooltip: the same comic in every mode, with a localized caption in
|
||||
// text modes. Literal names and values can stand alone. Visibility has three channels:
|
||||
// hover on fine pointers, keyboard focus via :has(:focus-visible), and the
|
||||
// Material long-press on touch (500ms hold → open, ~1.5s after release →
|
||||
// auto-hide, context menu and the trailing synthetic click suppressed).
|
||||
@@ -358,14 +358,15 @@ export function Tooltip({
|
||||
<span ref={tipRef} id={tooltipId} popover="manual"
|
||||
data-tone={resolvedTone}
|
||||
style={{ ...comicStyle(resolvedTone, resolvedMotion), left: position.left, top: position.top, "--tooltip-caret": `${position.caret}px` } as CSSProperties}
|
||||
className={`lr-comic-tip${text !== undefined ? " is-text" : ""}${placeClass}`} role="tooltip" aria-hidden={!interactionOpen}>
|
||||
{panelMounted
|
||||
? text ?? (kind
|
||||
className={`lr-comic-tip${text !== undefined ? " is-text" : ""}${kind ? " has-comic" : ""}${placeClass}`} role="tooltip" aria-hidden={!interactionOpen}>
|
||||
{panelMounted ? <>
|
||||
{kind
|
||||
? isHintKind(kind)
|
||||
? <HintComic kind={kind} size={240} tone={resolvedTone} motion={resolvedMotion} />
|
||||
: <Comic kind={kind} theme="paper" size={240} tone={resolvedTone} motion={resolvedMotion} />
|
||||
: null)
|
||||
: null}
|
||||
: null}
|
||||
{text !== undefined ? <span className="lr-comic-tip-caption">{text}</span> : null}
|
||||
</> : null}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Control hint scenes (操作提示漫画): small 2-panel before→after idioms used
|
||||
// as hover/focus tooltips on controls in pure-visual mode, where no human
|
||||
// language may appear. Same cast, palette, 320x96 canvas, and motion
|
||||
// as hover/focus tooltips in every mode, with captions added by Tooltip.
|
||||
// Same cast, palette, 320x96 canvas, and motion
|
||||
// constitution as the state comics. Sets live in set1..set4.tsx;
|
||||
// each must define exactly its SetNKind keys — the merge below then proves
|
||||
// completeness at compile time. JSX-free shell: this module is also loaded
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// A native double-click follows two clicks. Defer only the picture shortcut;
|
||||
// visible controls and keyboard actions remain immediate.
|
||||
const SINGLE_CLICK_MS = 500;
|
||||
|
||||
export function bindPlaybackGestures(
|
||||
video: HTMLVideoElement,
|
||||
togglePlayback: () => void,
|
||||
toggleFullscreen: () => void,
|
||||
): () => void {
|
||||
const listeners = new AbortController();
|
||||
const options = { signal: listeners.signal };
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pointerType = "";
|
||||
const cancel = () => {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
};
|
||||
|
||||
// Beginning another action retires an uncommitted picture click, including
|
||||
// the second press of a double-click or an action in the control bar.
|
||||
video.parentElement?.addEventListener("pointerdown", (event) => {
|
||||
pointerType = event.pointerType;
|
||||
cancel();
|
||||
}, options);
|
||||
video.parentElement?.addEventListener("keydown", cancel, options);
|
||||
video.parentElement?.addEventListener("pointercancel", cancel, options);
|
||||
video.addEventListener("click", (event) => {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.detail !== 1 || pointerType === "touch") return;
|
||||
cancel();
|
||||
const source = video.srcObject;
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
if (video.srcObject === source) togglePlayback();
|
||||
}, SINGLE_CLICK_MS);
|
||||
}, options);
|
||||
video.addEventListener("dblclick", (event) => {
|
||||
if (event.defaultPrevented || event.button !== 0 || pointerType === "touch") return;
|
||||
cancel();
|
||||
toggleFullscreen();
|
||||
}, options);
|
||||
for (const event of ["play", "pause", "emptied"]) video.addEventListener(event, cancel, options);
|
||||
|
||||
return () => {
|
||||
cancel();
|
||||
listeners.abort();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -29,7 +29,23 @@ function normalizeVersion(value: unknown): string | null {
|
||||
return version ? `v${version}` : null;
|
||||
}
|
||||
|
||||
export function parseReleaseMetadata(value: unknown): PublishedRelease | null {
|
||||
function packageDownloadURL(release: PublishedRelease, assets: unknown, target?: string): string {
|
||||
if (!target || !/^[a-z0-9]+-[a-z0-9]+$/.test(target) || !Array.isArray(assets)) return release.url;
|
||||
const names = [`piik-app-${target}.zip`];
|
||||
if (release.revision) names.push(`piik-app-${target}-${release.revision.slice(0, 7)}.zip`);
|
||||
for (const name of names) {
|
||||
const matches = assets.filter(asset => asset?.name === name);
|
||||
if (!matches.length) continue;
|
||||
const asset = matches[0];
|
||||
const expectedURL = `${release.url.replace("/releases/tag/", "/releases/download/")}/${name}`;
|
||||
return matches.length === 1 && (release.url.startsWith(MIRROR_RELEASE_PAGE) ||
|
||||
(Number.isSafeInteger(asset.size) && asset.size > 0 && asset.state === "uploaded")) &&
|
||||
asset.browser_download_url === expectedURL ? asset.browser_download_url : release.url;
|
||||
}
|
||||
return release.url;
|
||||
}
|
||||
|
||||
export function parseReleaseMetadata(value: unknown, packageTarget?: string): PublishedRelease | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const release = value as Record<string, unknown>;
|
||||
if ((release.draft !== undefined && release.draft !== false) ||
|
||||
@@ -37,15 +53,16 @@ export function parseReleaseMetadata(value: unknown): PublishedRelease | null {
|
||||
const version = normalizeVersion(release.tag_name);
|
||||
if (!version || version !== release.tag_name || !/^v\d+\.\d+\.\d+$/.test(version) ||
|
||||
release.html_url !== RELEASE_PAGE + version) return null;
|
||||
return {
|
||||
const normalized = {
|
||||
version,
|
||||
// GitHub permits a branch here; only our publisher's full SHA is provenance.
|
||||
revision: normalizeReleaseRevision(release.target_commitish),
|
||||
url: RELEASE_PAGE + version,
|
||||
};
|
||||
return { ...normalized, url: packageDownloadURL(normalized, release.assets, packageTarget) };
|
||||
}
|
||||
|
||||
export function parseMirrorReleaseMetadata(value: unknown): PublishedRelease | null {
|
||||
export function parseMirrorReleaseMetadata(value: unknown, packageTarget?: string): PublishedRelease | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const release = value as Record<string, unknown>;
|
||||
if (release.prerelease !== false || typeof release.body !== "string") return null;
|
||||
@@ -56,7 +73,9 @@ export function parseMirrorReleaseMetadata(value: unknown): PublishedRelease | n
|
||||
tag_name: release.tag_name, html_url: RELEASE_PAGE + String(release.tag_name),
|
||||
target_commitish: sources[0][1],
|
||||
});
|
||||
return normalized ? { ...normalized, url: MIRROR_RELEASE_PAGE + normalized.version } : null;
|
||||
if (!normalized) return null;
|
||||
const mirror = { ...normalized, url: MIRROR_RELEASE_PAGE + normalized.version };
|
||||
return { ...mirror, url: packageDownloadURL(mirror, release.assets, packageTarget) };
|
||||
}
|
||||
|
||||
export function releaseUpdateNotice(
|
||||
@@ -85,6 +104,7 @@ export async function checkReleaseUpdate(
|
||||
mirrorAPIURL?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
packageTarget?: string;
|
||||
} = {},
|
||||
): Promise<ReleaseUpdateNotice | null> {
|
||||
if (!normalizeVersion(current.version) && !normalizeReleaseRevision(current.revision)) return null;
|
||||
@@ -117,13 +137,13 @@ export async function checkReleaseUpdate(
|
||||
if (!response.ok) break;
|
||||
const data: unknown = await response.json();
|
||||
if (!source.mirror) {
|
||||
latest = parseReleaseMetadata(data);
|
||||
latest = parseReleaseMetadata(data, options.packageTarget);
|
||||
if (latest) return releaseUpdateNotice(current, latest);
|
||||
break;
|
||||
}
|
||||
if (!Array.isArray(data) || data.length > 100) break;
|
||||
for (const value of data) {
|
||||
const candidate = parseMirrorReleaseMetadata(value);
|
||||
const candidate = parseMirrorReleaseMetadata(value, options.packageTarget);
|
||||
if (candidate && (!latest || compare(candidate.version, latest.version) > 0)) latest = candidate;
|
||||
}
|
||||
if (data.length < 100) {
|
||||
|
||||
@@ -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") &&
|
||||
|
||||
@@ -75,7 +75,7 @@ export const en: Record<CopyKey, string> = {
|
||||
"client.launch.siteHint": "Use an existing Piik Site",
|
||||
"client.launch.siteAddress": "Piik Site address",
|
||||
"client.launch.localAccess": "Site passphrase (optional)",
|
||||
"client.launch.localAccessHint": "If set, visitors enter this passphrase before accessing your site. Use 8–128 English letters, numbers or keyboard symbols, without spaces.",
|
||||
"client.launch.localAccessHint": "If set, visitors enter this passphrase before accessing your site.",
|
||||
"client.launch.go": "Open Piik",
|
||||
"client.launch.starting": "Preparing Piik",
|
||||
"client.launch.error": "Piik could not start; check the setting and retry",
|
||||
|
||||
@@ -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": "暂时无法启动,请检查设置后重试",
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ControlsPreview() {
|
||||
<Btn icon="stop" tone="danger" title="host.stop" cap="host.stop" hint="hint-share-stop" onClick={notify} />
|
||||
<Btn icon={paused ? "play" : "pause"} title={paused ? "host.resume" : "host.pause"} cap={paused ? "host.resume" : "host.pause"}
|
||||
hint={paused ? "hint-resume" : "hint-pause"} draw="preview-pause" pressed={paused} onClick={() => setPaused(!paused)} />
|
||||
<Btn icon="gauge" tone="on" title="host.details" cap="host.details" pressed onClick={notify} />
|
||||
<Btn icon="gauge" tone="on" title="host.details" cap="host.details" hint="hint-details" pressed onClick={notify} />
|
||||
<Btn icon="refresh" title="viewer.reconnect" cap="viewer.reconnect" hint="hint-reconnect" disabled />
|
||||
<Btn icon="cast" title="host.starting" cap="host.starting" busy disabled hint="hint-share-start" />
|
||||
</div>
|
||||
@@ -58,13 +58,13 @@ export function ControlsPreview() {
|
||||
<Btn icon="link" title="host.invite.copy" hint="hint-copy-invite" onClick={notify} />
|
||||
<Btn icon="linkOff" title="host.invite.revoke" hint="hint-revoke-invite" onClick={notify} />
|
||||
<Btn icon="network" title="host.topology" hint="hint-topology" onClick={notify} />
|
||||
<Btn icon="sliders" title="host.advanced" onClick={notify} />
|
||||
<Btn icon="sliders" title="host.advanced" hint="hint-advanced" onClick={notify} />
|
||||
</div>
|
||||
</section>
|
||||
<section id="option-preview" className="cp-card">
|
||||
<header><span className="cp-number">02</span><h2>{en ? "Pick, toggle, adjust" : "选一个,再拨一下。"}</h2></header>
|
||||
<div className="lr-tiles" role="group" aria-label={t("host.quality")}>
|
||||
{(["720p30", "1080p30", "1080p60"] as const).map((value, index) => <Tooltip key={value} kind="hint-quality" text={t(`host.quality.${value}`)}>
|
||||
{(["720p30", "1080p30", "1080p60"] as const).map((value, index) => <Tooltip key={value} kind="hint-quality" text={vis ? undefined : t(`host.quality.${value}`)}>
|
||||
<button type="button" className={`lr-tile${preset === value ? " is-selected" : ""}`} aria-pressed={preset === value}
|
||||
aria-label={t(`host.quality.${value}`)} onClick={() => setPreset(value)}>
|
||||
<Glyph name={["mountain", "balance", "zap"][index]!} size={23} /><small>{value.replace("p", "p · ")}</small>
|
||||
@@ -77,7 +77,7 @@ export function ControlsPreview() {
|
||||
</div>
|
||||
<div className="cp-tools">
|
||||
<span className="lr-toggle" role="group" aria-label={t("host.policy")} data-selected={policy}>
|
||||
{(["open", "private"] as const).map(value => <Tooltip key={value} kind={value === "open" ? "hint-policy-open" : "hint-policy-private"} text={t(`host.policy.${value}`)}>
|
||||
{(["open", "private"] as const).map(value => <Tooltip key={value} kind={value === "open" ? "hint-policy-open" : "hint-policy-private"} text={vis ? undefined : t(`host.policy.${value}`)}>
|
||||
<button type="button" className={policy === value ? "is-selected" : undefined} aria-pressed={policy === value}
|
||||
aria-label={t(`host.policy.${value}`)} onClick={() => setPolicy(value)}>
|
||||
<Glyph name={value === "open" ? "globe" : "lock"} size={19} />{vis ? null : <span className="lr-cap">{t(`host.policy.${value}`)}</span>}
|
||||
@@ -95,7 +95,8 @@ export function ControlsPreview() {
|
||||
<label className="lr-input"><Glyph name="key" size={17} /><input type={passwordVisible ? "text" : "password"}
|
||||
placeholder={t("join.password")} aria-label={t("join.password")} aria-invalid={invalid} aria-describedby={invalid ? "preview-input-error" : undefined} autoComplete="off" />
|
||||
</label>
|
||||
<Btn icon={passwordVisible ? "eyeOff" : "eye"} title={passwordVisible ? "host.password.hide" : "host.password.show"} onClick={() => setPasswordVisible(!passwordVisible)} />
|
||||
<Btn icon={passwordVisible ? "eyeOff" : "eye"} title={passwordVisible ? "host.password.hide" : "host.password.show"}
|
||||
hint={passwordVisible ? "hint-password-hide" : "hint-password-show"} onClick={() => setPasswordVisible(!passwordVisible)} />
|
||||
<SwitchItem checked={invalid} onChange={setInvalid} label={en ? "Show error" : "看看错误态"} />
|
||||
</div>
|
||||
{invalid ? <p id="preview-input-error" className="cp-input-error" role="alert">{en ? "That password did not match. Try again." : "密码没对上,再试一次。"}</p> : null}
|
||||
@@ -134,7 +135,7 @@ export function ControlsPreview() {
|
||||
{sourceOpen ? <CaptureSourcePicker nativeSources={sourceState === "ready" ? SOURCES : { kind: sourceState }}
|
||||
onBrowser={() => { setSourceOpen(false); notify(); }} onNative={() => { setSourceOpen(false); notify(); }}
|
||||
onPreview={previewSource} onRefresh={() => setSourceState("ready")} onCancel={() => setSourceOpen(false)} />
|
||||
: <div className="cp-stage-action"><Btn icon="cast" tone="primary" title="host.start" cap="host.start" onClick={() => setSourceOpen(true)} /></div>}
|
||||
: <div className="cp-stage-action"><Btn icon="cast" tone="primary" title="host.start" cap="host.start" hint="hint-share-start" onClick={() => setSourceOpen(true)} /></div>}
|
||||
</StageTv></div>
|
||||
</section>
|
||||
<section id="playback-preview" className="cp-section">
|
||||
|
||||
+85
-143
@@ -4,9 +4,7 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
@@ -43,7 +41,6 @@ import {
|
||||
} from "../components/living/ViewerOverview";
|
||||
import type { ComicKind } from "../components/living/Comic";
|
||||
import { Tooltip } from "../components/living/Tooltip";
|
||||
import type { HintKind } from "../components/living/hints";
|
||||
import {
|
||||
StageOverlay,
|
||||
StageTv,
|
||||
@@ -3313,32 +3310,6 @@ export function HostPage({
|
||||
);
|
||||
useDocumentTitle([room?.roomId, titleContent[0]], titleContent.slice(1));
|
||||
|
||||
// These Btn triggers already own their text tooltip; add only their visual comic.
|
||||
// wrapStyle adds a layout span around the tooltip wrapper (vis mode only)
|
||||
// for triggers whose flex context would otherwise stretch the wrapper away
|
||||
// from the trigger it must hug.
|
||||
const hintWrap = (
|
||||
kind: HintKind,
|
||||
node: ReactNode,
|
||||
align: "start" | "center" | "end" = "center",
|
||||
wrapStyle?: CSSProperties,
|
||||
): ReactNode =>
|
||||
vis ? (
|
||||
wrapStyle ? (
|
||||
<span style={wrapStyle}>
|
||||
<Tooltip kind={kind} align={align}>
|
||||
{node}
|
||||
</Tooltip>
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip kind={kind} align={align}>
|
||||
{node}
|
||||
</Tooltip>
|
||||
)
|
||||
) : (
|
||||
node
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="lr-app">
|
||||
<AppHeader
|
||||
@@ -3630,19 +3601,16 @@ export function HostPage({
|
||||
type="submit"
|
||||
disabled={displayNameDraft === displayName}
|
||||
/>
|
||||
{hintWrap(
|
||||
"hint-close",
|
||||
<Btn
|
||||
icon="x"
|
||||
title="host.nameCancel"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
setEditingDisplayName(false);
|
||||
}}
|
||||
/>,
|
||||
"end",
|
||||
)}
|
||||
<Btn
|
||||
icon="x"
|
||||
title="host.nameCancel"
|
||||
hint="hint-close"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
setEditingDisplayName(false);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
@@ -3650,20 +3618,17 @@ export function HostPage({
|
||||
name={labeledHostPresence?.label ?? displayName}
|
||||
identity={hostIdentity}
|
||||
/>
|
||||
{hintWrap(
|
||||
"hint-rename",
|
||||
<Btn
|
||||
icon="pencil"
|
||||
cap="common.edit"
|
||||
title="host.nameEdit"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
setEditingDisplayName(true);
|
||||
}}
|
||||
/>,
|
||||
"end",
|
||||
)}
|
||||
<Btn
|
||||
icon="pencil"
|
||||
cap="common.edit"
|
||||
title="host.nameEdit"
|
||||
hint="hint-rename"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(null);
|
||||
setEditingDisplayName(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{displayNameError ? (
|
||||
@@ -3677,47 +3642,41 @@ export function HostPage({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="lr-row-group lr-group-actions lr-host-diagnostics-slot">
|
||||
{hintWrap(
|
||||
showConnectionDetails ? "hint-collapse" : "hint-details",
|
||||
<Btn
|
||||
icon="gauge"
|
||||
cap={
|
||||
showConnectionDetails
|
||||
? "host.details.hide"
|
||||
: "host.details"
|
||||
}
|
||||
title={
|
||||
showConnectionDetails
|
||||
? "host.details.hide"
|
||||
: "host.details"
|
||||
}
|
||||
tone={showConnectionDetails ? "on" : undefined}
|
||||
expanded={showConnectionDetails}
|
||||
controls="host-details-panel host-viewer-overview"
|
||||
disabled={!hostDiagnosticsAvailable}
|
||||
onClick={() =>
|
||||
setShowConnectionDetails((current) => !current)
|
||||
}
|
||||
/>,
|
||||
"start",
|
||||
)}
|
||||
{hintWrap(
|
||||
showTopology ? "hint-collapse" : "hint-topology",
|
||||
<Btn
|
||||
icon="network"
|
||||
cap="host.topology"
|
||||
title={
|
||||
showTopology
|
||||
? "host.topology.hide"
|
||||
: "host.topology.show"
|
||||
}
|
||||
tone={showTopology ? "on" : undefined}
|
||||
expanded={showTopology}
|
||||
controls="room-topology"
|
||||
onClick={() => setShowTopology((current) => !current)}
|
||||
/>,
|
||||
"end",
|
||||
)}
|
||||
<Btn
|
||||
icon="gauge"
|
||||
cap={
|
||||
showConnectionDetails
|
||||
? "host.details.hide"
|
||||
: "host.details"
|
||||
}
|
||||
title={
|
||||
showConnectionDetails
|
||||
? "host.details.hide"
|
||||
: "host.details"
|
||||
}
|
||||
hint={showConnectionDetails ? "hint-collapse" : "hint-details"}
|
||||
tone={showConnectionDetails ? "on" : undefined}
|
||||
expanded={showConnectionDetails}
|
||||
controls="host-details-panel host-viewer-overview"
|
||||
disabled={!hostDiagnosticsAvailable}
|
||||
onClick={() =>
|
||||
setShowConnectionDetails((current) => !current)
|
||||
}
|
||||
/>
|
||||
<Btn
|
||||
icon="network"
|
||||
cap="host.topology"
|
||||
title={
|
||||
showTopology
|
||||
? "host.topology.hide"
|
||||
: "host.topology.show"
|
||||
}
|
||||
hint={showTopology ? "hint-collapse" : "hint-topology"}
|
||||
tone={showTopology ? "on" : undefined}
|
||||
expanded={showTopology}
|
||||
controls="room-topology"
|
||||
onClick={() => setShowTopology((current) => !current)}
|
||||
/>
|
||||
</div>
|
||||
{phase === "live" || phase === "starting" ? (
|
||||
<div className="lr-row-group lr-group-actions lr-host-share-slot">
|
||||
@@ -3732,41 +3691,32 @@ export function HostPage({
|
||||
disabled={switchingSource || changingQuality}
|
||||
onClick={toggleSharingPause}
|
||||
/>
|
||||
{hintWrap(
|
||||
"hint-switch-source",
|
||||
<Btn
|
||||
icon="switchSource"
|
||||
cap={switchingSource ? "host.switching" : "host.switchSource"}
|
||||
title="host.switchSource"
|
||||
disabled={switchingSource || changingQuality}
|
||||
onClick={() => void switchSource()}
|
||||
/>,
|
||||
"end",
|
||||
)}
|
||||
{hintWrap(
|
||||
"hint-share-stop",
|
||||
<Btn
|
||||
icon="stop"
|
||||
tone="danger"
|
||||
cap="host.stop"
|
||||
title="host.stop"
|
||||
onClick={() => endSharing({ key: "host.stopNotice" })}
|
||||
/>,
|
||||
"end",
|
||||
)}
|
||||
<Btn
|
||||
icon="switchSource"
|
||||
cap={switchingSource ? "host.switching" : "host.switchSource"}
|
||||
title="host.switchSource"
|
||||
hint="hint-switch-source"
|
||||
disabled={switchingSource || changingQuality}
|
||||
onClick={() => void switchSource()}
|
||||
/>
|
||||
<Btn
|
||||
icon="stop"
|
||||
tone="danger"
|
||||
cap="host.stop"
|
||||
title="host.stop"
|
||||
hint="hint-share-stop"
|
||||
onClick={() => endSharing({ key: "host.stopNotice" })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
hintWrap(
|
||||
"hint-share-stop",
|
||||
<Btn
|
||||
icon="x"
|
||||
tone="danger"
|
||||
cap="host.cancelStart"
|
||||
title="host.cancelStart"
|
||||
onClick={() => endSharing({ key: "host.startCancelled" })}
|
||||
/>,
|
||||
"end",
|
||||
)
|
||||
<Btn
|
||||
icon="x"
|
||||
tone="danger"
|
||||
cap="host.cancelStart"
|
||||
title="host.cancelStart"
|
||||
hint="hint-close"
|
||||
onClick={() => endSharing({ key: "host.startCancelled" })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -4167,28 +4117,20 @@ export function HostPage({
|
||||
</div>
|
||||
</RowGroup>
|
||||
<span className="lr-spacer" />
|
||||
{hintWrap(
|
||||
showAdvanced ? "hint-collapse" : "hint-advanced",
|
||||
{/* Keep the tooltip trigger compact when mobile rows stretch. */}
|
||||
<span style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Btn
|
||||
icon="sliders"
|
||||
busy={changingQuality}
|
||||
cap="host.advanced"
|
||||
title={showAdvanced ? "host.advanced.hide" : "host.advanced"}
|
||||
hint={showAdvanced ? "hint-collapse" : "hint-advanced"}
|
||||
tone={showAdvanced ? "on" : undefined}
|
||||
expanded={showAdvanced}
|
||||
controls="host-advanced-door"
|
||||
onClick={() => setShowAdvanced((current) => !current)}
|
||||
/>,
|
||||
"end",
|
||||
// Mobile stacks `.lr-row` into a stretch column, which would
|
||||
// stretch the tooltip wrapper to full row width and leave its
|
||||
// wrapper-centered caret (and end-aligned panel) floating in
|
||||
// empty card space away from the button. Take the stretch on a
|
||||
// layout span instead and park the wrapper at the row's end,
|
||||
// matching the desktop row-right placement; in the desktop row
|
||||
// layout the span shrink-wraps and this is a no-op.
|
||||
{ display: "flex", justifyContent: "flex-end" },
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</Row>
|
||||
<div
|
||||
id="host-advanced-door"
|
||||
|
||||
@@ -57,7 +57,7 @@ export function StatusPreviewPage() {
|
||||
<main className="sp-main">
|
||||
<header className="sp-heading">
|
||||
<div><p className="sp-kicker">PIIK · 状态语义提案</p><h1>同一事实,各有分寸。</h1>
|
||||
<p>标题说正在做什么,顶部说明信令,电视下沿图标说明画面;悬停查看文字或漫画详情。</p></div>
|
||||
<p>标题说正在做什么,顶部说明信令,电视下沿图标说明画面;悬停查看图示与文字说明。</p></div>
|
||||
<label className="sp-motion"><input type="checkbox" checked={reducedMotion}
|
||||
onChange={(event) => setReducedMotion(event.target.checked)} />减少动态效果</label>
|
||||
</header>
|
||||
|
||||
@@ -68,6 +68,7 @@ export function TooltipPreviewPage() {
|
||||
<ol>{WELCOME_LINES.map(({ key }, index) => <li key={key}><span>{t(key)}</span><WelcomeCipher line={index} /></li>)}</ol>
|
||||
</details>
|
||||
<h2 id="comic-preview">语义规则 / Semantic grammar</h2>
|
||||
<p>{en ? "Aa pairs the scene with a caption; ✦ shows the pure-visual version." : "Aa 查看图示与文字说明,✦ 查看纯视觉版本。"}</p>
|
||||
<div className="lr-tooltip-preview-grid">
|
||||
{EXAMPLES.map((example) => <PreviewCard key={example.label} {...example} text={t(example.text)} />)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
@@ -31,8 +30,6 @@ import { PawnDetail, RouteGlyph } from "../components/living/PawnDetail";
|
||||
import { Lcd } from "../components/living/RoomChip";
|
||||
import { RouteTree } from "../components/living/RouteTree";
|
||||
import { Comic, type ComicKind } from "../components/living/Comic";
|
||||
import { Tooltip } from "../components/living/Tooltip";
|
||||
import type { HintKind } from "../components/living/hints";
|
||||
import {
|
||||
StageOverlay,
|
||||
StageTv,
|
||||
@@ -2369,20 +2366,6 @@ export function ViewerPage({
|
||||
)
|
||||
.map((viewer) => viewer.peerId);
|
||||
|
||||
// Btn owns its text tooltip; these wrappers supply only its visual comic.
|
||||
const hintWrap = (
|
||||
kind: HintKind,
|
||||
node: ReactNode,
|
||||
align: "start" | "center" | "end" = "center",
|
||||
): ReactNode =>
|
||||
vis ? (
|
||||
<Tooltip kind={kind} align={align}>
|
||||
{node}
|
||||
</Tooltip>
|
||||
) : (
|
||||
node
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="lr-app">
|
||||
<style>{`
|
||||
@@ -2581,20 +2564,17 @@ export function ViewerPage({
|
||||
name={displayName}
|
||||
identity={selfPeerId ?? viewerClientId}
|
||||
/>
|
||||
{hintWrap(
|
||||
"hint-rename",
|
||||
<Btn
|
||||
icon="pencil"
|
||||
cap="common.edit"
|
||||
title="host.nameEdit"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(false);
|
||||
setEditingDisplayName(true);
|
||||
}}
|
||||
/>,
|
||||
"end",
|
||||
)}
|
||||
<Btn
|
||||
icon="pencil"
|
||||
cap="common.edit"
|
||||
title="host.nameEdit"
|
||||
hint="hint-rename"
|
||||
onClick={() => {
|
||||
setDisplayNameDraft(displayName);
|
||||
setDisplayNameError(false);
|
||||
setEditingDisplayName(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{displayNameError && (
|
||||
@@ -2632,28 +2612,25 @@ export function ViewerPage({
|
||||
setShowConnectionDetails((current) => !current)
|
||||
}
|
||||
/>
|
||||
{hintWrap(
|
||||
labeledHostPresence && showTopology ? "hint-collapse" : "hint-topology",
|
||||
<Btn
|
||||
icon="network"
|
||||
cap="host.topology"
|
||||
title={
|
||||
labeledHostPresence && showTopology
|
||||
? "host.topology.hide"
|
||||
: "host.topology.show"
|
||||
}
|
||||
tone={
|
||||
labeledHostPresence && showTopology ? "on" : undefined
|
||||
}
|
||||
expanded={Boolean(labeledHostPresence && showTopology)}
|
||||
controls="room-topology"
|
||||
disabled={!labeledHostPresence}
|
||||
onClick={() =>
|
||||
setShowTopology((current) => !current)
|
||||
}
|
||||
/>,
|
||||
"start",
|
||||
)}
|
||||
<Btn
|
||||
icon="network"
|
||||
cap="host.topology"
|
||||
title={
|
||||
labeledHostPresence && showTopology
|
||||
? "host.topology.hide"
|
||||
: "host.topology.show"
|
||||
}
|
||||
hint={labeledHostPresence && showTopology ? "hint-collapse" : "hint-topology"}
|
||||
tone={
|
||||
labeledHostPresence && showTopology ? "on" : undefined
|
||||
}
|
||||
expanded={Boolean(labeledHostPresence && showTopology)}
|
||||
controls="room-topology"
|
||||
disabled={!labeledHostPresence}
|
||||
onClick={() =>
|
||||
setShowTopology((current) => !current)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+25
-7
@@ -303,9 +303,24 @@ body.lr-theater-open { overflow: hidden; }
|
||||
.lr-tooltip-preview-trigger { width: 36px; height: 36px; padding: 0; border: 1px solid var(--wall-2); border-radius: 999px; background: transparent; color: var(--ink); cursor: help; }
|
||||
.lr-tooltip-preview-art { min-height: 106px; display: grid; place-items: center; overflow: hidden; }
|
||||
.lr-tooltip-preview-art svg { max-width: 100%; height: auto; }
|
||||
.lr-tv-overlay.has-comic { gap: 8px; padding: 10px; }
|
||||
.lr-tv-overlay.has-comic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
.lr-tv-overlay.has-comic > svg { min-height: 0; flex-shrink: 1; }
|
||||
.lr-tv-overlay.has-comic .lr-tv-status-content {
|
||||
flex: none;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.lr-tv-overlay.has-comic .lr-tv-msg { line-height: 1.35; }
|
||||
.lr-tv-overlay.has-comic .lr-brand-loader,
|
||||
.lr-tv-overlay.has-comic .lr-tv-big { width: 40px; height: 40px; }
|
||||
.lr-tv-overlay.has-comic .lr-tv-big { width: 40px; height: 40px; flex-shrink: 0; }
|
||||
.lr-tv-overlay.has-comic .lr-brand-mark { width: 32px; height: 32px; }
|
||||
|
||||
.lr-tv-big {
|
||||
@@ -1750,6 +1765,13 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.lr-comic-tip.has-comic.is-text { width: 272px; }
|
||||
.lr-comic-tip > svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin-inline: auto;
|
||||
}
|
||||
/* Keep the panel reachable across the small gap while reading it. */
|
||||
.lr-comic-tip::after {
|
||||
content: "";
|
||||
@@ -1830,12 +1852,8 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
|
||||
|
||||
.lr-comic-tip-caption {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
opacity: .72;
|
||||
}
|
||||
.lr-comic-tip > svg + .lr-comic-tip-caption { margin-top: 8px; }
|
||||
|
||||
/* ---------- reduced motion: everything collapses to instant ---------- */
|
||||
|
||||
|
||||
+10
-11
@@ -672,7 +672,7 @@ describe("client session identity", () => {
|
||||
});
|
||||
|
||||
describe("site access API", () => {
|
||||
it("checks and authenticates site access without a request body", async () => {
|
||||
it("checks site access and sends the exact password in a Unicode-capable JSON body", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
@@ -693,7 +693,7 @@ describe("site access API", () => {
|
||||
required: true,
|
||||
authenticated: false,
|
||||
});
|
||||
await expect(authenticateSiteAccess(" instance-password ")).resolves.toEqual({
|
||||
await expect(authenticateSiteAccess(" 中文 +& ")).resolves.toEqual({
|
||||
required: true,
|
||||
authenticated: true,
|
||||
});
|
||||
@@ -704,10 +704,9 @@ describe("site access API", () => {
|
||||
]);
|
||||
const post = fetchMock.mock.calls[1][1];
|
||||
expect(post?.method).toBe("POST");
|
||||
expect(post?.body).toBeUndefined();
|
||||
expect(new Headers(post?.headers).get("Authorization")).toBe(
|
||||
"Bearer instance-password",
|
||||
);
|
||||
expect(JSON.parse(String(post?.body))).toEqual({ password: " 中文 +& " });
|
||||
expect(new Headers(post?.headers).get("Content-Type")).toBe("application/json");
|
||||
expect(new Headers(post?.headers).has("Authorization")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a clear Chinese message for a rejected password", async () => {
|
||||
@@ -910,10 +909,10 @@ describe("room codes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a malformed App bootstrap without authenticating it", () => {
|
||||
it("removes an empty App bootstrap without authenticating it", () => {
|
||||
const replaceState = vi.fn();
|
||||
vi.stubGlobal("window", {
|
||||
location: { hash: "#client-access=short", pathname: "/", search: "" },
|
||||
location: { hash: "#client-access=", pathname: "/", search: "" },
|
||||
history: { state: null, replaceState },
|
||||
});
|
||||
|
||||
@@ -921,18 +920,18 @@ describe("room codes", () => {
|
||||
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
|
||||
});
|
||||
|
||||
it("accepts a bounded user-chosen App access value", () => {
|
||||
it.each(["x", "中文", " ", " 中文 +& ", "x".repeat(256)])("preserves a chosen App access value: %s", (password) => {
|
||||
const replaceState = vi.fn();
|
||||
vi.stubGlobal("window", {
|
||||
location: {
|
||||
hash: "#client-access=a%2Bb%26c%3Fd%3De",
|
||||
hash: `#${new URLSearchParams({ "client-access": password })}`,
|
||||
pathname: "/",
|
||||
search: "",
|
||||
},
|
||||
history: { state: null, replaceState },
|
||||
});
|
||||
|
||||
expect(takeClientLaunchBootstrap().accessToken).toBe("a+b&c?d=e");
|
||||
expect(takeClientLaunchBootstrap().accessToken).toBe(password);
|
||||
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { bindPlaybackGestures } from "../src/client/components/living/playback-gestures";
|
||||
|
||||
function picture() {
|
||||
const screen = new EventTarget();
|
||||
const video = Object.assign(new EventTarget(), {
|
||||
parentElement: screen,
|
||||
srcObject: {} as object | null,
|
||||
});
|
||||
const playback = vi.fn();
|
||||
const fullscreen = vi.fn();
|
||||
const dispose = bindPlaybackGestures(video as unknown as HTMLVideoElement, playback, fullscreen);
|
||||
const pointer = (pointerType = "mouse") => screen.dispatchEvent(Object.assign(new Event("pointerdown"), { pointerType }));
|
||||
const click = (detail = 1, button = 0) => video.dispatchEvent(Object.assign(new Event("click"), { detail, button }));
|
||||
const doubleClick = () => video.dispatchEvent(Object.assign(new Event("dblclick"), { detail: 2, button: 0 }));
|
||||
return { screen, video, playback, fullscreen, dispose, pointer, click, doubleClick };
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("Viewer picture gestures", () => {
|
||||
it("waits for a single click and gives double-click fullscreen priority without changing playback", () => {
|
||||
vi.useFakeTimers();
|
||||
const view = picture();
|
||||
view.pointer();
|
||||
view.click();
|
||||
vi.advanceTimersByTime(499);
|
||||
expect(view.playback).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(view.playback).toHaveBeenCalledOnce();
|
||||
|
||||
view.playback.mockClear();
|
||||
view.pointer();
|
||||
view.click();
|
||||
vi.advanceTimersByTime(300);
|
||||
view.pointer();
|
||||
// The second press cancels the single even if its release comes later.
|
||||
vi.advanceTimersByTime(300);
|
||||
view.click(2);
|
||||
view.doubleClick();
|
||||
vi.runAllTimers();
|
||||
expect(view.playback).not.toHaveBeenCalled();
|
||||
expect(view.fullscreen).toHaveBeenCalledOnce();
|
||||
view.dispose();
|
||||
});
|
||||
|
||||
it("retires pending clicks after another action, playback change, media replacement or unmount", () => {
|
||||
vi.useFakeTimers();
|
||||
const view = picture();
|
||||
for (const [target, event] of [
|
||||
[view.screen, "pointerdown"], [view.screen, "keydown"], [view.screen, "pointercancel"],
|
||||
[view.video, "play"], [view.video, "pause"], [view.video, "emptied"],
|
||||
] as const) {
|
||||
view.click();
|
||||
target.dispatchEvent(new Event(event));
|
||||
vi.runAllTimers();
|
||||
}
|
||||
view.click();
|
||||
view.video.srcObject = {};
|
||||
vi.runAllTimers();
|
||||
view.click();
|
||||
view.dispose();
|
||||
vi.runAllTimers();
|
||||
view.click();
|
||||
view.doubleClick();
|
||||
vi.runAllTimers();
|
||||
expect(view.playback).not.toHaveBeenCalled();
|
||||
expect(view.fullscreen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves touch taps, control-bar clicks, auxiliary buttons and keyboard-generated clicks alone", () => {
|
||||
vi.useFakeTimers();
|
||||
const view = picture();
|
||||
view.pointer("touch");
|
||||
view.click();
|
||||
view.doubleClick();
|
||||
view.pointer();
|
||||
view.screen.dispatchEvent(Object.assign(new Event("click"), { detail: 1, button: 0 }));
|
||||
view.screen.dispatchEvent(Object.assign(new Event("dblclick"), { detail: 2, button: 0 }));
|
||||
view.click(1, 2);
|
||||
view.click(0);
|
||||
vi.runAllTimers();
|
||||
expect(view.playback).not.toHaveBeenCalled();
|
||||
expect(view.fullscreen).not.toHaveBeenCalled();
|
||||
view.dispose();
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,66 @@ const release = {
|
||||
target_commitish: latestRevision,
|
||||
html_url: "https://github.com/TNTcraftHIM/Piik/releases/tag/v1.2.0",
|
||||
};
|
||||
const packageAsset = (target: string, suffix = "") => {
|
||||
const name = `piik-app-${target}${suffix}.zip`;
|
||||
return { name, size: 1024, state: "uploaded",
|
||||
browser_download_url: `https://github.com/TNTcraftHIM/Piik/releases/download/${release.tag_name}/${name}` };
|
||||
};
|
||||
|
||||
describe("App release update notice", () => {
|
||||
it.each(["windows-amd64", "darwin-arm64", "linux-amd64"])("links the published %s ZIP without downloading it during the check", async (packageTarget) => {
|
||||
const assets = ["windows-amd64", "darwin-arm64", "linux-amd64"].map(target => packageAsset(target));
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => Response.json({ ...release, assets }));
|
||||
await expect(checkReleaseUpdate({ version: "v1.1.0", revision: currentRevision }, { fetchImpl, packageTarget }))
|
||||
.resolves.toMatchObject({ kind: "update-available", version: release.tag_name,
|
||||
url: assets.find(asset => asset.name === `piik-app-${packageTarget}.zip`)!.browser_download_url });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchImpl.mock.calls[0][0])).toBe(DEFAULT_RELEASE_API_URL);
|
||||
});
|
||||
|
||||
it("uses a listed older SHA-suffixed ZIP and prefers the fixed name when both exist", () => {
|
||||
const older = packageAsset("windows-amd64", `-${latestRevision.slice(0, 7)}`);
|
||||
expect(parseReleaseMetadata({ ...release, assets: [older] }, "windows-amd64")?.url)
|
||||
.toBe(older.browser_download_url);
|
||||
const current = packageAsset("windows-amd64");
|
||||
expect(parseReleaseMetadata({ ...release, assets: [older, current] }, "windows-amd64")?.url)
|
||||
.toBe(current.browser_download_url);
|
||||
});
|
||||
|
||||
it("keeps the release page when a matching safe ZIP is missing, without trying another provider", async () => {
|
||||
const asset = packageAsset("windows-amd64");
|
||||
for (const assets of [[], [packageAsset("linux-amd64")], [packageAsset("windows-amd64", "-aaaaaaa")],
|
||||
[{ ...asset, state: "new" }], [{ ...asset, size: 0 }], [{ ...asset, size: undefined }], [asset, asset],
|
||||
[{ ...asset, browser_download_url: asset.browser_download_url.replace("github.com", "evil.example") }],
|
||||
[{ ...asset, browser_download_url: asset.browser_download_url.replace("v1.2.0", "v1.1.0") }],
|
||||
]) {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => Response.json({ ...release, assets }));
|
||||
await expect(checkReleaseUpdate({ version: "v1.1.0", revision: currentRevision },
|
||||
{ fetchImpl, packageTarget: "windows-amd64" })).resolves.toMatchObject({ url: release.html_url });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
expect(parseReleaseMetadata({ ...release, assets: [asset] }, "windows-386")?.url).toBe(release.html_url);
|
||||
expect(parseReleaseMetadata({ ...release, assets: [asset] })?.url).toBe(release.html_url);
|
||||
});
|
||||
|
||||
it.each(["windows-amd64", "darwin-arm64", "linux-amd64"])("uses a listed mirror %s ZIP only after the existing primary check fails", async (packageTarget) => {
|
||||
const name = `piik-app-${packageTarget}.zip`;
|
||||
const mirrorAsset = { name,
|
||||
browser_download_url: `https://gitee.com/TNTcraftHIM/Piik/releases/download/${release.tag_name}/${name}` };
|
||||
const mirrored = { tag_name: release.tag_name, prerelease: false,
|
||||
body: `<!-- piik-source: ${latestRevision} -->`, assets: [mirrorAsset] };
|
||||
const fetchImpl = vi.fn<typeof fetch>(async (input) => String(input) === DEFAULT_RELEASE_API_URL
|
||||
? new Response(null, { status: 503 }) : Response.json([mirrored]));
|
||||
await expect(checkReleaseUpdate({ version: "v1.1.0", revision: currentRevision },
|
||||
{ fetchImpl, packageTarget })).resolves.toMatchObject({ url: mirrorAsset.browser_download_url });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
for (const assets of [[], [mirrorAsset, mirrorAsset],
|
||||
[{ ...mirrorAsset, browser_download_url: mirrorAsset.browser_download_url.replace("gitee.com", "evil.example") }],
|
||||
[{ ...mirrorAsset, browser_download_url: mirrorAsset.browser_download_url.replace("v1.2.0", "v1.1.0") }],
|
||||
]) expect(parseMirrorReleaseMetadata({ ...mirrored, assets }, packageTarget)?.url)
|
||||
.toBe(`https://gitee.com/TNTcraftHIM/Piik/releases/tag/${release.tag_name}`);
|
||||
});
|
||||
|
||||
it("checks a release without sending installed identity or credentials", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
|
||||
expect(String(input)).toBe(DEFAULT_RELEASE_API_URL);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { websiteDownloads } from "../site/downloads";
|
||||
|
||||
const revision = "a".repeat(40);
|
||||
@@ -7,7 +8,7 @@ const github = {
|
||||
tag_name: version, target_commitish: revision, draft: false, prerelease: false,
|
||||
html_url: `https://github.com/TNTcraftHIM/Piik/releases/tag/${version}`,
|
||||
assets: ["windows-amd64", "darwin-arm64", "linux-amd64"].map(target => {
|
||||
const name = `piik-app-${target}-${revision.slice(0, 7)}.zip`;
|
||||
const name = `piik-app-${target}.zip`;
|
||||
return { name, state: "uploaded", size: 1024,
|
||||
browser_download_url: `https://github.com/TNTcraftHIM/Piik/releases/download/${version}/${name}` };
|
||||
}),
|
||||
@@ -22,21 +23,43 @@ describe("website package downloads", () => {
|
||||
it("links each uploaded platform package and rejects an incomplete or mismatched GitHub release", () => {
|
||||
const result = websiteDownloads({ github, mirror });
|
||||
expect(result.version).toBe(version);
|
||||
expect(result.notes).toBe(github.html_url);
|
||||
expect(result.packages.map(item => item.url)).toEqual(github.assets.map(asset => asset.browser_download_url));
|
||||
expect(result.packages.map(item => item.url)).toEqual(github.assets.map(asset =>
|
||||
`https://github.com/TNTcraftHIM/Piik/releases/latest/download/${asset.name}`));
|
||||
expect(result.packages.map(item => item.mirrorURL)).toEqual(mirror.assets.map(asset => asset.browser_download_url));
|
||||
for (const changed of [
|
||||
{ target_commitish: "main" }, { prerelease: true }, { draft: true },
|
||||
{ assets: github.assets.slice(1) }, { assets: [...github.assets, github.assets[0]] },
|
||||
...[
|
||||
{ state: "new" }, { size: 0 },
|
||||
{ name: github.assets[0]!.name.replace("aaaaaaa", "bbbbbbb") },
|
||||
{ name: github.assets[0]!.name.replace(".zip", "-aaaaaaa.zip") },
|
||||
{ name: github.assets[0]!.name.replace(".zip", ".tar.gz") },
|
||||
{ browser_download_url: github.assets[0]!.browser_download_url.replace("github.com", "evil.example") },
|
||||
{ browser_download_url: github.assets[0]!.browser_download_url.replace(version, "v1.1.0") },
|
||||
].map(change => ({ assets: [{ ...github.assets[0], ...change }, ...github.assets.slice(1)] })),
|
||||
]) expect(() => websiteDownloads({ github: { ...github, ...changed } })).toThrow();
|
||||
});
|
||||
|
||||
it("keeps GitHub URLs valid across releases and without build metadata", () => {
|
||||
const nextVersion = "v1.2.1";
|
||||
const next = websiteDownloads({ github: { ...github, tag_name: nextVersion,
|
||||
target_commitish: "b".repeat(40), html_url: github.html_url.replace(version, nextVersion),
|
||||
assets: github.assets.map(asset => ({ ...asset,
|
||||
browser_download_url: asset.browser_download_url.replace(version, nextVersion) })),
|
||||
}, mirror });
|
||||
const current = websiteDownloads({ github, mirror });
|
||||
expect(next.packages.map(item => item.url)).toEqual(current.packages.map(item => item.url));
|
||||
expect(next.packages.every(item => item.mirrorURL === null)).toBe(true);
|
||||
|
||||
const html = readFileSync(new URL("../site/index.html", import.meta.url), "utf8");
|
||||
for (const item of current.packages) {
|
||||
const link = html.match(new RegExp(`<a\\b[^>]*data-download="${item.target}"[^>]*data-provider="github"[^>]*>[\\s\\S]*?<\\/a\\s*>`))?.[0];
|
||||
expect(link).toContain(`href="${item.url}"`);
|
||||
expect(link).toContain("Download ZIP");
|
||||
expect(link).toContain("下载 ZIP");
|
||||
}
|
||||
expect(html.match(/id="download-status">[\s\S]*?<\/p>/)?.[0]).not.toMatch(/v\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
it("keeps mirror navigation as the fallback until the same release and attachment are confirmed", () => {
|
||||
for (const changed of [undefined, null, { ...mirror, prerelease: true },
|
||||
{ ...mirror, body: `<!-- piik-source: ${"b".repeat(40)} -->` },
|
||||
|
||||
Reference in new issue
Block a user