diff --git a/README.md b/README.md index 6d603c5..e8362c5 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ you install, built from source in [termcade-games](https://github.com/aviorstudio/termcade-games) and vendored here as packages. Nothing is compiled into the binary as a game. -Remove one and it stays removed — the arcade seeds a game once, not every -run. For anything else, press `m` for the marketplace. +Uninstall one and it stays uninstalled — the arcade seeds a game once, not every +run. The TUI opens directly on Marketplace; Library includes installed games +even while signed out. ## Run @@ -57,8 +58,9 @@ requirements change. TERMCADE_PIXELS=sextant go run . ``` -In the arcade, `p` on the index or library cycles the pixel style and -persists it to `~/.config/termcade/settings.json`; `TERMCADE_PIXELS` +In the arcade, the pixel-style control in Settings or the pause menu cycles +the style for the next game start and persists it to +`~/.config/termcade/settings.json`; `TERMCADE_PIXELS` overrides the saved choice for a run. Games never change for any of this — the renderer owns the look. @@ -77,41 +79,59 @@ the auto-repeat heuristic. | Key | Action | | --- | --- | -| ↑/↓ or j/k | menu / pause navigation | -| enter | select / launch | +| Tab / Shift+Tab | focus navigation, page content, or actions; move between form controls | +| ↑/↓ | select a row or pause-menu item | +| ←/→ (actions) | select the focused action | +| enter | activate a focused item/button; ordinary game rows open details | +| Page Up / Page Down | page lists or scroll long detail/documentation text | | ←/→ (or a/d, h/l) | move paddle / turn ship / shift piece | | ↑ (or w) | thrust (Asteroid) · rotate (Tetris) | | ↓ (or s) | soft drop (Tetris) | | space or z | A button: launch ball / fire / hard drop | | x | B button | | esc or p | pause | -| l (index) | library — every game you have | -| m (index/library) | marketplace | -| r (index/library) | remove the selected added game | -| p (index/library) | cycle pixel style (incl. ASCII art) | -| q | quit (from menu) | +| esc (outside gameplay) | dismiss/back; stop waiting and reconcile a pending form mutation | +| Ctrl+C | quit and save local scores | + +While a game runs, game inputs cannot operate the sidebar. Pause first, then +Tab into navigation. The sidebar is 22 columns wide at 120+ terminal columns; +below that it overlays the content on demand. Non-game content is capped at +100 columns. Gameplay keeps its fixed cell footprint and hides the sidebar +when needed to fit. See [TUI workflows](docs/tui.md) for the complete contract. High scores persist to `~/.config/termcade/scores.json`, and are yours whether or not you have an account — see [Your history](#your-history). ## The marketplace -The arcade opens on your recently played games, with the library (`l`) and -marketplace (`m`) one keystroke away. Press `m` to browse — that much is -anonymous — and sign in to install. The bundled games are what a signed-out -arcade plays; an account is what adds to them, and it is also what publishing -and the library mirror hang off. Run `termcade login`; it displays a short -one-time code for `https://app.termca.de/pair`, where the browser handles -account authentication. Termcade never asks for account credentials in the -terminal. +The TUI opens on Marketplace and shares the web app's Marketplace, Docs, +Library, owner/game pages, and account/settings destinations. Browsing is +anonymous. Library joins account games with installed packages, with clear +installation/membership badges and Continue Playing entries. + +In the **TUI**, Add saves account membership without downloading. Play installs +a missing compatible account game; healthy installed games remain playable +offline. Remove from Library changes only account membership; Uninstall here +removes only the local package, after confirmation. No automatic updates replace +an existing local copy. + +Sign in from Settings without exiting the TUI: it shows the trusted pairing URL +and one-time code, and opens a browser only when you choose Open browser. +Account authentication stays in the browser. Settings also supports handles, +org/member administration, CLI-session revocation, account deletion, and +revoke-then-clear sign-out. The normal pairing destination remains +`https://app.termca.de/pair`. The gate is a product decision, not a security boundary: packages are public GitHub release assets, so an account is not what keeps anyone out. What it -does is give every installed game somewhere to belong — your adds and removes -mirror to a library on your account, and `termcade sync` brings it back down -— which `termcade login` does for you, so signing in on a new machine is -enough. Sync only adds: a game you installed from a file stays put, because a -server having never heard of it is not a reason to delete it. +does is give account games a library that follows you between devices. + +**Existing shell commands keep their established behavior:** `termcade add` +saves membership and installs; `termcade remove` removes membership and the local +copy; `termcade login` restores missing account packages; `termcade sync` adds +missing packages without deleting local-only games or automatically updating +existing copies. The new TUI's separate actions do not silently change those +command contracts. Creating an account in the app claims a **username** — your publishing handle, and the author segment of every game you release. `nicodes/pong` and `aviorstudio/tetris` are diff --git a/cli.go b/cli.go index fb47108..36df49c 100644 --- a/cli.go +++ b/cli.go @@ -201,6 +201,13 @@ func cmdDevInstall(args []string) error { // compiles and exports the termcade ABI — then an atomic extract into the // games directory. func installPackageBytes(raw []byte) (*manifest.Package, string, error) { + return installPackageBytesContext(context.Background(), raw) +} + +func installPackageBytesContext(ctx context.Context, raw []byte) (*manifest.Package, string, error) { + if err := ctx.Err(); err != nil { + return nil, "", err + } pkg, err := manifest.ReadPackage(raw) if err != nil { return nil, "", err @@ -209,7 +216,7 @@ func installPackageBytes(raw []byte) (*manifest.Package, string, error) { return nil, "", fmt.Errorf("%s needs ABI v%d; this termcade speaks v%d", pkg.Manifest.Game.ID, pkg.Manifest.Requirements.ABI, 1) } - rt := plugin.NewRuntime(context.Background()) + rt := plugin.NewRuntime(ctx) defer rt.Close() if _, err := rt.Compile(pkg.Manifest.Game.ID, pkg.Wasm); err != nil { return nil, "", err @@ -219,6 +226,9 @@ func installPackageBytes(raw []byte) (*manifest.Package, string, error) { if err != nil { return nil, "", err } + if err := ctx.Err(); err != nil { + return nil, "", err + } dest, err := pkg.Install(gamesDir) if err != nil { return nil, "", err diff --git a/docs/tui.md b/docs/tui.md new file mode 100644 index 0000000..f7bc826 --- /dev/null +++ b/docs/tui.md @@ -0,0 +1,120 @@ +# TUI workflows + +The terminal application follows the web product's destinations and information +hierarchy, while keeping terminal-cell graphics and keyboard input. The web app, +backend behavior, game ABI, and sandbox limits are not changed by this shell. + +## Navigation and focus + +- Marketplace is the initial destination, including while signed out. +- Tab/Shift+Tab cycles navigation, content, and actions. Arrows select within the + focused area; Enter activates. A normal game row opens details; Continue + Playing and the explicit Play action launch the game. +- At 120 or more columns the sidebar takes 22 columns. At smaller widths it is + an on-demand overlay. Non-game content is centered and capped at 100 columns. +- The supported baseline is 80×24. Lists and long text scroll. Page Up/Down + moves list selection with the viewport, so actions do not target a hidden row. +- During active gameplay, keys belong to the game. Esc/P pauses; Tab can then + focus navigation. Leaving Play closes the guest and records an abandoned run. +- Pause retains Resume, Restart, pixel selection for the next start, and Leave + Play. Pixel changes do not stretch or mutate an existing guest framebuffer. +- Forms consume typed/pasted characters. Tab moves between fields/buttons; + left/right, Home/End, Backspace/Delete and Ctrl+U edit a field. Enter activates + a button rather than silently submitting from a text field. +- Ctrl+C quits from every state. Escape dismisses/backtracks outside gameplay. + +## Library and package actions + +Library is an ID-keyed union of account membership and installed packages. +Installed versions are shown independently of the newest registry metadata. + +| Action | Account membership | Local package | +| --- | --- | --- | +| Add | Added | Unchanged | +| Play, healthy installed copy | Unchanged | Runs as-is, including offline | +| Play, missing account game | Unchanged | Downloaded, digest/identity/ABI validated, installed, launched | +| Remove from Library | Removed | Preserved | +| Uninstall here | Preserved | Removed after typed confirmation | + +Account-only entries show Not installed. Local-only is asserted only when +membership is known; unavailable account reads show unknown state, and +signed-out copies are described as on this machine. Broken/incompatible packages +remain visible with an explanation instead of being treated as playable. The +TUI does not silently update or overwrite existing copies. + +Sign-in loads account metadata and the library, not all packages. Existing +`termcade add/remove/login/sync` command-line semantics are unchanged. + +## Details and settings + +Owner/game pages expose public metadata, repository/website links, game actions, +and UTC year-to-date play counts. A seven-row cell heatmap uses the web's relative +intensity thresholds; Enter opens the scrollable exact daily counts. Public +aggregate activity is distinct from personal/machine-local high scores. + +Settings provides handle availability/claim/rename, org creation, member +addition/role changes/removal, org dissolution, CLI login-session listing and +revocation, account deletion, and sign-out. The API remains authoritative for +permissions and refusal rules. Publish keys are not CLI login sessions. + +Uninstall, membership removal from an org, org/account deletion and revocation +show explicit confirmations. Escape during a submitted mutation stops waiting +and refreshes actual state: a confirmed remote/local operation may already have +committed, so cancellation does not promise rollback. + +New logins retain their credential ID and expiry in the atomic mode-0600 session +file. TUI sign-out attempts revocation and then clears the local credential even +when the remote call fails. Failures are reported honestly. Legacy sessions with +no ID clear locally with a warning; the TUI never guesses another session to +revoke. Account changes/expiry clear cached private member/session views. + +## Local development pairing + +Use the actual API/app ports printed by `make dev` in the backend checkout: + +```sh +TERMCADE_REGISTRY=http://127.0.0.1:8080 \ +TERMCADE_DEV_APP_URL=http://127.0.0.1:8081 \ +mise exec -- go run . +``` + +`TERMCADE_DEV_APP_URL` is an explicit **TUI-only** local pairing opt-in. Both +origins must be HTTP with a port, have the same literal loopback host, and carry +no credentials, path, query or fragment. DNS names, LAN hosts and remote apps are +refused. The API's normal trusted pairing response is still validated; the +display/open target is the explicitly configured local `/pair` page. + +Local credentials live under +`/termcade/local//session.json`; this mode never loads the +normal production session file. Changing a registry override without matching +credentials cannot forward the old registry's credential. Bound TUI HTTP clients +also refuse cross-origin redirects. + +The ordinary `termcade login` command and its production pairing policy remain +unchanged. If testing shell commands as well, use separate XDG_CONFIG_HOME and +XDG_DATA_HOME directories; do not mix production sessions into a local registry. +Changing the local API port selects a different local credential store. + +## Verification + +Use the pinned Go toolchain and the existing CI gates: + +```sh +mise install +mise exec -- gofmt -l . +mise exec -- go vet ./... +mise exec -- go vet ./sdk/... +mise exec -- go test -race -count=1 -timeout 10m ./... ./sdk/... +GOWORK=off mise exec -- go build ./... +GOWORK=off mise exec -- go vet ./... +``` + +Formatting output must be empty. Cross-compile the targets and flags in +`.github/actions/build/action.yml` before a release. The product-model tests use +non-nil ProductServices and verify the real application path; older nil-service +shell tests remain controls for standalone game-loop behavior. + +HTTP tests cover read contracts, cancellation, credential-origin isolation, +revocation, and the independent effects of package/library actions. Local PTY +walkthroughs must use isolated configuration/data and disposable local accounts, +never production credentials or account data. diff --git a/go.mod b/go.mod index 378369b..cb3219b 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/aviorstudio/termcade/sdk v0.0.2 github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/ansi v0.11.7 github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/internal/registry/client.go b/internal/registry/client.go index 73dd2fd..c39a4a0 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -133,13 +133,16 @@ type Session struct { Username string `json:"username,omitempty"` // Notice is a server-side remark about an otherwise usable session. Not // persisted: it describes the moment the session was created. - Notice string `json:"notice,omitempty"` + Notice string `json:"notice,omitempty"` + CredentialID string `json:"credential_id,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` } type Client struct { baseURL string token string http *http.Client + ctx context.Context } // URL resolves the registry base URL: explicit env wins, then the session's @@ -168,7 +171,32 @@ type apiMessage struct { } func (c *Client) do(method, path string, body, out any) error { - return c.doContext(context.Background(), method, path, body, out) + return c.doContext(c.requestContext(), method, path, body, out) +} + +// WithContext scopes a TUI operation without mutating a client used by another command. +func (c *Client) WithContext(ctx context.Context) *Client { + next := *c + next.ctx = ctx + transport := *c.http + transport.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) > 0 && (req.URL.Scheme != via[0].URL.Scheme || req.URL.Host != via[0].URL.Host) { + return fmt.Errorf("refusing a registry redirect to another origin") + } + if len(via) >= 10 { + return fmt.Errorf("too many registry redirects") + } + return nil + } + next.http = &transport + return &next +} + +func (c *Client) requestContext() context.Context { + if c.ctx != nil { + return c.ctx + } + return context.Background() } func (c *Client) doContext(ctx context.Context, method, path string, body, out any) error { @@ -326,8 +354,15 @@ const maxCatalogPages = 20 // for games this arcade can run: a marketplace full of entries that refuse to // install is worse than a shorter one. func (c *Client) Games() ([]Game, error) { + return c.catalog(CatalogQuery{ABI: sdk.ABIVersion}) +} + +// Catalog matches the web marketplace, including entries this runtime cannot +// play. Availability belongs on each action, not in an invisible list filter. +func (c *Client) Catalog() ([]Game, error) { return c.catalog(CatalogQuery{}) } + +func (c *Client) catalog(query CatalogQuery) ([]Game, error) { var all []Game - query := CatalogQuery{ABI: sdk.ABIVersion} for range maxCatalogPages { page, err := c.CatalogPage(query) if err != nil { @@ -383,7 +418,7 @@ func (c *Client) Download(author, slug string) (string, error) { q := url.Values{} q.Set("abi", strconv.Itoa(sdk.ABIVersion)) - req, err := http.NewRequest(http.MethodGet, + req, err := http.NewRequestWithContext(c.requestContext(), http.MethodGet, c.baseURL+"/v1/games/"+author+"/"+slug+"/download?"+q.Encode(), nil) if err != nil { return "", err @@ -626,6 +661,9 @@ func (c *Client) RemoveMember(org, email string) error { type HandleOwner struct { Name string `json:"name"` IsOrg bool `json:"is_org"` + Bio string `json:"bio,omitempty"` + Link string `json:"link,omitempty"` + Games []Game `json:"games,omitempty"` } // HandleTaken reports whether a handle is claimed, and by what kind of owner. diff --git a/internal/registry/device.go b/internal/registry/device.go index 36fdea0..e4e2928 100644 --- a/internal/registry/device.go +++ b/internal/registry/device.go @@ -272,6 +272,36 @@ func (c *Client) deviceLogin(ctx context.Context, deviceName string, display fun } verifyBackoff = nextBackoff(verifyBackoff) } - return Session{Registry: c.baseURL, Email: me.Email, Username: me.Username, Token: approved.Token}, nil + return Session{Registry: c.baseURL, Email: me.Email, Username: me.Username, Token: approved.Token, CredentialID: approved.CredentialID, ExpiresAt: approved.ExpiresAt}, nil } } + +// CompleteDevice is a single bounded round for an interactive TUI. Unlike the +// shell command, it returns expiry so the person can explicitly start again. +func (c *Client) CompleteDevice(ctx context.Context, round DeviceRound) (Session, error) { + approved, err := c.pollRound(ctx, round, productionDevicePolicy) + if err != nil { + return Session{}, err + } + client := New(c.baseURL, approved.Token).WithContext(ctx) + deadline, _ := time.Parse(time.RFC3339, approved.ExpiresAt) + backoff := time.Second + var me Me + for { + me, err = client.MeContext(ctx) + if err == nil { + break + } + if !transient(err) { + return Session{}, fmt.Errorf("verifying issued CLI credential: %w", err) + } + if err = waitWithin(ctx, productionDevicePolicy, backoff, deadline); err != nil { + return Session{}, fmt.Errorf("verifying issued CLI credential: %w", err) + } + backoff = nextBackoff(backoff) + } + if strings.TrimSpace(me.Email) == "" { + return Session{}, fmt.Errorf("the marketplace returned an incomplete account identity") + } + return Session{Registry: c.baseURL, Email: me.Email, Username: me.Username, Token: approved.Token, CredentialID: approved.CredentialID, ExpiresAt: approved.ExpiresAt}, nil +} diff --git a/internal/registry/device_test.go b/internal/registry/device_test.go index 0ab7b00..cb53b89 100644 --- a/internal/registry/device_test.go +++ b/internal/registry/device_test.go @@ -124,6 +124,9 @@ func TestExpiredRoundRestartsWithBackoff(t *testing.T) { if err != nil || starts != 2 || displays != 2 || session.Token != testToken { t.Fatalf("session=%#v starts=%d displays=%d err=%v", session, starts, displays, err) } + if session.CredentialID != "credential-id" || session.ExpiresAt == "" { + t.Fatal("login did not retain revocation metadata") + } } func TestPollingRetriesTransientFailureUntilApproval(t *testing.T) { diff --git a/internal/registry/product.go b/internal/registry/product.go new file mode 100644 index 0000000..e1e74b3 --- /dev/null +++ b/internal/registry/product.go @@ -0,0 +1,68 @@ +package registry + +import ( + "fmt" + "net/http" + "net/url" +) + +type PlayDay struct { + Day string `json:"day"` + Count int `json:"count"` +} + +type CLICredential struct { + ID string `json:"id"` + ClientID string `json:"client_id"` + DeviceName string `json:"device_name"` + ExpiresAt string `json:"expires_at"` + Revoked bool `json:"revoked"` + LastUsedAt string `json:"last_used_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +func (c *Client) Game(author, slug string) (Game, error) { + var out Game + err := c.do(http.MethodGet, "/v1/games/"+url.PathEscape(author)+"/"+url.PathEscape(slug), nil, &out) + if err == nil && (out.ID != author+"/"+slug || out.Name == "") { + return Game{}, fmt.Errorf("the marketplace returned a mismatched game identity") + } + return out, err +} + +func (c *Client) Owner(name string) (HandleOwner, error) { + var out HandleOwner + err := c.do(http.MethodGet, "/v1/usernames/"+url.PathEscape(name), nil, &out) + if err == nil && out.Name != name { + return HandleOwner{}, fmt.Errorf("the marketplace returned a mismatched owner identity") + } + return out, err +} + +func (c *Client) PlayDays(author, slug string, days int) ([]PlayDay, error) { + if days < 1 || days > 366 { + return nil, fmt.Errorf("play-day window must be 1–366") + } + path := "/v1/usernames/" + url.PathEscape(author) + "/plays" + if slug != "" { + path = "/v1/games/" + url.PathEscape(author) + "/" + url.PathEscape(slug) + "/plays" + } + var out struct { + Days []PlayDay `json:"days"` + } + err := c.do(http.MethodGet, fmt.Sprintf("%s?days=%d", path, days), nil, &out) + return out.Days, err +} + +func (c *Client) CLICredentials() ([]CLICredential, error) { + var out []CLICredential + err := c.do(http.MethodGet, "/v1/cli-credentials", nil, &out) + return out, err +} + +func (c *Client) RevokeCLICredential(id string) error { + if id == "" { + return fmt.Errorf("missing credential id") + } + return c.do(http.MethodDelete, "/v1/cli-credentials/"+url.PathEscape(id), nil, nil) +} diff --git a/internal/registry/product_test.go b/internal/registry/product_test.go new file mode 100644 index 0000000..7f57afb --- /dev/null +++ b/internal/registry/product_test.go @@ -0,0 +1,205 @@ +package registry + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestProductReadAndCredentialContracts(t *testing.T) { + var calls []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.RequestURI()) + switch r.URL.Path { + case "/v1/usernames/acme": + json.NewEncoder(w).Encode(map[string]any{"name": "acme", "is_org": true, "bio": "Studio", "link": "https://example.test", "games": []Game{{ID: "acme/game", Name: "Game", HasPackage: true, ABI: 1, Width: 32, Height: 40}}}) + case "/v1/games/acme/game": + json.NewEncoder(w).Encode(Game{ID: "acme/game", Name: "Game", Repo: "https://github.com/acme/game", Version: "1.2.3", HasPackage: true, ABI: 1, Width: 32, Height: 40}) + case "/v1/games/acme/game/plays", "/v1/usernames/acme/plays": + if r.URL.Query().Get("days") != "30" { + t.Error("wrong day window") + } + json.NewEncoder(w).Encode(map[string]any{"days": []PlayDay{{Day: "2026-09-01", Count: 3}, {Day: "2026-09-02", Count: 0}}}) + case "/v1/cli-credentials": + json.NewEncoder(w).Encode([]CLICredential{{ID: "cred-1", ClientID: "termcade-cli", DeviceName: "Laptop", ExpiresAt: "2026-10-01T00:00:00Z", CreatedAt: "2026-09-01T00:00:00Z", LastUsedAt: "2026-09-02T00:00:00Z"}}) + case "/v1/cli-credentials/cred-1": + if r.Method != http.MethodDelete { + t.Error("wrong revoke verb") + } + w.WriteHeader(204) + default: + t.Error("unexpected endpoint", r.URL.Path) + w.WriteHeader(404) + } + })) + defer srv.Close() + c := New(srv.URL, testToken).WithContext(context.Background()) + o, err := c.Owner("acme") + if err != nil || !o.IsOrg || o.Bio != "Studio" || len(o.Games) != 1 || o.Link == "" { + t.Fatal("owner contract", err) + } + g, err := c.Game("acme", "game") + if err != nil || g.Version != "1.2.3" || g.ABI != 1 || g.Width != 32 || !g.HasPackage { + t.Fatal("game contract", err) + } + for _, slug := range []string{"game", ""} { + days, err := c.PlayDays("acme", slug, 30) + if err != nil || len(days) != 2 || days[0].Count != 3 || days[1].Count != 0 { + t.Fatal("play-day contract", err) + } + } + creds, err := c.CLICredentials() + if err != nil || len(creds) != 1 || creds[0].DeviceName != "Laptop" || creds[0].ExpiresAt == "" || creds[0].CreatedAt == "" || creds[0].LastUsedAt == "" { + t.Fatal("credential contract", err) + } + if err := c.RevokeCLICredential("cred-1"); err != nil { + t.Fatal(err) + } + if len(calls) != 6 { + t.Fatal(calls) + } + for _, days := range []int{0, 367} { + if _, err = c.PlayDays("acme", "game", days); err == nil { + t.Fatal("unbounded chart request") + } + } +} + +func TestProductContextCancellationAndOriginBinding(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := New("http://127.0.0.1:1", testToken).WithContext(ctx).Owner("acme"); !errors.Is(err, context.Canceled) { + t.Fatal("cancellation lost", err) + } + reached := false + destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true; w.WriteHeader(200) })) + defer destination.Close() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, destination.URL, 302) })) + defer source.Close() + if _, err := New(source.URL, testToken).WithContext(context.Background()).Me(); err == nil || reached { + t.Fatal("cross-origin credential redirect accepted") + } +} + +func TestProductDetailIdentityCannotChangeActionTargets(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/games/") { + json.NewEncoder(w).Encode(Game{ID: "other/game", Name: "Other"}) + } else { + json.NewEncoder(w).Encode(HandleOwner{Name: "other"}) + } + })) + defer srv.Close() + c := New(srv.URL, "") + if _, err := c.Game("acme", "game"); err == nil { + t.Fatal("mismatched game accepted") + } + if _, err := c.Owner("acme"); err == nil { + t.Fatal("mismatched owner accepted") + } +} + +func TestTUIStoreLocalIsolationAndLegacyCompatibility(t *testing.T) { + root := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", root) + t.Setenv("HOME", root) + t.Setenv("AppData", root) + t.Setenv("TERMCADE_REGISTRY", "http://127.0.0.1:8080") + t.Setenv("TERMCADE_DEV_APP_URL", "http://127.0.0.1:8081") + if err := SaveSession(Session{Registry: DefaultURL, Token: testToken, Email: "production@example.test"}); err != nil { + t.Fatal(err) + } + store, err := NewTUIStore() + if err != nil { + t.Fatal(err) + } + if store.PairingURL != "http://127.0.0.1:8081/pair" || !strings.Contains(store.Path, string(filepath.Separator)+"local"+string(filepath.Separator)) { + t.Fatal("wrong local store") + } + if s, err := store.Load(); err != nil || s != nil { + t.Fatal("local mode read production credentials") + } + session := Session{Registry: store.Registry, Token: testToken, CredentialID: "new-credential", ExpiresAt: "2026-10-01T00:00:00Z"} + if err = store.Save(session); err != nil { + t.Fatal(err) + } + s, err := store.Load() + if err != nil || s.CredentialID != session.CredentialID { + t.Fatal("credential metadata lost") + } + info, _ := os.Stat(store.Path) + if info.Mode().Perm() != 0600 { + t.Fatal("credentials not private") + } + if err = store.Clear(); err != nil { + t.Fatal(err) + } + old, err := LoadSession() + if err != nil || old == nil || old.Email != "production@example.test" { + t.Fatal("local logout touched production session") + } + t.Setenv("TERMCADE_DEV_APP_URL", "") + ordinary, err := NewTUIStore() + if err != nil { + t.Fatal(err) + } + if _, err = ordinary.Load(); err == nil { + t.Fatal("registry override reused unrelated credential") + } +} + +func TestTUILocalPairingRejectsUnsafeOrigins(t *testing.T) { + root := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", root) + t.Setenv("HOME", root) + t.Setenv("AppData", root) + for _, pair := range [][2]string{{DefaultURL, "http://127.0.0.1:8081"}, {"http://127.0.0.1:8080", "https://evil.test"}, {"http://localhost:8080", "http://localhost:8081"}, {"http://127.0.0.1:8080", "http://user@127.0.0.1:8081"}, {"http://127.0.0.1:8080", "http://127.0.0.1:8081/path"}, {"http://127.0.0.1:8080", "http://127.0.0.1:8081?x=1"}} { + t.Setenv("TERMCADE_REGISTRY", pair[0]) + t.Setenv("TERMCADE_DEV_APP_URL", pair[1]) + if _, err := NewTUIStore(); err == nil { + t.Fatal("unsafe pairing configuration accepted", pair) + } + } +} + +func TestTUIRegistryDoesNotAcceptCredentialsInURLs(t *testing.T) { + for _, value := range []string{"https://user:password@example.test", "https://example.test?token=secret", "https://example.test#secret", "file:///tmp/registry", "ftp://example.test"} { + if err := validateTUIRegistry(value); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatal("unsafe registry URL handling") + } + } + if err := validateTUIRegistry(DefaultURL); err != nil { + t.Fatal(err) + } +} + +func TestTUICompleteDeviceRetriesVerificationWithoutLosingMetadata(t *testing.T) { + var checks atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/device/poll": + json.NewEncoder(w).Encode(DevicePoll{Status: "approved", Token: testToken, CredentialID: "verified-device", ExpiresAt: time.Now().Add(24 * time.Hour).Format(time.RFC3339)}) + case "/v1/me": + if checks.Add(1) == 1 { + http.Error(w, `{"message":"temporary"}`, 503) + return + } + json.NewEncoder(w).Encode(Me{Email: "person@example.test", Username: "person"}) + default: + w.WriteHeader(404) + } + })) + defer srv.Close() + session, err := New(srv.URL, "").CompleteDevice(context.Background(), DeviceRound{DeviceCode: testDevice, ExpiresIn: 10 * time.Second, Interval: time.Millisecond}) + if err != nil || session.CredentialID != "verified-device" || checks.Load() != 2 { + t.Fatal("verification recovery failed", err) + } +} diff --git a/internal/registry/session.go b/internal/registry/session.go index af539e6..72332e8 100644 --- a/internal/registry/session.go +++ b/internal/registry/session.go @@ -22,6 +22,10 @@ func LoadSession() (*Session, error) { if err != nil { return nil, err } + return loadSessionAt(path) +} + +func loadSessionAt(path string) (*Session, error) { raw, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil, nil @@ -52,6 +56,13 @@ func SaveSession(s Session) error { if err != nil { return err } + return saveSessionAt(path, s) +} + +func saveSessionAt(path string, s Session) error { + if !IsCLIToken(s.Token) { + return fmt.Errorf("refusing to save a non-CLI credential") + } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } diff --git a/internal/registry/tui_store.go b/internal/registry/tui_store.go new file mode 100644 index 0000000..4d98cb8 --- /dev/null +++ b/internal/registry/tui_store.go @@ -0,0 +1,107 @@ +package registry + +import ( + "crypto/sha256" + "errors" + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" +) + +// TUIStore binds credentials to one registry. Explicit local development never +// reads the normal production session file, and cannot select a remote app. +type TUIStore struct { + Registry string + PairingURL string + Path string +} + +func NewTUIStore() (TUIStore, error) { + base := URL(nil) + if err := validateTUIRegistry(base); err != nil { + return TUIStore{}, err + } + path, err := sessionPath() + if err != nil { + return TUIStore{}, err + } + if localApp := strings.TrimRight(os.Getenv("TERMCADE_DEV_APP_URL"), "/"); localApp != "" { + a, err := loopbackOrigin(base) + if err != nil { + return TUIStore{}, err + } + b, err := loopbackOrigin(localApp) + if err != nil || a.Hostname() != b.Hostname() { + return TUIStore{}, fmt.Errorf("local registry and app must use the same literal loopback host") + } + sum := sha256.Sum256([]byte(base)) + path = filepath.Join(filepath.Dir(path), "local", fmt.Sprintf("%x", sum[:12]), "session.json") + return TUIStore{Registry: base, PairingURL: localApp + "/pair", Path: path}, nil + } + if os.Getenv("TERMCADE_REGISTRY") == "" { + if s, err := loadSessionAt(path); err == nil && s != nil { + base = URL(s) + } + } + if err := validateTUIRegistry(base); err != nil { + return TUIStore{}, err + } + return TUIStore{Registry: base, PairingURL: PairingURI, Path: path}, nil +} + +func validateTUIRegistry(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("registry must be an HTTP(S) URL without credentials, query or fragment") + } + return nil +} + +func loopbackOrigin(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "http" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return nil, fmt.Errorf("local pairing requires an HTTP loopback origin, without credentials or a path") + } + ip := net.ParseIP(u.Hostname()) + port, err := strconv.Atoi(u.Port()) + if ip == nil || !ip.IsLoopback() || err != nil || port < 1 || port > 65535 { + return nil, fmt.Errorf("local pairing requires a literal loopback host and port") + } + return u, nil +} + +func (s TUIStore) Load() (*Session, error) { + session, err := loadSessionAt(s.Path) + if err != nil || session == nil { + return session, err + } + if strings.TrimRight(session.Registry, "/") != strings.TrimRight(s.Registry, "/") { + return nil, fmt.Errorf("saved credentials belong to another registry; sign in here separately") + } + return session, nil +} + +func (s TUIStore) Save(session Session) error { + if session.Registry != s.Registry { + return fmt.Errorf("refusing credentials for another registry") + } + return saveSessionAt(s.Path, session) +} + +func (s TUIStore) Clear() error { + if err := os.Remove(s.Path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func (s TUIStore) CanPair() error { + if _, err := loopbackOrigin(s.Registry); err == nil && s.PairingURL == PairingURI { + return fmt.Errorf("set TERMCADE_DEV_APP_URL to the local app origin before pairing with a local registry") + } + return nil +} diff --git a/internal/shell/market.go b/internal/shell/market.go index c4e0e7d..3d01e61 100644 --- a/internal/shell/market.go +++ b/internal/shell/market.go @@ -1,7 +1,9 @@ package shell import ( + "context" "strings" + "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -22,6 +24,7 @@ type MarketGame struct { // the marketplace and account screens. All calls may block; the shell only // invokes them inside tea commands. A nil *Marketplace disables the screens. type Marketplace struct { + Product *ProductServices List func() ([]MarketGame, error) Install func(id string) error Remove func(id string) error @@ -98,6 +101,18 @@ func (m Model) removeCmd(id string) tea.Cmd { // yields no command at all, so an arcade built without one is not paying for a // goroutine per game over. func (m Model) syncCmd() tea.Cmd { + if m.productEnabled() { + if m.mp.Product.Sync == nil { + return nil + } + service := m.mp.Product + generation := m.app.accountGen + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return productSyncMsg{AccountGen: generation, Notice: service.Sync(ctx)} + } + } if m.mp == nil || m.mp.Sync == nil { return nil } diff --git a/internal/shell/product.go b/internal/shell/product.go new file mode 100644 index 0000000..0310be1 --- /dev/null +++ b/internal/shell/product.go @@ -0,0 +1,1417 @@ +package shell + +import ( + "context" + "errors" + "fmt" + "math" + "sort" + "strings" + "time" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/aviorstudio/termcade/internal/registry" + "github.com/aviorstudio/termcade/sdk" + "github.com/charmbracelet/x/ansi" +) + +const sidebarWidth = 22 +const sidebarBreakpoint = 120 +const productContentMax = 100 + +type productLocation struct{ Page, ID string } +type productItem struct{ Label, Detail, Kind, ID string } +type productAction struct { + Label, Kind, ID string + Disabled bool +} +type productForm struct { + Carets []int + Title, Kind, ID string + Labels, Values []string + Index int + Confirmation string +} +type productState struct { + accountGen uint64 + loc productLocation + history []productLocation + snapshot ProductSnapshot + game registry.Game + owner registry.HandleOwner + days []registry.PlayDay + members []registry.Member + credentials []registry.CLICredential + focus, selection, action, nav int // navigation / content / actions + scroll int + manualScroll bool + loading bool + mutating bool + gen uint64 + ctx context.Context + cancel context.CancelFunc + form *productForm + round registry.DeviceRound + pairingURL string + notice string + gameNav bool +} +type productMsg struct { + Gen uint64 + Request ProductRequest + Reply ProductReply + Err error +} +type productSyncMsg struct { + AccountGen uint64 + Notice string +} + +func (m Model) productEnabled() bool { return m.mp != nil && m.mp.Product != nil } + +func (m *Model) cancelProduct() { + if m.app.cancel != nil { + m.app.cancel() + } + m.app.gen++ + m.app.loading = false + m.app.mutating = false +} + +func (m *Model) productRequest(req ProductRequest) tea.Cmd { + m.cancelProduct() + m.app.notice = "" + timeout := 45 * time.Second + if req.Kind == "pair-wait" { + timeout = m.app.round.ExpiresIn + time.Second + } + m.app.ctx, m.app.cancel = context.WithTimeout(context.Background(), timeout) + m.app.loading = true + m.app.mutating = req.Kind != "load" && req.Kind != "game" && req.Kind != "owner" && req.Kind != "members" && req.Kind != "sessions" + if req.Extra == "refresh-page" { + m.app.mutating = true + } + gen, ctx, services := m.app.gen, m.app.ctx, m.mp.Product + return func() tea.Msg { reply, err := services.Request(ctx, req); return productMsg{gen, req, reply, err} } +} + +func (m Model) productInit() tea.Cmd { + services, ctx, gen := m.mp.Product, m.app.ctx, m.app.gen + return func() tea.Msg { + req := ProductRequest{Kind: "load"} + reply, err := services.Request(ctx, req) + return productMsg{gen, req, reply, err} + } +} + +func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case productOpenMsg: + if msg.Gen == m.app.gen && msg.Err != nil { + m.app.notice = sanitize(msg.Err.Error()) + } + return m, nil, true + case productSyncMsg: + if msg.AccountGen == m.app.accountGen && m.app.notice == "" { + m.app.notice = sanitize(msg.Notice) + } + return m, nil, true + case productMsg: + if msg.Gen != m.app.gen { + return m, nil, true + } + selected := "" + selectedKind := "" + navigation := m.productNavItems() + navSelection := navigation[clampIndex(m.app.nav, len(navigation))] + items := m.productItems() + if m.app.selection < len(items) { + selected = items[m.app.selection].ID + selectedKind = items[m.app.selection].Kind + } + m.app.loading = false + m.app.mutating = false + if msg.Err != nil { + if !errors.Is(msg.Err, context.Canceled) { + m.app.notice = sanitize(msg.Err.Error()) + } + if msg.Request.Kind == "game" && m.localIndex(msg.Request.ID) >= 0 { + m.app.notice = "Using installed package metadata · " + sanitize(msg.Err.Error()) + } + if errors.Is(msg.Err, registry.ErrLoginRequired) { + if m.app.snapshot.SignedIn { + m.app.accountGen++ + } + m.app.snapshot.SignedIn = false + m.app.snapshot.LibraryKnown = false + m.app.snapshot.Library = nil + m.clearPrivateProductViews() + } + return m, nil, true + } + if msg.Reply.Snapshot != nil { + before := m.app.snapshot + m.app.snapshot = *msg.Reply.Snapshot + if before.SignedIn != m.app.snapshot.SignedIn || before.Account.Email != m.app.snapshot.Account.Email || before.CredentialID != m.app.snapshot.CredentialID { + m.app.accountGen++ + } + if !m.app.snapshot.SignedIn || before.Account.Email != m.app.snapshot.Account.Email || before.CredentialID != m.app.snapshot.CredentialID { + m.clearPrivateProductViews() + } + m.games = m.app.snapshot.Installed + m.latest = map[string]string{} + for _, g := range m.app.snapshot.Catalog { + m.latest[g.ID] = g.Version + } + } + if msg.Reply.Game != nil { + m.app.game = *msg.Reply.Game + m.app.days = msg.Reply.Days + } + if msg.Reply.Owner != nil { + m.app.owner = *msg.Reply.Owner + m.app.days = msg.Reply.Days + } + if msg.Request.Kind == "members" { + m.app.members = msg.Reply.Members + } + if msg.Request.Kind == "sessions" { + m.app.credentials = msg.Reply.Credentials + } + m.app.notice = sanitize(msg.Reply.Notice) + switch msg.Request.Kind { + case "pair-start": + m.app.round = msg.Reply.Round + m.app.pairingURL = msg.Reply.PairingURL + cmd := m.productRequest(ProductRequest{Kind: "pair-wait", Round: msg.Reply.Round}) + return m, cmd, true + case "pair-wait": + cmd := m.productRequest(ProductRequest{Kind: "save-login", Session: msg.Reply.Session}) + return m, cmd, true + case "save-login": + m.app.round = registry.DeviceRound{} + m.app.pairingURL = "" + m.app.form = nil + m.app.loc = productLocation{Page: "settings"} + m.app.focus = 1 + m.app.selection = 0 + if n := len(m.app.history); n > 0 && m.app.history[n-1] == m.app.loc { + m.app.history = m.app.history[:n-1] + } + case "install": + for i, g := range m.games { + if g.Info.ID == msg.Request.ID { + next, cmd := m.startGame(i) + return next.(Model), cmd, true + } + } + m.app.notice = "The installed package could not be discovered; refresh Library" + case "logout", "account-delete": + m.app.form = nil + m.app.history = nil + m.app.loc = productLocation{Page: "marketplace"} + m.app.selection = 0 + m.app.focus = 1 + default: + switch msg.Request.Kind { + case "load", "game", "owner", "members", "sessions", "username-check": + default: + m.app.form = nil + } + if !m.app.snapshot.SignedIn && m.app.loc.Page == "sessions" { + m.app.loc = productLocation{Page: "settings"} + } + if msg.Request.Kind == "member-set" || msg.Request.Kind == "member-remove" { + cmd := m.productRequest(ProductRequest{Kind: "members", ID: m.app.loc.ID}) + return m, cmd, true + } + if msg.Request.Kind == "session-revoke" && m.app.snapshot.SignedIn { + cmd := m.productRequest(ProductRequest{Kind: "sessions"}) + return m, cmd, true + } + if msg.Request.Kind == "org-delete" { + m.app.loc = productLocation{Page: "settings"} + m.app.selection = 0 + m.app.focus = 1 + } + } + items = m.productItems() + for i, item := range items { + if selected != "" && item.ID == selected && item.Kind == selectedKind { + m.app.selection = i + break + } + } + m.app.selection = clampIndex(m.app.selection, len(items)) + for i, item := range m.productNavItems() { + if item.Kind == navSelection.Kind && item.ID == navSelection.ID { + m.app.nav = i + break + } + } + m.app.nav = clampIndex(m.app.nav, len(m.productNavItems())) + m.app.action = 0 + if msg.Request.Kind == "load" && msg.Request.Extra == "refresh-page" { + switch m.app.loc.Page { + case "game", "owner", "members", "sessions", "chart": + next, cmd := m.openProduct(m.app.loc, false) + return next, cmd, true + } + } + return m, nil, true + case tea.PasteMsg: + if m.app.form != nil && m.app.form.Index < len(m.app.form.Values) && !m.app.loading { + m.appendFormText(msg.Content) + } + return m, nil, true + case tea.KeyPressMsg: + key := msg.String() + if m.screen != screenPlaying && msg.IsRepeat && (key == "enter" || key == "tab" || key == "shift+tab" || key == "esc") { + return m, nil, true + } + if key == "ctrl+c" { + m.cancelProduct() + m = m.closeProductGame() + m.scores.Save() + return m, tea.Quit, true + } + if m.screen == screenPlaying { + return m, nil, false + } + if m.screen == screenPaused || m.screen == screenGameOver || m.screen == screenCrashed { + if key == "tab" || key == "shift+tab" { + m.app.gameNav = !m.app.gameNav + return m, nil, true + } + if m.app.gameNav { + if key == "esc" { + m.app.gameNav = false + return m, nil, true + } + return m.productNavKey(key) + } + if m.screen == screenPaused { + return m.productPauseKey(key) + } + return m, nil, false + } + if m.app.form != nil { + return m.productFormKey(msg) + } + if key == "esc" { + if m.app.focus == 0 && m.termW < sidebarBreakpoint { + m.app.focus = 1 + return m, nil, true + } + m.cancelProduct() + if n := len(m.app.history); n > 0 { + loc := m.app.history[n-1] + m.app.history = m.app.history[:n-1] + next, cmd := m.openProduct(loc, false) + return next, cmd, true + } + m.app.loc = productLocation{Page: "marketplace"} + m.app.focus = 1 + return m, nil, true + } + if key == "tab" || key == "shift+tab" { + delta := 1 + if key == "shift+tab" { + delta = 2 + } + m.app.focus = (m.app.focus + delta) % 3 + return m, nil, true + } + if m.app.focus == 0 { + return m.productNavKey(key) + } + if m.app.focus == 2 { + actions := m.productActions() + if len(actions) == 0 { + return m, nil, true + } + switch key { + case "left", "up": + m.app.action = (m.app.action + len(actions) - 1) % len(actions) + case "right", "down": + m.app.action = (m.app.action + 1) % len(actions) + case "enter": + a := actions[clampIndex(m.app.action, len(actions))] + if !a.Disabled { + next, cmd := m.activateProduct(a.Kind, a.ID) + return next, cmd, true + } + } + return m, nil, true + } + items := m.productItems() + switch key { + case "up", "down": + if len(items) > 0 { + delta := 1 + if key == "up" { + delta = len(items) - 1 + } + m.app.selection = (m.app.selection + delta) % len(items) + m.app.manualScroll = false + m.app.action = 0 + } + case "pgdown", "pgup": + if m.app.loc.Page != "docs" && m.app.loc.Page != "game" { + delta := max(1, (m.termH-6)/3) + if key == "pgup" { + delta = -delta + } + m.app.selection = clampIndex(m.app.selection+delta, len(items)) + m.app.manualScroll = false + m.app.action = 0 + return m, nil, true + } + d := max(1, m.termH/2) + if key == "pgup" { + d = -d + } + m.app.scroll = max(0, m.app.scroll+d) + m.app.manualScroll = true + case "enter": + if len(items) > 0 { + item := items[clampIndex(m.app.selection, len(items))] + next, cmd := m.activateProduct(item.Kind, item.ID) + return next, cmd, true + } + } + return m, nil, true + } + return m, nil, false +} + +func clampIndex(i, n int) int { + if n == 0 { + return 0 + } + return min(max(i, 0), n-1) +} + +func (m Model) productNavItems() []productItem { + items := []productItem{{Label: "Marketplace", Kind: "page", ID: "marketplace"}, {Label: "Docs", Kind: "page", ID: "docs"}, {Label: "Library", Kind: "page", ID: "library"}} + for _, g := range m.app.snapshot.Library { + items = append(items, productItem{Label: " " + g.Name, Kind: "game", ID: g.ID}) + } + label := "Sign in" + if m.app.snapshot.SignedIn { + label = "Account" + if m.app.snapshot.Account.Username != "" { + label = "@" + m.app.snapshot.Account.Username + } + } + return append(items, productItem{Label: label, Kind: "page", ID: "settings"}) +} + +func (m Model) productNavKey(key string) (Model, tea.Cmd, bool) { + items := m.productNavItems() + switch key { + case "up": + m.app.nav = (m.app.nav + len(items) - 1) % len(items) + case "down": + m.app.nav = (m.app.nav + 1) % len(items) + case "enter": + i := items[clampIndex(m.app.nav, len(items))] + m = m.closeProductGame() + m.app.gameNav = false + next, cmd := m.activateProduct(i.Kind, i.ID) + return next, cmd, true + } + return m, nil, true +} + +func (m Model) openProduct(loc productLocation, remember bool) (Model, tea.Cmd) { + if remember && loc != m.app.loc && m.app.loc.Page != "pairing" { + m.app.history = append(m.app.history, m.app.loc) + } + m.cancelProduct() + if m.app.loc.Page == "pairing" { + m.app.round = registry.DeviceRound{} + m.app.pairingURL = "" + } + m.screen = screenProduct + m.app.loc = loc + m.app.focus = 1 + m.app.selection = 0 + m.app.action = 0 + m.app.scroll = 0 + m.app.manualScroll = false + m.app.form = nil + m.app.notice = "" + m.app.days = nil + var req ProductRequest + switch loc.Page { + case "game": + m.app.game = m.metadata(loc.ID) + req = ProductRequest{Kind: "game", ID: loc.ID} + case "owner": + m.app.owner = registry.HandleOwner{Name: loc.ID} + req = ProductRequest{Kind: "owner", ID: loc.ID} + case "members": + m.app.members = nil + req = ProductRequest{Kind: "members", ID: loc.ID} + case "sessions": + m.app.credentials = nil + req = ProductRequest{Kind: "sessions"} + case "chart": + kind := "owner" + if strings.Contains(loc.ID, "/") { + kind = "game" + } + req = ProductRequest{Kind: kind, ID: loc.ID} + case "docs": + return m, nil + default: + req = ProductRequest{Kind: "load"} + } + cmd := m.productRequest(req) + return m, cmd +} + +func (m Model) metadata(id string) registry.Game { + for _, collection := range [][]registry.Game{m.app.snapshot.Catalog, m.app.snapshot.Library, m.app.owner.Games} { + for _, g := range collection { + if g.ID == id { + return g + } + } + } + for _, g := range m.games { + if g.Info.ID == id { + return registry.Game{ID: id, Name: g.Info.Title, Version: g.Version, Width: g.Info.PixelW, Height: g.Info.PixelH, ABI: sdk.ABIVersion, HasPackage: g.Err == nil} + } + } + return registry.Game{ID: id, Name: id} +} + +func (m Model) localIndex(id string) int { + for i, g := range m.games { + if g.Info.ID == id { + return i + } + } + return -1 +} +func (m Model) inAccount(id string) bool { + if !m.app.snapshot.SignedIn || !m.app.snapshot.LibraryKnown { + return false + } + for _, g := range m.app.snapshot.Library { + if g.ID == id { + return true + } + } + return false +} +func (m Model) gameBadge(id string) string { + local := m.localIndex(id) + owned := m.inAccount(id) + if local >= 0 { + if m.games[local].Err != nil { + return "Broken install" + } + if owned { + return "Installed" + } + if m.app.snapshot.SignedIn && !m.app.snapshot.LibraryKnown { + return "Installed · membership unknown" + } + if !m.app.snapshot.SignedIn { + return "Installed · on this machine" + } + return "Installed · Local only" + } + if owned { + return "Not installed" + } + return "Not in Library" +} + +func (m Model) installationStatus(id string) string { + status := m.gameBadge(id) + if i := m.localIndex(id); i >= 0 && m.games[i].Version != "" { + status += " · local v" + m.games[i].Version + } + return status +} + +func (m Model) libraryGames() []registry.Game { + byID := map[string]registry.Game{} + if m.app.snapshot.SignedIn { + for _, g := range m.app.snapshot.Library { + byID[g.ID] = g + } + } + for _, g := range m.games { + if _, ok := byID[g.Info.ID]; !ok { + byID[g.Info.ID] = m.metadata(g.Info.ID) + } + } + ids := make([]string, 0, len(byID)) + for id := range byID { + ids = append(ids, id) + } + sort.Strings(ids) + out := make([]registry.Game, 0, len(ids)) + for _, id := range ids { + out = append(out, byID[id]) + } + return out +} + +func (m Model) productRecentItems() []productItem { + type recent struct { + game registry.Game + played time.Time + best int + } + var entries []recent + for _, g := range m.libraryGames() { + played, best := m.scores.LastPlayed(g.ID), m.scores.High(g.ID) + if m.app.snapshot.SignedIn { + for _, activity := range m.app.snapshot.Activity { + if activity.ID == g.ID { + if activity.PlayedAt().After(played) { + played = activity.PlayedAt() + } + best = max(best, activity.PersonalBest) + } + } + } + local := m.localIndex(g.ID) + playable := local >= 0 && m.games[local].Err == nil + if !played.IsZero() && (playable || (local < 0 && m.inAccount(g.ID) && g.HasPackage && g.ABI == sdk.ABIVersion)) { + entries = append(entries, recent{g, played, best}) + } + } + sort.SliceStable(entries, func(i, j int) bool { return entries[i].played.After(entries[j].played) }) + var out []productItem + for _, e := range entries[:min(3, len(entries))] { + out = append(out, productItem{Label: "Continue playing: " + e.game.Name + " [Play]", Detail: fmt.Sprintf("best %d · played %s", e.best, productPlayedAgo(e.played, time.Now())), Kind: "play", ID: e.game.ID}) + } + return out +} + +func productPlayedAgo(when, now time.Time) string { + if when.IsZero() { + return "never" + } + seconds := max(0, math.Round(now.Sub(when).Seconds())) + if seconds < 90 { + return "just now" + } + minutes := math.Round(seconds / 60) + if minutes < 60 { + return fmt.Sprintf("%.0f minutes ago", minutes) + } + hours := math.Round(minutes / 60) + if hours < 24 { + if hours == 1 { + return "an hour ago" + } + return fmt.Sprintf("%.0f hours ago", hours) + } + days := math.Round(hours / 24) + if days < 30 { + if days == 1 { + return "yesterday" + } + return fmt.Sprintf("%.0f days ago", days) + } + months := math.Round(days / 30) + if months == 1 { + return "a month ago" + } + return fmt.Sprintf("%.0f months ago", months) +} + +func (m Model) productItems() []productItem { + var items []productItem + gameRows := func(games []registry.Game) { + for _, g := range games { + items = append(items, productItem{Label: g.ID + " · " + m.installationStatus(g.ID), Detail: g.Name + " · " + g.Description, Kind: "game", ID: g.ID}) + } + } + switch m.app.loc.Page { + case "marketplace": + gameRows(m.app.snapshot.Catalog) + case "library": + items = append(items, m.productRecentItems()...) + gameRows(m.libraryGames()) + case "game": + g := m.app.game + owner, _, _ := strings.Cut(g.ID, "/") + release := "No published release" + if g.HasPackage { + release = fmt.Sprintf("Release %s · ABI %d · %d×%d", g.Version, g.ABI, g.Width, g.Height) + } + items = append(items, productItem{Label: g.Name, Detail: g.Description}, productItem{Label: "@" + owner, Kind: "owner", ID: owner}, productItem{Label: "Repository", Detail: g.Repo, Kind: "open-url", ID: g.Repo}, productItem{Label: release, Detail: m.installationStatus(g.ID)}, productItem{Label: "Public play activity", Detail: playSummary(m.app.days), Kind: "chart", ID: g.ID}) + if idx := m.localIndex(g.ID); idx >= 0 && m.games[idx].Err != nil { + items = append(items, productItem{Label: "Cannot play", Detail: m.games[idx].Err.Error()}) + } + case "owner": + o := m.app.owner + kind := "developer" + if o.IsOrg { + kind = "org" + } + items = append(items, productItem{Label: "@" + o.Name + " · " + kind, Detail: o.Bio}, productItem{Label: "Website", Detail: o.Link, Kind: "open-url", ID: o.Link}, productItem{Label: "Public play activity", Detail: playSummary(m.app.days), Kind: "chart", ID: o.Name}) + gameRows(o.Games) + case "chart": + for _, d := range m.app.days { + items = append(items, productItem{Label: fmt.Sprintf("%s %s %d plays", d.Day, playIntensity(d.Count, m.app.days), d.Count)}) + } + case "settings": + if !m.app.snapshot.SignedIn { + items = append(items, productItem{Label: "Sign in", Detail: "Approve this terminal in your browser", Kind: "sign-in"}) + } else { + a := m.app.snapshot.Account + items = append(items, productItem{Label: a.Email}, productItem{Label: "Handle: @" + a.Username, Detail: "Check availability, claim, or rename", Kind: "form-username"}, productItem{Label: "Create an org", Kind: "form-org"}) + for _, o := range a.Orgs { + role := "member" + if o.Admin { + role = "admin" + } + items = append(items, productItem{Label: "@" + o.Username + " · " + role, Detail: o.Bio, Kind: "members", ID: o.Username}) + } + items = append(items, productItem{Label: "CLI sessions", Detail: "View and revoke login sessions (not publish keys)", Kind: "page", ID: "sessions"}, productItem{Label: "Sign out", Detail: "Revoke this device, then clear locally", Kind: "confirm-logout"}, productItem{Label: "Delete account", Detail: "Subject to published-game and org-admin safeguards", Kind: "confirm-account"}) + } + items = append(items, productItem{Label: "Pixel style: " + m.shape.Name, Detail: "Change for the next game start", Kind: "pixels"}) + case "members": + for _, member := range m.app.members { + role := "member" + if member.Admin { + role = "admin" + } + items = append(items, productItem{Label: member.Email + " · " + role, Kind: "form-member", ID: member.Email}) + } + case "sessions": + for _, c := range m.app.credentials { + state := "active" + if c.Revoked { + state = "revoked" + } + if c.ID == m.app.snapshot.CredentialID { + state += " · this device" + } + items = append(items, productItem{Label: c.DeviceName + " · " + state, Detail: "expires " + c.ExpiresAt + " · last used " + c.LastUsedAt + " · created " + c.CreatedAt, Kind: "confirm-session", ID: c.ID}) + } + case "pairing": + items = append(items, productItem{Label: "Secure browser approval", Detail: "Never enter an account password or email verification code here."}, productItem{Label: m.app.pairingURL, Kind: "open-url", ID: m.app.pairingURL}, productItem{Label: "Pairing code: " + m.app.round.UserCode, Detail: "Waiting for approval; Esc cancels. Browser opens only on explicit selection."}) + case "docs": + items = append(items, productItem{Label: "TERMCADE", Detail: "One arcade in your terminal and browser."}, productItem{Label: "Library", Detail: "Add saves account membership. Play installs a missing package. Local installations remain playable offline."}, productItem{Label: "Two removal actions", Detail: "Remove from Library changes your account. Uninstall here removes only this machine's copy."}, productItem{Label: "Build a game", Detail: "termcade dev new author/slug\ntermcade dev build\ntermcade dev install game.tcade"}, productItem{Label: "Publish", Detail: "termcade publish [asset]"}, productItem{Label: "Keyboard", Detail: "Tab/Shift+Tab focus · arrows select · Enter activate · Esc back/pause · Ctrl+C quit. PgUp/PgDown scroll long text."}, productItem{Label: "Gameplay", Detail: "Arrows/WASD to move · Space/Z action A · X action B · Enter start. Pause retains keyboard navigation and pixel selection."}) + } + return items +} + +func playSummary(days []registry.PlayDay) string { + n := 0 + for _, d := range days { + n += d.Count + } + return fmt.Sprintf("%d plays · %d UTC days this year · Enter for daily counts", n, len(days)) + "\n" + playHeatmap(days) +} + +func playHeatmap(days []registry.PlayDay) string { + if len(days) == 0 { + return "Activity unavailable or no daily data" + } + start, err := time.Parse("2006-01-02", days[0].Day) + if err != nil { + return "Use daily view to inspect activity" + } + start = start.AddDate(0, 0, -int(start.Weekday())) + byDate := map[string]int{} + last := start + for _, day := range days { + d, e := time.Parse("2006-01-02", day.Day) + if e != nil { + continue + } + byDate[day.Day] = day.Count + if d.After(last) { + last = d + } + } + weeks := min(54, int(last.Sub(start).Hours()/24)/7+1) + var lines []string + for row, label := range []string{"S", "M", "T", "W", "T", "F", "S"} { + line := label + " " + for week := 0; week < weeks; week++ { + date := start.AddDate(0, 0, week*7+row).Format("2006-01-02") + if count, ok := byDate[date]; ok { + line += playIntensity(count, days) + } else { + line += " " + } + } + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} +func playIntensity(count int, days []registry.PlayDay) string { + peak := 0 + for _, d := range days { + peak = max(peak, d.Count) + } + if count <= 0 { + return "░" + } + if count*4 <= peak { + return "▁" + } + if count*2 <= peak { + return "▃" + } + if count*4 <= peak*3 { + return "▆" + } + return "█" +} + +func (m Model) productActions() []productAction { + a := []productAction{{Label: "Back", Kind: "back"}, {Label: "Refresh", Kind: "refresh"}} + if m.app.loc.Page == "pairing" { + return []productAction{{Label: "Open browser", Kind: "open-url", ID: m.app.pairingURL, Disabled: m.app.pairingURL == ""}, {Label: "New code", Kind: "pair-retry", Disabled: m.app.loading}, {Label: "Cancel", Kind: "back"}} + } + if m.app.loc.Page == "members" { + a = append(a, productAction{Label: "View org", Kind: "owner", ID: m.app.loc.ID}) + if m.orgAdmin(m.app.loc.ID) { + a = append(a, productAction{Label: "Add member", Kind: "form-member"}, productAction{Label: "Remove member", Kind: "confirm-member"}, productAction{Label: "Dissolve org", Kind: "confirm-org"}) + } + } + id := "" + if m.app.loc.Page == "game" { + id = m.app.game.ID + } else if m.app.loc.Page == "library" || m.app.loc.Page == "marketplace" || m.app.loc.Page == "owner" { + items := m.productItems() + if len(items) > 0 { + selected := items[clampIndex(m.app.selection, len(items))] + if selected.Kind == "game" || selected.Kind == "play" { + id = selected.ID + } + } + } + if id != "" { + g := m.metadata(id) + local := m.localIndex(id) + canPlay := local >= 0 && m.games[local].Err == nil + canDownload := local < 0 && m.inAccount(id) && g.HasPackage && g.ABI == sdk.ABIVersion + a = append(a, productAction{Label: "Play", Kind: "play", ID: id, Disabled: !canPlay && !canDownload}) + if m.inAccount(id) { + a = append(a, productAction{Label: "Remove from Library", Kind: "remove", ID: id}) + } else { + a = append(a, productAction{Label: "Add", Kind: "add", ID: id, Disabled: !m.app.snapshot.SignedIn || !m.app.snapshot.LibraryKnown}) + } + if local >= 0 { + a = append(a, productAction{Label: "Uninstall here", Kind: "confirm-uninstall", ID: id}) + } + } + for i := range a { + if m.app.loading && a[i].Kind != "back" && a[i].Kind != "open-url" && (a[i].Kind != "play" || m.localIndex(a[i].ID) < 0 || m.app.mutating) { + a[i].Disabled = true + } + } + return a +} + +func (m Model) orgAdmin(name string) bool { + if !m.app.snapshot.SignedIn { + return false + } + for _, o := range m.app.snapshot.Account.Orgs { + if o.Username == name { + return o.Admin + } + } + return false +} + +func (m *Model) clearPrivateProductViews() { + m.app.members = nil + m.app.credentials = nil + m.app.form = nil + if !m.app.snapshot.SignedIn { + m.app.snapshot.Activity = nil + m.app.snapshot.Account = registry.Me{} + m.app.snapshot.CredentialID = "" + } + if !m.app.snapshot.SignedIn && (m.app.loc.Page == "members" || m.app.loc.Page == "sessions") { + m.app.loc = productLocation{Page: "settings"} + m.app.selection = 0 + m.app.focus = 1 + } +} + +func (m Model) activateProduct(kind, id string) (Model, tea.Cmd) { + if m.app.loading && (strings.HasPrefix(kind, "form-") || strings.HasPrefix(kind, "confirm-")) { + m.app.notice = "Wait for the current request, or Esc to cancel it" + return m, nil + } + switch kind { + case "page": + return m.openProduct(productLocation{Page: id}, true) + case "game", "owner", "members": + return m.openProduct(productLocation{Page: kind, ID: id}, true) + case "chart": + m.app.history = append(m.app.history, m.app.loc) + m.app.loc = productLocation{Page: "chart", ID: id} + m.app.selection = 0 + m.app.manualScroll = false + return m, nil + case "back": + m.cancelProduct() + if n := len(m.app.history); n > 0 { + loc := m.app.history[n-1] + m.app.history = m.app.history[:n-1] + return m.openProduct(loc, false) + } + return m.openProduct(productLocation{Page: "marketplace"}, false) + case "refresh": + return m.openProduct(m.app.loc, false) + case "pixels": + next, cmd := m.cyclePixels() + m = next.(Model) + m.app.notice = m.notice + return m, cmd + case "play": + if i := m.localIndex(id); i >= 0 { + if m.games[i].Err != nil { + m.app.notice = "Broken package: uninstall or replace it explicitly" + return m, nil + } + m.cancelProduct() + next, cmd := m.startGame(i) + return next.(Model), cmd + } + g := m.metadata(id) + if !m.inAccount(id) || !g.HasPackage || g.ABI != sdk.ABIVersion { + m.app.notice = "Add a compatible published game to your Library first" + return m, nil + } + cmd := m.productRequest(ProductRequest{Kind: "install", ID: id}) + return m, cmd + case "add", "remove": + cmd := m.productRequest(ProductRequest{Kind: kind, ID: id}) + return m, cmd + case "open-url": + if id == "" { + return m, nil + } + // Opening a browser must not cancel the active device poll. + service, gen := m.mp.Product, m.app.gen + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + reply, err := service.Request(ctx, ProductRequest{Kind: "open-url", ID: id}) + return productOpenMsg{gen, reply.Notice, err} + } + case "sign-in": + m.app.history = append(m.app.history, m.app.loc) + m.app.loc = productLocation{Page: "pairing"} + m.app.focus = 2 + m.app.action = 0 + m.app.round = registry.DeviceRound{} + m.app.pairingURL = "" + cmd := m.productRequest(ProductRequest{Kind: "pair-start"}) + return m, cmd + case "pair-retry": + m.app.round = registry.DeviceRound{} + m.app.pairingURL = "" + m.app.notice = "" + cmd := m.productRequest(ProductRequest{Kind: "pair-start"}) + return m, cmd + case "form-username": + m.app.form = &productForm{Title: "Handle availability / claim / rename", Kind: "username", Labels: []string{"Handle"}, Values: []string{m.app.snapshot.Account.Username}} + case "form-org": + m.app.form = &productForm{Title: "Create org", Kind: "org-create", Labels: []string{"Handle", "Bio (optional)", "Website (optional)"}, Values: []string{"", "", ""}} + case "form-member": + if !m.orgAdmin(m.app.loc.ID) { + m.app.notice = "Only org admins can change membership" + return m, nil + } + role := "member" + for _, member := range m.app.members { + if member.Email == id && member.Admin { + role = "admin" + } + } + m.app.form = &productForm{Title: "Add member / change role", Kind: "member-set", ID: m.app.loc.ID, Labels: []string{"Existing account email", "Role: member or admin"}, Values: []string{id, role}} + case "confirm-uninstall": + m.confirmProduct("Uninstall local copy", "uninstall", id, id) + case "confirm-logout": + m.confirmProduct("Revoke this device and sign out", "logout", "", "sign out") + case "confirm-account": + m.confirmProduct("Delete account", "account-delete", "", m.app.snapshot.Account.Email) + case "confirm-org": + if m.orgAdmin(m.app.loc.ID) { + m.confirmProduct("Dissolve org", "org-delete", m.app.loc.ID, m.app.loc.ID) + } + case "confirm-member": + items := m.productItems() + if len(items) > 0 && m.orgAdmin(m.app.loc.ID) { + email := items[clampIndex(m.app.selection, len(items))].ID + m.confirmProduct("Remove member", "member-remove", m.app.loc.ID, email) + } + case "confirm-session": + name := id + for _, credential := range m.app.credentials { + if credential.ID == id { + name = credential.DeviceName + " (" + credential.ID + ")" + if credential.Revoked { + m.app.notice = "This session is already revoked" + return m, nil + } + } + } + m.confirmProduct("Revoke "+sanitize(name), "session-revoke", id, "revoke") + } + return m, nil +} + +type productOpenMsg struct { + Gen uint64 + Notice string + Err error +} + +func (m *Model) confirmProduct(title, kind, id, confirmation string) { + m.app.form = &productForm{Title: title, Kind: kind, ID: id, Labels: []string{"Type " + confirmation + " to confirm"}, Values: []string{""}, Confirmation: confirmation} +} +func (m *Model) appendFormText(text string) { + f := *m.app.form + f.Values = append([]string(nil), f.Values...) + f.Carets = append([]int(nil), f.Carets...) + initializeCarets(&f) + r := []rune(f.Values[f.Index]) + at := f.Carets[f.Index] + insert := []rune(sanitize(text)) + value := string(r[:at]) + string(insert) + string(r[at:]) + if utf8.RuneCountInString(value) <= 2048 { + f.Values[f.Index] = value + f.Carets[f.Index] = at + len(insert) + m.app.form = &f + } +} + +func initializeCarets(f *productForm) { + if len(f.Carets) != len(f.Values) { + f.Carets = make([]int, len(f.Values)) + for i, value := range f.Values { + f.Carets[i] = utf8.RuneCountInString(value) + } + } +} + +func (m Model) productFormKey(msg tea.KeyPressMsg) (Model, tea.Cmd, bool) { + key := msg.String() + f := *m.app.form + f.Values = append([]string(nil), f.Values...) + f.Carets = append([]int(nil), f.Carets...) + initializeCarets(&f) + m.app.form = &f + if key == "esc" { + refresh := m.app.loading && m.app.mutating + m.cancelProduct() + m.app.form = nil + if refresh { + cmd := m.productRequest(ProductRequest{Kind: "load", Extra: "refresh-page"}) + return m, cmd, true + } + return m, nil, true + } + if m.app.loading { + return m, nil, true + } + buttons := 2 + if f.Kind == "username" { + buttons = 3 + } + if key == "tab" || key == "shift+tab" { + delta := 1 + if key == "shift+tab" { + delta = len(f.Values) + buttons - 1 + } + m.app.form.Index = (f.Index + delta) % (len(f.Values) + buttons) + return m, nil, true + } + if f.Index < len(f.Values) { + r := []rune(f.Values[f.Index]) + at := f.Carets[f.Index] + switch key { + case "left": + f.Carets[f.Index] = max(0, at-1) + case "right": + f.Carets[f.Index] = min(len(r), at+1) + case "home": + f.Carets[f.Index] = 0 + case "end": + f.Carets[f.Index] = len(r) + case "ctrl+u": + f.Values[f.Index] = "" + f.Carets[f.Index] = 0 + case "backspace": + if at > 0 { + f.Values[f.Index] = string(r[:at-1]) + string(r[at:]) + f.Carets[f.Index]-- + } + case "delete": + if at < len(r) { + f.Values[f.Index] = string(r[:at]) + string(r[at+1:]) + } + default: + if msg.Text != "" { + m.appendFormText(msg.Text) + } + } + return m, nil, true + } + if key != "enter" { + return m, nil, true + } + button := f.Index - len(f.Values) + if button == buttons-1 { + m.app.form = nil + return m, nil, true + } + if f.Confirmation != "" && f.Values[0] != f.Confirmation { + m.app.notice = "Confirmation does not match" + return m, nil, true + } + req := ProductRequest{Kind: f.Kind, ID: f.ID, Value: strings.TrimSpace(f.Values[0])} + switch f.Kind { + case "username": + if req.Value == "" { + m.app.notice = "Handle is required" + return m, nil, true + } + if button == 1 { + req.Kind = "username-check" + } + case "org-create": + req.ID = req.Value + req.Value = strings.TrimSpace(f.Values[1]) + req.Extra = strings.TrimSpace(f.Values[2]) + if req.ID == "" { + m.app.notice = "Org handle is required" + return m, nil, true + } + case "member-set": + role := strings.TrimSpace(f.Values[1]) + if role != "admin" && role != "member" { + m.app.notice = "Role must be admin or member" + return m, nil, true + } + req.Admin = role == "admin" + case "member-remove": + req.Value = f.Confirmation + } + cmd := m.productRequest(req) + return m, cmd, true +} + +func (m Model) closeProductGame() Model { + if m.game != nil { + if m.screen == screenPlaying || m.screen == screenPaused { + m.scores.Record(m.game.Info().ID, m.game.Score(), m.gameVersion, false) + } + m.game.Close() + m.game = nil + m.tickGen++ + } + m.screen = screenProduct + return m +} + +func (m Model) productPauseKey(key string) (Model, tea.Cmd, bool) { + choices := []string{"Resume", "Restart", "Pixel style (next start)", "Leave Play"} + switch key { + case "esc", "p": + next, cmd := m.resumeGame() + return next.(Model), cmd, true + case "up": + m.pauseIdx = (m.pauseIdx + len(choices) - 1) % len(choices) + case "down": + m.pauseIdx = (m.pauseIdx + 1) % len(choices) + case "enter": + switch m.pauseIdx { + case 0: + next, cmd := m.resumeGame() + return next.(Model), cmd, true + case 1: + next, cmd := m.startGame(m.localIndex(m.game.Info().ID)) + return next.(Model), cmd, true + case 2: + next, cmd := m.cyclePixels() + return next.(Model), cmd, true + case 3: + m = m.closeProductGame() + next, cmd := m.openProduct(productLocation{Page: "library"}, false) + return next, tea.Batch(cmd, saveScores(m.scores), m.syncCmd()), true + } + } + return m, nil, true +} + +// boundedBlock constrains both axes in display cells, including ANSI styling. +func boundedBlock(s string, w, h int) string { + if w <= 0 || h <= 0 { + return "" + } + return lipgloss.NewStyle().Width(w).MaxWidth(w).Height(h).MaxHeight(h).Render(s) +} + +func (m Model) productView() string { + if m.termW < 1 || m.termH < 1 { + return "" + } + gameRoute := m.screen != screenProduct + showRail := m.termW >= sidebarBreakpoint + if gameRoute && m.game != nil && m.termW-sidebarWidth < m.game.Info().PixelW+2 { + showRail = false + } + navFocus := (!gameRoute && m.app.focus == 0) || m.app.gameNav + width := m.termW + if showRail { + width -= sidebarWidth + } + contentWidth := min(width, productContentMax) + if gameRoute { + contentWidth = width + } + var body string + if gameRoute { + body = m.productGameView(width, m.termH) + } else if m.app.form != nil { + body = m.productFormView(contentWidth, m.termH) + } else { + body = m.productPageView(contentWidth, m.termH) + } + body = lipgloss.PlaceHorizontal(width, lipgloss.Center, boundedBlock(body, contentWidth, m.termH)) + if navFocus && !showRail { + columns := min(sidebarWidth, m.termW) + background := strings.Split(boundedBlock(body, m.termW, m.termH), "\n") + nav := strings.Split(m.productNavView(columns, m.termH), "\n") + nav[0] = boundedBlock("NAVIGATION", columns-1, 1) + for i := range background { + left := "" + if i < len(nav) { + left = nav[i] + } + background[i] = boundedBlock(left, columns-1, 1) + dimStyle.Render("│") + lipgloss.NewStyle().Faint(true).Render(ansi.Cut(background[i], columns, m.termW)) + } + return boundedBlock(strings.Join(background, "\n"), m.termW, m.termH) + } + if showRail { + nav := strings.Split(m.productNavView(sidebarWidth, m.termH), "\n") + for i := range nav { + nav[i] += dimStyle.Render("│") + } + body = lipgloss.JoinHorizontal(lipgloss.Top, boundedBlock(strings.Join(nav, "\n"), sidebarWidth, m.termH), body) + } + return boundedBlock(body, m.termW, m.termH) +} + +func (m Model) productNavView(width, height int) string { + items := m.productNavItems() + lines := []string{lipgloss.NewStyle().Foreground(lipgloss.Color("#3fc4c9")).Bold(true).Render("TERMCADE"), ""} + start := max(0, m.app.nav-max(1, height-5)+1) + for i := start; i < len(items) && len(lines) < height-2; i++ { + line := " " + sanitize(items[i].Label) + active := items[i].Kind == "page" && items[i].ID == m.app.loc.Page + if items[i].ID == "settings" && (m.app.loc.Page == "members" || m.app.loc.Page == "sessions" || m.app.loc.Page == "pairing") { + active = true + } + if active { + line = titleStyle.Render(line) + } + if i == m.app.nav && ((m.screen == screenProduct && m.app.focus == 0) || m.app.gameNav) { + line = selectedStyle.Render("▸ " + sanitize(items[i].Label)) + } + lines = append(lines, line) + } + lines = append(lines, "", "Tab: change focus") + return boundedBlock(strings.Join(lines, "\n"), width-1, height) +} + +func (m Model) productPageView(width, height int) string { + title := strings.ToUpper(m.app.loc.Page) + if m.app.loc.ID != "" { + title += " / " + sanitize(m.app.loc.ID) + } + header := titleStyle.Render(title) + if m.app.loading { + header += dimStyle.Render(" · loading…") + } + notice := m.app.notice + if notice == "" { + switch m.app.loc.Page { + case "marketplace": + notice = m.app.snapshot.CatalogError + case "library": + notice = m.app.snapshot.LibraryError + case "settings": + notice = m.app.snapshot.AccountError + } + } + if notice == "" { + notice = m.app.snapshot.AccountError + } + footer := m.productActionView(width) + "\n" + dimStyle.Render("Tab focus · ↑/↓ select · Enter activate · Esc back · PgUp/PgDn scroll") + footerLines := strings.Count(footer, "\n") + 1 + if notice != "" { + footer = marketNotice.Render(sanitize(notice)) + "\n" + footer + footerLines++ + } + available := max(1, height-footerLines-3) + items := m.productItems() + var lines []string + selectedLine := 0 + for i, item := range items { + if i == m.app.selection { + selectedLine = len(lines) + } + label := " " + sanitize(item.Label) + if i == m.app.selection && m.app.focus == 1 { + label = selectedStyle.Render("▸ " + sanitize(item.Label)) + } + text := lipgloss.NewStyle().Width(max(1, width-2)).Render(label) + if item.Detail != "" { + text += "\n" + lipgloss.NewStyle().PaddingLeft(2).Width(max(1, width-2)).Render(dimStyle.Render(sanitizeBlock(item.Detail))) + } + lines = append(lines, strings.Split(text, "\n")...) + lines = append(lines, "") + } + if len(lines) == 0 { + lines = []string{"Nothing here yet. Use the focused actions or visit Marketplace."} + } + start := max(0, selectedLine-available+2) + if m.app.manualScroll { + start = m.app.scroll + } + start = min(start, max(0, len(lines)-available)) + content := boundedBlock(strings.Join(lines[start:min(len(lines), start+available)], "\n"), width, available) + return boundedBlock(header+"\n\n"+content+"\n"+footer, width, height) +} + +func (m Model) productActionView(width int) string { + var labels []string + for i, a := range m.productActions() { + label := "[" + a.Label + "]" + if a.Disabled { + label = dimStyle.Render(label) + } else if m.app.focus == 2 && i == m.app.action { + label = selectedStyle.Render(label) + } + labels = append(labels, label) + } + return lipgloss.NewStyle().Width(max(1, width)).Render(strings.Join(labels, " ")) +} + +func (m Model) productFormView(width, height int) string { + f := m.app.form + lines := []string{titleStyle.Render(f.Title), ""} + for i, label := range f.Labels { + value := sanitize(f.Values[i]) + prefix := " " + if f.Index == i { + prefix = "▸ " + r := []rune(value) + at := len(r) + if len(f.Carets) > i { + at = min(len(r), f.Carets[i]) + } + start := 0 + for start < at && lipgloss.Width(string(r[start:at])) > max(1, (width-4)/2) { + start++ + } + value = string(r[start:at]) + "│" + string(r[at:]) + } + lines = append(lines, dimStyle.Render(sanitize(label)), lipgloss.NewStyle().MaxWidth(max(1, width-2)).Render(prefix+value), "") + } + buttons := []string{"Submit", "Cancel"} + if f.Kind == "username" { + buttons = []string{"Save handle", "Check availability", "Cancel"} + } + for i, b := range buttons { + if f.Index == len(f.Values)+i { + b = selectedStyle.Render("[" + b + "]") + } else { + b = "[" + b + "]" + } + lines = append(lines, b) + } + if m.app.loading { + lines = append(lines, "Working… Esc stops waiting and refreshes state") + } + if m.app.notice != "" { + lines = append(lines, marketNotice.Render(sanitize(m.app.notice))) + } + lines = append(lines, "", "Tab/Shift+Tab focus · Enter button · Esc cancel") + return boundedBlock(lipgloss.NewStyle().Width(width).Render(strings.Join(lines, "\n")), width, height) +} + +func sanitizeBlock(value string) string { + lines := strings.Split(value, "\n") + for i, line := range lines { + lines[i] = sanitize(line) + } + return strings.Join(lines, "\n") +} + +func (m Model) productGameView(width, height int) string { + if m.game == nil { + return "No active game" + } + info := m.game.Info() + if width < info.PixelW+2 || height < info.PixelH/2+4 { + return tooSmall(info.PixelW+2, info.PixelH/2+4, width, height) + } + frame := m.frame + if m.screen == screenPaused { + lines := []string{"PAUSED", ""} + for i, label := range []string{"Resume", "Restart", "Pixel style: " + m.shape.Name + " (next start)", "Leave Play"} { + if i == m.pauseIdx { + label = selectedStyle.Render("▸ " + label) + } + lines = append(lines, label) + } + frame = compose(frame, borderStyle.Render(strings.Join(lines, "\n"))) + } + if m.screen == screenCrashed { + frame = compose(frame, crashBox(m.crash)) + } + header := inputStyle.Bold(true).Render(sanitize(info.Title)) + dimStyle.Render(fmt.Sprintf(" best %d", m.scores.High(info.ID))) + if played := m.scores.LastPlayed(info.ID); !played.IsZero() { + header += dimStyle.Render(" · played " + productPlayedAgo(played, time.Now())) + } + hud := m.game.HUD() + var fields []string + for _, f := range hud.Fields { + value := inputStyle.Render(sanitize(f.Value)) + if f.Accent { + value = titleStyle.Render(sanitize(f.Value)) + } + fields = append(fields, dimStyle.Render(sanitize(f.Label)+" ")+value) + } + fields = append(fields, dimStyle.Render(fmt.Sprintf("SCORE %d", m.game.Score()))) + text := header + "\n" + frame + "\n" + strings.Join(fields, " ") + "\n" + dimStyle.Render(sanitize(hud.Hint)) + if m.screen == screenGameOver { + text += "\n" + titleStyle.Render("[Enter: play again] [Esc: Library] [Tab: navigation]") + } else if m.screen == screenPaused { + text += "\n" + dimStyle.Render("↑/↓ select · Enter activate · Esc resume · Tab navigation") + } else { + footer := "Esc pause · Ctrl+C quit" + if m.app.notice != "" { + footer += " · " + sanitize(m.app.notice) + } + text += "\n" + dimStyle.Render(footer) + } + return boundedBlock(text, width, height) +} diff --git a/internal/shell/product_service.go b/internal/shell/product_service.go new file mode 100644 index 0000000..e51dd37 --- /dev/null +++ b/internal/shell/product_service.go @@ -0,0 +1,52 @@ +package shell + +import ( + "context" + + "github.com/aviorstudio/termcade/internal/engine" + "github.com/aviorstudio/termcade/internal/registry" +) + +// ProductServices is the non-printing, cancellable boundary used by the full +// application shell. The older Marketplace hooks remain for game-only embeds. +type ProductServices struct { + Request func(context.Context, ProductRequest) (ProductReply, error) + Sync func(context.Context) string +} + +type ProductSnapshot struct { + Catalog []registry.Game + Library []registry.Game + Installed []engine.Registration + Account registry.Me + SignedIn bool + LibraryKnown bool + CredentialID string + CatalogError string + AccountError string + LibraryError string + Activity []registry.Activity +} + +type ProductRequest struct { + Kind string + ID string + Value string + Extra string + Admin bool + Round registry.DeviceRound + Session registry.Session +} + +type ProductReply struct { + Snapshot *ProductSnapshot + Game *registry.Game + Owner *registry.HandleOwner + Days []registry.PlayDay + Members []registry.Member + Credentials []registry.CLICredential + Round registry.DeviceRound + PairingURL string + Session registry.Session + Notice string +} diff --git a/internal/shell/product_test.go b/internal/shell/product_test.go new file mode 100644 index 0000000..629e32f --- /dev/null +++ b/internal/shell/product_test.go @@ -0,0 +1,608 @@ +package shell + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/aviorstudio/termcade/internal/engine" + "github.com/aviorstudio/termcade/internal/registry" + "github.com/aviorstudio/termcade/internal/scores" + "github.com/aviorstudio/termcade/sdk" + "github.com/charmbracelet/x/ansi" +) + +type productGame struct { + fakeGame + id string +} + +func (g *productGame) Info() sdk.Info { + return sdk.Info{ID: g.id, Title: "LOCAL", PixelW: 16, PixelH: 8} +} + +type productFixture struct { + snapshot ProductSnapshot + calls []ProductRequest + failure error + game *productGame +} + +func newProductFixture(t *testing.T) (Model, *productFixture) { + t.Helper() + config := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", config) + t.Setenv("HOME", config) + t.Setenv("AppData", config) + st, err := scores.Load() + if err != nil { + t.Fatal(err) + } + g := &productGame{id: "acme/local"} + reg := engine.Registration{Info: g.Info(), New: func(sdk.CellShape) (sdk.Game, error) { return g, nil }, Installed: true, Version: "1.0.0"} + f := &productFixture{game: g, snapshot: ProductSnapshot{ + Catalog: []registry.Game{{ID: "acme/local", Name: "Local", Version: "1.0.0", HasPackage: true, ABI: 1}, {ID: "acme/remote", Name: "Remote", Version: "2.0.0", HasPackage: true, ABI: 1}, {ID: "acme/future", Name: "Future", HasPackage: true, ABI: 2}, {ID: "acme/unreleased", Name: "Unreleased"}}, + Library: []registry.Game{{ID: "acme/remote", Name: "Remote", Version: "2.0.0", HasPackage: true, ABI: 1}}, Installed: []engine.Registration{reg}, + SignedIn: true, LibraryKnown: true, CredentialID: "this-device", Account: registry.Me{Email: "dev@example.test", Username: "dev", Orgs: []registry.Org{{Username: "team", Admin: true}}}, + }} + service := &ProductServices{Request: func(ctx context.Context, r ProductRequest) (ProductReply, error) { + f.calls = append(f.calls, r) + if ctx.Err() != nil { + return ProductReply{}, ctx.Err() + } + if f.failure != nil { + return ProductReply{}, f.failure + } + switch r.Kind { + case "game": + for _, game := range f.snapshot.Catalog { + if game.ID == r.ID { + return ProductReply{Game: &game}, nil + } + } + return ProductReply{}, registry.ErrNotFound + case "owner": + return ProductReply{Owner: ®istry.HandleOwner{Name: r.ID, IsOrg: true, Bio: "studio", Games: f.snapshot.Catalog}}, nil + case "add": + for _, game := range f.snapshot.Catalog { + if game.ID == r.ID { + f.snapshot.Library = append(f.snapshot.Library, game) + } + } + case "remove": + var keep []registry.Game + for _, game := range f.snapshot.Library { + if game.ID != r.ID { + keep = append(keep, game) + } + } + f.snapshot.Library = keep + case "uninstall": + f.snapshot.Installed = nil + case "pair-start": + return ProductReply{Round: registry.DeviceRound{UserCode: "ABCD-EFGH", ExpiresIn: time.Minute}, PairingURL: "http://127.0.0.1:8081/pair"}, nil + case "pair-wait": + return ProductReply{Session: registry.Session{Token: "must-not-render", CredentialID: "new-device"}}, nil + case "sessions": + return ProductReply{Credentials: []registry.CLICredential{{ID: "this-device", DeviceName: "Laptop"}}}, nil + case "members": + return ProductReply{Members: []registry.Member{{Email: "member@example.test"}}}, nil + } + snapshot := f.snapshot + return ProductReply{Snapshot: &snapshot}, nil + }} + m := New(f.snapshot.Installed, st, sdk.Quadrant, &Marketplace{Product: service}) + t.Cleanup(m.app.cancel) + m, _ = step(t, m, tea.WindowSizeMsg{Width: 80, Height: 24}) + return m, f +} + +func productKey(name string) tea.KeyPressMsg { + codes := map[string]rune{"tab": tea.KeyTab, "enter": tea.KeyEnter, "esc": tea.KeyEscape, "up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight, "backspace": tea.KeyBackspace, "pgdown": tea.KeyPgDown, "pgup": tea.KeyPgUp} + if name == "shift+tab" { + return tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} + } + if name == "ctrl+c" { + return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} + } + if code, ok := codes[name]; ok { + return tea.KeyPressMsg{Code: code} + } + r := []rune(name) + return tea.KeyPressMsg{Code: r[0], Text: name} +} + +func TestProductStartsOnMarketplaceAndDefersServiceWork(t *testing.T) { + m, f := newProductFixture(t) + if m.screen != screenProduct || m.app.loc.Page != "marketplace" || len(f.calls) != 0 { + t.Fatal("startup contract") + } + m = drain(t, m, m.Init()) + if len(f.calls) != 1 || f.calls[0].Kind != "load" || len(m.productItems()) != 4 { + t.Fatalf("startup load: %#v", f.calls) + } + if !strings.Contains(view(m), "MARKETPLACE") { + t.Fatal("no marketplace") + } +} + +func TestProductEnterOpensDetailsAndAddDoesNotInstall(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + m, cmd := step(t, m, productKey("enter")) + m = drain(t, m, cmd) + if m.app.loc != (productLocation{Page: "game", ID: "acme/local"}) { + t.Fatal(m.app.loc) + } + for _, call := range f.calls { + if call.Kind == "install" || call.Kind == "add" { + t.Fatal("Enter mutated library") + } + } + m, cmd = m.activateProduct("add", "acme/local") + m = drain(t, m, cmd) + if !m.inAccount("acme/local") || len(m.games) != 1 { + t.Fatal("Add did not preserve local copy") + } + for _, call := range f.calls { + if call.Kind == "install" { + t.Fatal("Add downloaded") + } + } +} + +func TestProductLibraryMembershipAndFilesAreIndependent(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + if len(m.libraryGames()) != 2 || m.gameBadge("acme/remote") != "Not installed" || m.gameBadge("acme/local") != "Installed · Local only" { + t.Fatal("union or badges") + } + m, cmd := m.activateProduct("add", "acme/local") + m = drain(t, m, cmd) + m, cmd = m.activateProduct("remove", "acme/local") + m = drain(t, m, cmd) + if m.inAccount("acme/local") || m.localIndex("acme/local") < 0 { + t.Fatal("Remove deleted local copy") + } + m, cmd = m.activateProduct("add", "acme/local") + m = drain(t, m, cmd) + m, _ = m.activateProduct("confirm-uninstall", "acme/local") + m, _ = step(t, m, tea.PasteMsg{Content: "acme/local"}) + m, _ = step(t, m, productKey("tab")) + m, cmd = step(t, m, productKey("enter")) + m = drain(t, m, cmd) + if !m.inAccount("acme/local") || m.localIndex("acme/local") >= 0 { + t.Fatal("Uninstall changed membership") + } +} + +func TestProductUnknownMembershipAndOfflinePlay(t *testing.T) { + m, f := newProductFixture(t) + f.snapshot.LibraryKnown = false + f.snapshot.Library = nil + m = drain(t, m, m.Init()) + if m.gameBadge("acme/local") != "Installed · membership unknown" { + t.Fatal(m.gameBadge("acme/local")) + } + m.app.snapshot.SignedIn = false + f.calls = nil + m, cmd := m.activateProduct("play", "acme/local") + if cmd == nil || m.screen != screenPlaying || len(f.calls) != 0 { + t.Fatal("offline play contacted account") + } + m, _ = step(t, m, productKey("left")) + if f.game.lastKey != sdk.KeyLeft { + t.Fatal("game input lost") + } + m, _ = step(t, m, productKey("tab")) + if m.app.gameNav { + t.Fatal("navigation captured live game focus") + } + m, _ = step(t, m, productKey("esc")) + if m.screen != screenPaused { + t.Fatal("pause missing") + } + m, _ = step(t, m, productKey("tab")) + if !m.app.gameNav { + t.Fatal("paused navigation unavailable") + } + m, _ = step(t, m, productKey("esc")) + if m.app.gameNav || m.screen != screenPaused { + t.Fatal("navigation Escape resumed game") + } + m, _ = step(t, m, productKey("esc")) + if m.screen != screenPlaying { + t.Fatal("resume missing") + } + m = m.closeProductGame() +} + +func TestProductOldRepliesCannotChangeNewRouteOrSaveLogin(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + m, cmd := m.openProduct(productLocation{Page: "owner", ID: "old"}, true) + reply := cmd() + m, _ = m.openProduct(productLocation{Page: "docs"}, true) + m, _ = step(t, m, reply) + if m.app.loc.Page != "docs" || m.app.owner.Name != "old" || m.app.owner.Bio != "" { + t.Fatal("stale reply replaced route") + } + m, _ = m.activateProduct("sign-in", "") + gen := m.app.gen + m, _ = m.activateProduct("back", "") + m, cmd = step(t, m, productMsg{Gen: gen, Request: ProductRequest{Kind: "pair-wait"}, Reply: ProductReply{Session: registry.Session{Token: "must-not-render"}}}) + if cmd != nil { + t.Fatal("stale pairing scheduled credential persistence") + } + if strings.Contains(view(m), "must-not-render") { + t.Fatal("credential displayed") + } + for _, call := range f.calls { + if call.Kind == "save-login" { + t.Fatal("stale login saved") + } + } +} + +func TestProductAccountChangesDropPrivateViews(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m.app.loc = productLocation{Page: "members", ID: "team"} + m.app.members = []registry.Member{{Email: "private@example.test"}} + m.app.credentials = []registry.CLICredential{{ID: "private"}} + m, _ = step(t, m, productMsg{Gen: m.app.gen, Request: ProductRequest{Kind: "members"}, Err: registry.ErrLoginRequired}) + if m.app.snapshot.SignedIn || len(m.app.members) != 0 || len(m.app.credentials) != 0 || m.app.loc.Page != "settings" { + t.Fatal("private account view survived expiry") + } + if m.orgAdmin("team") { + t.Fatal("expired account retained admin controls") + } +} + +func TestProductHandleAndOrgFormsKeepActionsExplicit(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + f.calls = nil + m, _ = m.activateProduct("form-username", "") + m, _ = step(t, m, tea.KeyPressMsg{Code: 'u', Mod: tea.ModCtrl}) + m, _ = step(t, m, productKey("new-handle")) + m, _ = step(t, m, productKey("tab")) + m, _ = step(t, m, productKey("tab")) + m, cmd := step(t, m, productKey("enter")) + m = drain(t, m, cmd) + if len(f.calls) != 1 || f.calls[0].Kind != "username-check" || f.calls[0].Value != "new-handle" || m.app.form == nil { + t.Fatal("availability check mutated or closed the form") + } + m, _ = step(t, m, productKey("esc")) + m, _ = m.activateProduct("form-org", "") + for _, value := range []string{"studio", "Makes games", "https://example.test"} { + m, _ = step(t, m, productKey(value)) + m, _ = step(t, m, productKey("tab")) + } + m, cmd = step(t, m, productKey("enter")) + m = drain(t, m, cmd) + last := f.calls[len(f.calls)-1] + if last.Kind != "org-create" || last.ID != "studio" || last.Value != "Makes games" || last.Extra != "https://example.test" { + t.Fatalf("wrong org fields: %#v", last) + } +} + +func TestProductUnicodeEditingAndLongFieldsStayBounded(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = m.activateProduct("form-org", "") + m, _ = step(t, m, productKey("a界b")) + m, _ = step(t, m, productKey("left")) + m, _ = step(t, m, productKey("backspace")) + if m.app.form.Values[0] != "ab" { + t.Fatal("editing split a Unicode character") + } + m, _ = step(t, m, tea.PasteMsg{Content: strings.Repeat("x", 1500)}) + for _, line := range strings.Split(view(m), "\n") { + if lipgloss.Width(line) > 80 { + t.Fatal("field exceeded terminal width") + } + } + if !strings.Contains(view(m), "Submit") { + t.Fatal("long field hid submission controls") + } +} + +func TestProductHeatmapPreservesZeroDaysAndIntensity(t *testing.T) { + days := []registry.PlayDay{{Day: "2024-02-25", Count: 0}, {Day: "2024-02-26", Count: 1}, {Day: "2024-02-27", Count: 2}, {Day: "2024-02-28", Count: 3}, {Day: "2024-02-29", Count: 4}} + heatmap := playHeatmap(days) + if len(strings.Split(heatmap, "\n")) != 7 { + t.Fatal("week rows missing") + } + for _, glyph := range []string{"░", "▁", "▃", "▆", "█"} { + if !strings.Contains(heatmap, glyph) { + t.Fatal("missing activity level", glyph) + } + } + if !strings.Contains(playSummary(days), "10 plays") { + t.Fatal("wrong total") + } +} + +func TestProductDissolveReturnsToContentFocusAndChartRefreshRefetches(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + m.app.loc = productLocation{Page: "members", ID: "team"} + m.app.focus = 2 + snapshot := f.snapshot + m, _ = step(t, m, productMsg{Gen: m.app.gen, Request: ProductRequest{Kind: "org-delete", ID: "team"}, Reply: ProductReply{Snapshot: &snapshot}}) + if m.app.loc.Page != "settings" || m.app.focus != 1 || m.app.selection != 0 { + t.Fatal("dissolve returned to the wrong focus") + } + m, cmd := m.openProduct(productLocation{Page: "chart", ID: "acme/local"}, false) + m = drain(t, m, cmd) + if f.calls[len(f.calls)-1].Kind != "game" || m.app.loc.Page != "chart" { + t.Fatal("chart refresh did not refetch game activity") + } +} + +func TestProductCannotPlayBrokenOrUnsupportedUninstalledGames(t *testing.T) { + m, f := newProductFixture(t) + f.snapshot.Installed[0].Err = errors.New("bad package") + f.snapshot.Library = append(f.snapshot.Library, f.snapshot.Catalog[2]) + m = drain(t, m, m.Init()) + for _, id := range []string{"acme/local", "acme/future"} { + m.app.loc = productLocation{Page: "game", ID: id} + m.app.game = m.metadata(id) + for _, action := range m.productActions() { + if action.Kind == "play" && !action.Disabled { + t.Fatal("invalid game enabled Play", id) + } + } + } +} + +func TestProductPagingKeepsActionsOnTheVisibleSelection(t *testing.T) { + m, f := newProductFixture(t) + for i := 0; i < 30; i++ { + f.snapshot.Catalog = append(f.snapshot.Catalog, registry.Game{ID: fmt.Sprintf("acme/extra-%d", i), Name: "Extra"}) + } + m = drain(t, m, m.Init()) + m, _ = step(t, m, productKey("pgdown")) + if m.app.selection == 0 || m.app.manualScroll { + t.Fatal("paging left actions targeting the hidden first row") + } + m.app.loc = productLocation{Page: "owner", ID: "acme"} + m.app.owner = registry.HandleOwner{Name: "acme", Games: f.snapshot.Catalog} + m.app.selection = 3 + found := false + for _, a := range m.productActions() { + if a.Kind == "play" && a.ID == "acme/local" { + found = true + } + } + if !found { + t.Fatal("owner game cards lack Play") + } +} + +func TestProductPairingNeverOpensBrowserAutomatically(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + f.calls = nil + m, cmd := m.activateProduct("sign-in", "") + m = drain(t, m, cmd) + for _, call := range f.calls { + if call.Kind == "open-url" { + t.Fatal("pairing launched a browser without selection") + } + } + m.app.loc = productLocation{Page: "pairing"} + m.app.pairingURL = "http://127.0.0.1:8081/pair" + m.app.focus = 2 + m.app.action = 0 + _, cmd = step(t, m, tea.KeyPressMsg{Code: tea.KeyEnter, IsRepeat: true}) + if cmd != nil { + t.Fatal("key repeat launched browser") + } +} + +func TestProductOrdinaryMemberCannotUseAdminForms(t *testing.T) { + m, f := newProductFixture(t) + f.snapshot.Account.Orgs[0].Admin = false + m = drain(t, m, m.Init()) + f.calls = nil + m.app.loc = productLocation{Page: "members", ID: "team"} + m.app.members = []registry.Member{{Email: "member@example.test"}} + m, _ = m.activateProduct("form-member", "member@example.test") + if m.app.form != nil || len(f.calls) != 0 { + t.Fatal("member received an admin form") + } + for _, action := range m.productActions() { + if strings.Contains(action.Kind, "member") || action.Kind == "confirm-org" { + t.Fatal("admin controls visible") + } + } +} + +func TestProductCancelAfterACommittedMutationReconcilesFiles(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = m.activateProduct("confirm-uninstall", "acme/local") + m, _ = step(t, m, tea.PasteMsg{Content: "acme/local"}) + m, _ = step(t, m, productKey("tab")) + m, cmd := step(t, m, productKey("enter")) + late := cmd() // the confirmed mutation committed before Escape + m, refresh := step(t, m, productKey("esc")) + if refresh == nil { + t.Fatal("canceled mutation did not reconcile") + } + m = drain(t, m, refresh) + m, _ = step(t, m, late) + if m.localIndex("acme/local") >= 0 || m.app.loading { + t.Fatal("stale installed state survived reconciliation") + } +} + +func TestProductRemoteContinuePlayingDoesNotRequireAnInstall(t *testing.T) { + m, f := newProductFixture(t) + f.snapshot.Activity = []registry.Activity{{ID: "acme/remote", PersonalBest: 42, LastPlayed: time.Now().UTC().Format(time.RFC3339)}} + m = drain(t, m, m.Init()) + items := m.productRecentItems() + if len(items) != 1 || items[0].ID != "acme/remote" || items[0].Kind != "play" || !strings.Contains(items[0].Detail, "best 42") { + t.Fatal("remote recent game missing") + } + if m.localIndex("acme/remote") >= 0 { + t.Fatal("reading recent games installed one") + } + m.app.snapshot.SignedIn = false + if len(m.productRecentItems()) != 0 { + t.Fatal("signed-out view retained account-only history") + } +} + +func TestProductRelativeTimeMatchesWebPresentation(t *testing.T) { + now := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) + for elapsed, want := range map[time.Duration]string{0: "just now", 2 * time.Minute: "2 minutes ago", time.Hour: "an hour ago", 24 * time.Hour: "yesterday", 60 * 24 * time.Hour: "2 months ago"} { + if got := productPlayedAgo(now.Add(-elapsed), now); got != want { + t.Fatalf("%v: %s", elapsed, got) + } + } + if productPlayedAgo(time.Time{}, now) != "never" { + t.Fatal("missing play time") + } +} + +func TestProductSidebarBreakpointAndContentCap(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = step(t, m, tea.WindowSizeMsg{Width: 119, Height: 30}) + first := strings.Split(ansi.Strip(view(m)), "\n")[0] + if strings.Contains(first, "│") { + t.Fatal("sidebar appeared below breakpoint") + } + for _, width := range []int{120, 200} { + m, _ = step(t, m, tea.WindowSizeMsg{Width: width, Height: 30}) + first = strings.Split(ansi.Strip(view(m)), "\n")[0] + if []rune(first)[21] != '│' { + t.Fatal("sidebar is not 22 columns") + } + at := strings.Index(first, "MARKETPLACE") + // The sidebar border is multibyte Unicode: compare display cells, not bytes. + if width == 200 && (at < 0 || lipgloss.Width(first[:at]) != 61) { + t.Fatal("content is not centered in its 100-column cap", first) + } + } + m, _ = step(t, m, tea.WindowSizeMsg{Width: 80, Height: 24}) + m.app.focus = 0 + first = strings.Split(ansi.Strip(view(m)), "\n")[0] + if !strings.HasPrefix(first, "NAVIGATION") || []rune(first)[21] != '│' { + t.Fatal("compact navigation is not an overlay") + } +} + +func TestProductPausePixelSelectionAppliesOnRestart(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = m.activateProduct("play", "acme/local") + m, _ = step(t, m, productKey("esc")) + m, _ = step(t, m, productKey("down")) + m, _ = step(t, m, productKey("down")) + m, cmd := step(t, m, productKey("enter")) + if cmd != nil { + cmd() + } + if m.shape.Name != sdk.Sextant.Name || m.canvas.Shape().Name != sdk.Quadrant.Name { + t.Fatal("pixel selector changed an active framebuffer") + } + m, _ = step(t, m, productKey("up")) + m, _ = step(t, m, productKey("enter")) + if m.screen != screenPlaying || m.canvas.Shape().Name != sdk.Sextant.Name { + t.Fatal("restart did not apply pixel selection") + } + m = m.closeProductGame() +} + +func TestProductFormsConsumeTypingAndRequireConfirmation(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + f.calls = nil + m, _ = m.activateProduct("confirm-account", "") + m, _ = step(t, m, productKey("pqr")) + m, _ = step(t, m, tea.PasteMsg{Content: "\x1b[31mtext\n"}) + if m.app.form == nil || !strings.HasPrefix(m.app.form.Values[0], "pqr") || len(f.calls) != 0 { + t.Fatal("typing triggered an action") + } + m, _ = step(t, m, productKey("tab")) + m, cmd := step(t, m, productKey("enter")) + if cmd != nil || !strings.Contains(m.app.notice, "Confirmation") { + t.Fatal("destructive action accepted wrong confirmation") + } + m, _ = step(t, m, productKey("esc")) + if m.app.form != nil { + t.Fatal("Escape did not dismiss form") + } +} + +func TestProductLayoutsRemainBoundedAndResizeKeepsFocus(t *testing.T) { + m, f := newProductFixture(t) + for i := 0; i < 80; i++ { + f.snapshot.Catalog = append(f.snapshot.Catalog, registry.Game{ID: fmt.Sprintf("acme/game-%d", i), Name: strings.Repeat("界", 40), Description: strings.Repeat("long description ", 20)}) + } + m = drain(t, m, m.Init()) + m.app.selection = 50 + m.app.focus = 2 + m.app.action = 1 + for _, size := range [][2]int{{80, 24}, {119, 30}, {120, 30}, {121, 30}, {180, 50}, {80, 24}, {10, 4}} { + m, _ = step(t, m, tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + lines := strings.Split(view(m), "\n") + if len(lines) > size[1] { + t.Fatalf("height %d > %d", len(lines), size[1]) + } + for _, line := range lines { + if lipgloss.Width(line) > size[0] { + t.Fatalf("width %d > %d", lipgloss.Width(line), size[0]) + } + } + if m.app.selection != 50 || m.app.focus != 2 { + t.Fatal("resize lost focus") + } + } +} + +func TestProductFailedRequestsRemainReadableAndCancelable(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + f.failure = errors.New("offline") + m, cmd := m.activateProduct("add", "acme/local") + if !m.app.loading { + t.Fatal("no pending state") + } + m = drain(t, m, cmd) + if m.app.loading || m.app.notice != "offline" || m.inAccount("acme/local") { + t.Fatal("failed mutation presented as success") + } + m, _ = m.openProduct(productLocation{Page: "settings"}, true) + ctx := m.app.ctx + m, _ = step(t, m, productKey("esc")) + if ctx.Err() == nil { + t.Fatal("Escape did not cancel operation") + } +} + +func TestProductGameVersionSurvivesCatalogReordering(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = m.activateProduct("play", "acme/local") + m.games = nil // discovery changes cannot change an active run's version + f.game.endAfter = 1 + m, _ = tickNow(t, m) + if m.screen != screenGameOver { + t.Fatal("game did not end") + } + pending := m.scores.Pending() + if len(pending) != 1 || pending[0].Version != "1.0.0" { + t.Fatal("run version changed") + } + m = m.closeProductGame() +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 34744ac..2af2de8 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -2,6 +2,7 @@ package shell import ( + "context" "fmt" "strconv" "time" @@ -34,6 +35,7 @@ const ( screenMarket screenAuth screenLibrary + screenProduct ) // route is the full-screen page boundary. Gameplay overlays share one route; @@ -46,6 +48,7 @@ const ( routeMarketplace routeAccount routeLibrary + routeProduct ) func routeFor(s screen) route { @@ -58,6 +61,8 @@ func routeFor(s screen) route { return routeAccount case screenLibrary: return routeLibrary + case screenProduct: + return routeProduct default: return routeGame } @@ -71,20 +76,22 @@ type tickMsg struct { var pauseChoices = []string{"Resume", "Restart", "Quit to Menu"} type Model struct { - screen screen - games []engine.Registration - menuIdx int - pauseIdx int - game *engine.SafeGame - gameIdx int - shape sdk.CellShape - canvas *sdk.Canvas - frame string // cached canvas render, reused under overlays - scores *scores.Store - newHigh bool - notice string // menu-level message, e.g. a game that failed to load - crash string // what the crash screen shows - mp *Marketplace + app productState + gameVersion string + screen screen + games []engine.Registration + menuIdx int + pauseIdx int + game *engine.SafeGame + gameIdx int + shape sdk.CellShape + canvas *sdk.Canvas + frame string // cached canvas render, reused under overlays + scores *scores.Store + newHigh bool + notice string // menu-level message, e.g. a game that failed to load + crash string // what the crash screen shows + mp *Marketplace // latest is the newest published version of each game, by id, as of the // last sync or marketplace load. Empty until one has happened, which is // why nothing claims an update is available before then. @@ -101,14 +108,29 @@ type Model struct { // New builds the arcade model. mp may be nil, which hides the marketplace. func New(games []engine.Registration, st *scores.Store, shape sdk.CellShape, mp *Marketplace) Model { - return Model{games: games, scores: st, shape: shape, mp: mp} + m := Model{games: games, scores: st, shape: shape, mp: mp} + if m.productEnabled() { + m.screen = screenProduct + m.app.loc = productLocation{Page: "marketplace"} + m.app.focus = 1 + m.app.gen = 1 + m.app.loading = true + m.app.snapshot.Installed = games + m.app.ctx, m.app.cancel = context.WithTimeout(context.Background(), 45*time.Second) + } + return m } // Init syncs once at startup, which is where an arcade that was played offline // catches up and where one on a new machine learns what the account already // knows. It runs off the UI loop like every other marketplace call, so a slow // or absent network delays nothing. -func (m Model) Init() tea.Cmd { return m.syncCmd() } +func (m Model) Init() tea.Cmd { + if m.productEnabled() { + return tea.Batch(m.productInit(), m.syncCmd()) + } + return m.syncCmd() +} func (m Model) tick() tea.Cmd { gen := m.tickGen @@ -119,16 +141,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { previousRoute := routeFor(m.screen) next, cmd := m.update(msg) nextModel, ok := next.(Model) - if ok && routeFor(nextModel.screen) != previousRoute { + if ok && (routeFor(nextModel.screen) != previousRoute || (m.productEnabled() && m.app.loc != nextModel.app.loc)) { // Bubble Tea normally repaints incrementally. A hard clear at page // boundaries prevents cells from a larger route showing through a // smaller one in terminals that do not erase blank cells reliably. cmd = tea.Batch(tea.ClearScreen, cmd) } + if ok && m.productEnabled() && m.game != nil && nextModel.game == nil { + cmd = tea.Batch(cmd, saveScores(m.scores), nextModel.syncCmd()) + } return next, cmd } func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.productEnabled() { + if mm, cmd, handled := m.updateProduct(msg); handled { + return mm, cmd + } + } if m.mp != nil { if mm, cmd, handled := m.updateMarketMsg(msg); handled { return mm, cmd @@ -189,7 +219,7 @@ func (m Model) updateTick(msg tickMsg) (tea.Model, tea.Cmd) { // account, which is what carries the same arcade to another // machine. It queues signed out too — signing in later sends it. m.newHigh = m.scores.Record( - m.game.Info().ID, m.game.Score(), m.games[m.gameIdx].Version, true) + m.game.Info().ID, m.game.Score(), m.gameVersion, true) m.screen = screenGameOver // Saved off the tick path, and synced after it; neither may stall // the loop the player is still looking at. @@ -290,6 +320,13 @@ func (m Model) updateKey(key string) (tea.Model, tea.Cmd) { // quitToMenu releases the current game and returns to the menu. func (m Model) quitToMenu() Model { + if m.productEnabled() { + m = m.closeProductGame() + m.app.loc = productLocation{Page: "library"} + m.app.focus = 1 + m.app.gameNav = false + return m + } if m.game != nil { m.game.Close() m.game = nil @@ -321,11 +358,20 @@ func (m Model) updatePausedKey(key string) (tea.Model, tea.Cmd) { } func (m Model) startGame(idx int) (tea.Model, tea.Cmd) { + if idx < 0 || idx >= len(m.games) { + m.notice = "Game is no longer installed" + m.app.notice = m.notice + return m, nil + } if m.games[idx].Err != nil { return m, nil // broken install; listed only so the player sees why } m.gameIdx = idx + m.gameVersion = m.games[idx].Version m.notice = "" + if m.productEnabled() { + m.app.notice = "" + } // A wasm guest sizes its pixel buffer from the shape at init, so a shape // change needs a fresh instance, not just a fresh canvas. sameShape := m.canvas != nil && m.canvas.Shape().Name == m.shape.Name @@ -338,6 +384,10 @@ func (m Model) startGame(idx int) (tea.Model, tea.Cmd) { if err != nil { m.notice = m.games[idx].Info.Title + ": " + err.Error() m.screen = screenMenu + if m.productEnabled() { + m.screen = screenProduct + m.app.notice = m.notice + } return m, nil } m.game = engine.Safe(g) @@ -408,6 +458,10 @@ func (m Model) View() tea.View { if m.termW == 0 { return v } + if m.productEnabled() { + v.SetContent(m.productView()) + return v + } var block string switch m.screen { case screenMenu: diff --git a/market.go b/market.go index debbd68..fd4c00c 100644 --- a/market.go +++ b/market.go @@ -30,6 +30,7 @@ func newMarketplace(rt *plugin.Runtime, st *scores.Store) *shell.Marketplace { } return &shell.Marketplace{ + Product: newProductServices(rt, st), List: func() ([]shell.MarketGame, error) { games, err := anonClient().Games() if err != nil { diff --git a/product_service.go b/product_service.go new file mode 100644 index 0000000..eecc901 --- /dev/null +++ b/product_service.go @@ -0,0 +1,371 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/aviorstudio/termcade/internal/plugin" + "github.com/aviorstudio/termcade/internal/registry" + "github.com/aviorstudio/termcade/internal/scores" + "github.com/aviorstudio/termcade/internal/shell" + "github.com/aviorstudio/termcade/manifest" +) + +type productBackend struct { + store registry.TUIStore + configErr error + runtime *plugin.Runtime + scores *scores.Store + mu sync.Mutex +} + +func newProductServices(rt *plugin.Runtime, st *scores.Store) *shell.ProductServices { + store, err := registry.NewTUIStore() + p := &productBackend{store: store, configErr: err, runtime: rt, scores: st} + return &shell.ProductServices{Request: p.request, Sync: p.sync} +} + +func (p *productBackend) client(ctx context.Context, required bool) (*registry.Client, *registry.Session, error) { + if p.configErr != nil { + return nil, nil, p.configErr + } + s, err := p.store.Load() + if err != nil { + return nil, nil, err + } + if required && s == nil { + return nil, nil, registry.ErrLoginRequired + } + token := "" + if s != nil { + token = s.Token + } + return registry.New(p.store.Registry, token).WithContext(ctx), s, nil +} + +func (p *productBackend) snapshot(ctx context.Context) shell.ProductSnapshot { + var out shell.ProductSnapshot + if dir, err := plugin.GamesDir(); err == nil { + out.Installed = plugin.Games(p.runtime, dir, nil) + } + if p.configErr != nil { + out.AccountError = p.configErr.Error() + return out + } + // Catalog is public: never attach account credentials to public reads. + catalog, err := registry.New(p.store.Registry, "").WithContext(ctx).Catalog() + if err != nil { + out.CatalogError = err.Error() + } else { + out.Catalog = catalog + } + c, s, err := p.client(ctx, false) + if err != nil { + out.AccountError = err.Error() + return out + } + if s == nil { + return out + } + out.SignedIn = true + out.CredentialID = s.CredentialID + out.Account = registry.Me{Email: s.Email, Username: s.Username} + me, err := c.MeContext(ctx) + if err != nil { + out.AccountError = err.Error() + if errors.Is(err, registry.ErrLoginRequired) { + out.SignedIn = false + } + return out + } + out.Account = me + out.Library, err = c.Library() + out.LibraryKnown = err == nil + if err != nil { + out.LibraryError = err.Error() + } + out.Activity, _ = c.Activity() + return out +} + +func gameParts(id string) (string, string, error) { + a, b, ok := strings.Cut(id, "/") + if !ok || a == "" || b == "" || strings.ContainsAny(a+b, `\.`) || strings.Contains(b, "/") { + return "", "", fmt.Errorf("game id must be author/slug") + } + return a, b, nil +} + +func (p *productBackend) request(ctx context.Context, req shell.ProductRequest) (shell.ProductReply, error) { + // Pairing waits without holding the session lock. It returns credentials to + // the model; only an accepted current-generation result may request saving. + if req.Kind == "pair-start" || req.Kind == "pair-wait" { + if p.configErr != nil { + return shell.ProductReply{}, p.configErr + } + if err := p.store.CanPair(); err != nil { + return shell.ProductReply{}, err + } + c := registry.New(p.store.Registry, "").WithContext(ctx) + if req.Kind == "pair-start" { + round, err := c.StartDevice(ctx, "Termcade TUI") + return shell.ProductReply{Round: round, PairingURL: p.store.PairingURL}, err + } + s, err := c.CompleteDevice(ctx, req.Round) + return shell.ProductReply{Session: s}, err + } + if req.Kind == "open-url" { + return shell.ProductReply{}, openProductURL(req.ID) + } + p.mu.Lock() + defer p.mu.Unlock() + if err := ctx.Err(); err != nil { + return shell.ProductReply{}, err + } + if req.Kind == "load" { + snap := p.snapshot(ctx) + return shell.ProductReply{Snapshot: &snap}, nil + } + if p.configErr != nil { + return shell.ProductReply{}, p.configErr + } + var out shell.ProductReply + var err error + public := registry.New(p.store.Registry, "").WithContext(ctx) + switch req.Kind { + case "game": + a, b, e := gameParts(req.ID) + if e != nil { + return out, e + } + g, e := public.Game(a, b) + if e != nil { + return out, e + } + out.Game = &g + out.Days, err = public.PlayDays(a, b, time.Now().UTC().YearDay()) + if err != nil { + out.Notice = "Play activity unavailable: " + err.Error() + err = nil + } + return out, err + case "owner": + o, e := public.Owner(req.ID) + if e != nil { + return out, e + } + out.Owner = &o + out.Days, err = public.PlayDays(req.ID, "", time.Now().UTC().YearDay()) + if err != nil { + out.Notice = "Play activity unavailable: " + err.Error() + err = nil + } + return out, err + case "save-login": + if err = p.store.Save(req.Session); err == nil && ctx.Err() != nil { + _ = p.store.Clear() + err = ctx.Err() + } + case "uninstall": + a, b, e := gameParts(req.ID) + if e != nil { + return out, e + } + err = removeLocal(a, b) + case "logout": + c, s, e := p.client(ctx, false) + if e != nil { + out.Notice = "Local credentials cleared; could not identify remote credential." + } else if s != nil { + if s.CredentialID == "" { + out.Notice = "Signed out locally. Legacy session has no credential ID; revoke it explicitly in session management." + } else if e = c.RevokeCLICredential(s.CredentialID); e != nil { + out.Notice = "Signed out locally; remote revocation failed. Use session management to revoke it." + } + } + err = p.store.Clear() + default: + c, s, e := p.client(ctx, true) + if e != nil { + return out, e + } + switch req.Kind { + case "add", "remove", "install": + a, b, e := gameParts(req.ID) + if e != nil { + return out, e + } + switch req.Kind { + case "add": + err = c.LibraryAdd(a, b) + case "remove": + err = c.LibraryRemove(a, b) + if errors.Is(err, registry.ErrNotFound) { + err = nil + } + case "install": + // Membership is independent of installation. Play may install only + // an account game; local-only play never calls this path. + owned, e := c.Library() + if e != nil { + return out, e + } + found := false + for _, g := range owned { + if g.ID == req.ID { + found = true + } + } + if !found { + return out, fmt.Errorf("add this game to your library first") + } + dir, e := plugin.GamesDir() + if e != nil { + return out, e + } + if _, e = os.Stat(filepath.Join(dir, a, b)); e == nil { + return out, fmt.Errorf("a local copy already exists; refresh Library instead of overwriting it") + } else if !errors.Is(e, os.ErrNotExist) { + return out, e + } + file, e := c.Download(a, b) + if e != nil { + return out, e + } + defer os.Remove(file) + raw, e := os.ReadFile(file) + if e != nil { + return out, e + } + if e = ctx.Err(); e != nil { + return out, e + } + pkg, e := manifest.ReadPackage(raw) + if e != nil { + return out, e + } + if pkg.Manifest.Game.ID != req.ID { + return out, fmt.Errorf("downloaded package identity does not match the selected game") + } + _, _, err = installPackageBytesContext(ctx, raw) + } + case "username-check": + _, taken, e := c.HandleTaken(req.Value) + if e != nil { + return out, e + } + if taken { + out.Notice = "Handle is taken" + } else { + out.Notice = "Handle is available" + } + return out, nil + case "username": + _, err = c.SetUsername(req.Value) + case "org-create": + _, err = c.CreateOrg(req.ID, req.Value, req.Extra) + case "members": + out.Members, err = c.Members(req.ID) + return out, err + case "member-set": + err = c.AddMember(req.ID, req.Value, req.Admin) + case "member-remove": + err = c.RemoveMember(req.ID, req.Value) + case "org-delete": + err = c.DeleteOrg(req.ID) + case "sessions": + out.Credentials, err = c.CLICredentials() + return out, err + case "session-revoke": + err = c.RevokeCLICredential(req.ID) + if err == nil && req.ID == s.CredentialID { + err = p.store.Clear() + out.Notice = "This device was revoked; signed out" + } + case "account-delete": + err = c.DeleteAccount() + if err == nil { + err = p.store.Clear() + } + default: + return out, fmt.Errorf("unsupported TUI action") + } + } + if err != nil { + return out, err + } + if out.Notice == "" { + out.Notice = "Done" + } + snap := p.snapshot(ctx) + out.Snapshot = &snap + return out, nil +} + +func (p *productBackend) sync(ctx context.Context) string { + p.mu.Lock() + defer p.mu.Unlock() + if ctx.Err() != nil { + return "" + } + c, s, err := p.client(ctx, false) + if err != nil || s == nil { + return "" + } + sent, err := flushRuns(c, p.scores) + if err == nil { + err = pullActivity(c, p.scores) + } + if sent > 0 { + p.scores.Save() + } + if errors.Is(err, registry.ErrLoginRequired) { + return "Session expired; sign in from Settings" + } + return syncMessage(sent, err) +} + +// Only explicit UI actions call this. Never execute a shell command string or +// allow file/javascript/custom-scheme URLs supplied by a public profile. +func openProductURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw != "" && !strings.Contains(raw, ":") { + raw = "https://" + raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "https" && u.Scheme != "http") { + return fmt.Errorf("only HTTP(S) links can be opened") + } + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", raw) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", raw) + default: + cmd = exec.Command("xdg-open", raw) + } + if err = cmd.Start(); err != nil { + return fmt.Errorf("could not open a browser; copy the displayed URL instead") + } + finished := make(chan error, 1) + go func() { finished <- cmd.Wait() }() + select { + case err := <-finished: + if err != nil { + return fmt.Errorf("the browser launcher failed; copy the displayed URL instead") + } + return nil + case <-time.After(time.Second): + return nil + } +} diff --git a/product_service_test.go b/product_service_test.go new file mode 100644 index 0000000..90b209d --- /dev/null +++ b/product_service_test.go @@ -0,0 +1,260 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/aviorstudio/termcade/internal/plugin" + "github.com/aviorstudio/termcade/internal/registry" + "github.com/aviorstudio/termcade/internal/scores" + "github.com/aviorstudio/termcade/internal/shell" + "github.com/aviorstudio/termcade/manifest" +) + +type productAPIFixture struct { + mu sync.Mutex + calls []string + member bool + revokeFailure bool +} + +func productBackendFixture(t *testing.T) (*productBackend, *productAPIFixture, string) { + t.Helper() + config := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", config) + t.Setenv("HOME", config) + t.Setenv("AppData", config) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + f := &productAPIFixture{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, r.Method+" "+r.URL.Path) + g := registry.Game{ID: "acme/game", Name: "Game", Version: "1.0.0", ABI: 1, HasPackage: true} + switch r.URL.Path { + case "/v1/games": + if r.Header.Get("Authorization") != "" { + t.Error("catalog received credentials") + } + if r.URL.Query().Get("abi") != "" { + t.Error("web-aligned catalog was filtered") + } + json.NewEncoder(w).Encode(registry.CatalogPage{Games: []registry.Game{g}}) + case "/v1/me": + json.NewEncoder(w).Encode(registry.Me{Email: "local@example.test", Username: "local"}) + case "/v1/library": + games := []registry.Game{} + if f.member { + games = append(games, g) + } + json.NewEncoder(w).Encode(games) + case "/v1/activity": + json.NewEncoder(w).Encode([]registry.Activity{}) + case "/v1/library/acme/game": + if r.Method == http.MethodPut { + f.member = true + } else if r.Method == http.MethodDelete { + f.member = false + } else { + t.Error("unexpected library verb") + } + w.WriteHeader(204) + case "/v1/cli-credentials/current": + if r.Method != http.MethodDelete { + t.Error("revoke verb") + } + if f.revokeFailure { + http.Error(w, `{"message":"unavailable"}`, 503) + } else { + w.WriteHeader(204) + } + default: + t.Error("unexpected product request", r.Method, r.URL.Path) + w.WriteHeader(404) + } + })) + t.Cleanup(srv.Close) + t.Setenv("TERMCADE_REGISTRY", srv.URL) + t.Setenv("TERMCADE_DEV_APP_URL", "http://127.0.0.1:8081") + store, err := registry.NewTUIStore() + if err != nil { + t.Fatal(err) + } + if err = store.Save(registry.Session{Registry: srv.URL, Email: "local@example.test", Token: "tcc_" + strings.Repeat("A", 43), CredentialID: "current"}); err != nil { + t.Fatal(err) + } + st, err := scores.Load() + if err != nil { + t.Fatal(err) + } + rt := plugin.NewRuntime(context.Background()) + t.Cleanup(func() { rt.Close() }) + backend := &productBackend{store: store, runtime: rt, scores: st} + dir, err := plugin.GamesDir() + if err != nil { + t.Fatal(err) + } + return backend, f, filepath.Join(dir, "acme", "game") +} + +func TestTUIAddAndSignInDoNotDownload(t *testing.T) { + p, f, _ := productBackendFixture(t) + for _, req := range []shell.ProductRequest{{Kind: "load"}, {Kind: "add", ID: "acme/game"}} { + reply, err := p.request(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if reply.Snapshot == nil || !reply.Snapshot.SignedIn { + t.Fatal("missing account snapshot") + } + } + f.mu.Lock() + defer f.mu.Unlock() + if !f.member { + t.Fatal("Add did not save membership") + } + for _, call := range f.calls { + if strings.Contains(call, "resolve") || strings.Contains(call, "download") { + t.Fatal("metadata flow downloaded a package") + } + } +} + +func TestTUIRemoveAndUninstallHaveSeparateEffects(t *testing.T) { + p, f, dir := productBackendFixture(t) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, manifest.FileName) + if err := os.WriteFile(file, []byte("broken local fixture"), 0600); err != nil { + t.Fatal(err) + } + f.member = true + if _, err := p.request(context.Background(), shell.ProductRequest{Kind: "remove", ID: "acme/game"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(file); err != nil { + t.Fatal("Remove deleted files", err) + } + f.mu.Lock() + f.member = true + before := len(f.calls) + f.mu.Unlock() + if _, err := p.request(context.Background(), shell.ProductRequest{Kind: "uninstall", ID: "acme/game"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatal("Uninstall left the copy") + } + f.mu.Lock() + defer f.mu.Unlock() + if !f.member { + t.Fatal("Uninstall removed account membership") + } + for _, call := range f.calls[before:] { + if strings.HasPrefix(call, "DELETE /v1/library") { + t.Fatal("Uninstall changed remote membership") + } + } +} + +func TestTUISignOutClearsEvenWhenRevokeFailsAndLegacyNeverGuesses(t *testing.T) { + for _, legacy := range []bool{false, true} { + t.Run(map[bool]string{false: "failed revoke", true: "legacy"}[legacy], func(t *testing.T) { + p, f, _ := productBackendFixture(t) + f.revokeFailure = true + if legacy { + s, _ := p.store.Load() + s.CredentialID = "" + if err := p.store.Save(*s); err != nil { + t.Fatal(err) + } + } + reply, err := p.request(context.Background(), shell.ProductRequest{Kind: "logout"}) + if err != nil { + t.Fatal(err) + } + s, err := p.store.Load() + if err != nil || s != nil { + t.Fatal("local credentials retained") + } + if reply.Notice == "" || reply.Snapshot.SignedIn { + t.Fatal("sign-out failure hidden") + } + f.mu.Lock() + defer f.mu.Unlock() + revokes := 0 + for _, call := range f.calls { + if strings.HasPrefix(call, "DELETE /v1/cli-credentials/") { + revokes++ + } + } + if (!legacy && revokes != 1) || (legacy && revokes != 0) { + t.Fatal("wrong revocation target count", revokes) + } + }) + } +} + +func TestTUIRevokeSelfAndCanceledMutation(t *testing.T) { + p, f, _ := productBackendFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := p.request(ctx, shell.ProductRequest{Kind: "add", ID: "acme/game"}); !errors.Is(err, context.Canceled) { + t.Fatal("canceled operation ran", err) + } + if len(f.calls) != 0 { + t.Fatal("canceled operation contacted API") + } + reply, err := p.request(context.Background(), shell.ProductRequest{Kind: "session-revoke", ID: "current"}) + if err != nil || reply.Snapshot.SignedIn { + t.Fatal("self revoke retained identity", err) + } +} + +func TestTUIInstallDoesNotOverwriteExistingCopy(t *testing.T) { + p, f, dir := productBackendFixture(t) + f.member = true + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + _, err := p.request(context.Background(), shell.ProductRequest{Kind: "install", ID: "acme/game"}) + if err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatal("existing installation would be overwritten", err) + } +} + +func TestUnsafeProductLinksAreRefusedWithoutLaunching(t *testing.T) { + for _, link := range []string{"javascript:alert(1)", "file:///tmp/example", "https://user:pass@example.test", ""} { + if err := openProductURL(link); err == nil { + t.Fatal("unsafe URL accepted") + } + } +} + +func TestCanceledProductInstallCannotStartValidationOrWrite(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, _, err := installPackageBytesContext(ctx, []byte("not a package")); !errors.Is(err, context.Canceled) { + t.Fatal("canceled install proceeded", err) + } +} + +func TestShippingMarketplaceUsesTheProductServicePath(t *testing.T) { + p, f, _ := productBackendFixture(t) + market := newMarketplace(p.runtime, p.scores) + if market.Product == nil || market.Product.Request == nil { + t.Fatal("main still uses legacy menu-only hooks") + } + if len(f.calls) != 0 { + t.Fatal("constructing the TUI performed a network request") + } +}