diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..30f956ae --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + workflow_dispatch: + pull_request: + branches: + - "main" + +# A newer push to the same long-lived ref cancels the in-flight run. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref == 'dev') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4.4.0 + - uses: actions/setup-node@v5 + with: + node-version: "22" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + + test: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref == 'dev') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4.4.0 + - uses: actions/setup-node@v5 + with: + node-version: "22" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm test + + coverage: + # Coverage is collected at the dev-to-main integration boundary or on demand. + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref == 'dev') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4.4.0 + - uses: actions/setup-node@v5 + with: + node-version: "22" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:coverage + - uses: actions/upload-artifact@v7 + with: + name: unit-coverage-${{ github.sha }} + path: coverage/ + if-no-files-found: error + retention-days: 30 + + e2e-full: + # Browser regression belongs only at the long-lived integration boundary. + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref == 'dev') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4.4.0 + - uses: actions/setup-node@v5 + with: + node-version: "22" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: sudo grep -rlZ 'packages.microsoft.com' /etc/apt/sources.list.d/ | sudo xargs -0 -r rm -f + - run: pnpm exec playwright install --with-deps chromium firefox webkit + - run: xvfb-run -a pnpm test:e2e diff --git a/.github/workflows/package-matrix.yml b/.github/workflows/package-matrix.yml new file mode 100644 index 00000000..34fd3513 --- /dev/null +++ b/.github/workflows/package-matrix.yml @@ -0,0 +1,76 @@ +name: Package Matrix + +on: + workflow_dispatch: + pull_request: + branches: + - "main" + paths: + - ".github/workflows/package-matrix.yml" + - "apps/web/**" + - "packages/cli/**" + - "packages/core/**" + - "scripts/build-cli-package.mjs" + - "scripts/package-archive.*" + - "e2e/packaged-cli.test.ts" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "store-specs.json" + +permissions: + contents: read + +concurrency: + group: package-matrix-${{ github.ref }} + cancel-in-progress: true + +jobs: + packaged-cli: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref == 'dev') + name: ${{ matrix.os }} / Node ${{ matrix.node }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + node: "22" + extended: false + - os: macos-latest + node: "22" + extended: false + - os: windows-latest + node: "22" + extended: false + - os: ubuntu-latest + node: "24" + extended: true + + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4.4.0 + - uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Remove broken Microsoft apt source + if: runner.os == 'Linux' + shell: bash + run: sudo grep -rlZ 'packages.microsoft.com' /etc/apt/sources.list.d/ | sudo xargs -0 -r rm -f + - name: Install Chromium on Linux + if: runner.os == 'Linux' + run: pnpm exec playwright install --with-deps chromium chromium-headless-shell + - name: Install Chromium on macOS or Windows + if: runner.os != 'Linux' + run: pnpm exec playwright install chromium chromium-headless-shell + + - name: Verify newer-LTS workspace compatibility + if: matrix.extended + run: pnpm typecheck && pnpm test + + - name: Verify packed install and exact export + run: pnpm test:e2e:package diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 00000000..8954124a --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,46 @@ +name: Release candidate + +on: + workflow_dispatch: + pull_request: + branches: + - "main" + paths: + - ".github/workflows/release-candidate.yml" + - "docs/releasing.md" + - "scripts/build-cli-package.mjs" + - "scripts/check-community.mjs" + - "scripts/check-release-candidate.mjs" + - "scripts/check-spdx.mjs" + - "scripts/check-spdx.test.ts" + - "scripts/release-audit.mjs" + - "scripts/release-audit.test.ts" + - "package.json" + - "pnpm-lock.yaml" + - "apps/web/package.json" + - "packages/core/package.json" + - "packages/cli/package.json" + +permissions: + contents: read + +concurrency: + group: release-candidate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + release-candidate: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref == 'dev') + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4.4.0 + - uses: actions/setup-node@v5 + with: + node-version: "22" + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check:community + - run: pnpm check:spdx + - run: pnpm check:release-candidate diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2794a1cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# OS +.DS_Store +Thumbs.db + +# Dependencies +node_modules/ +.pnpm-store/ + +# Build output / monorepo +dist/ +build/ +out/ +.turbo/ +*.tsbuildinfo + +# Next.js (apps/web) +.next/ +next-env.d.ts +.vercel/ + +# Env & secrets +.env +.env.* +!.env.example + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Testing (Vitest / Playwright) +coverage/ +.vitest/ +playwright-report/ +test-results/ +blob-report/ +.playwright/ + +# Generated screenshot bundles (CLI export output). The unanchored pattern also matches +# source directories of the same name, so re-include those explicitly. +export/ +!packages/core/src/export/ +!apps/web/app/api/export/ + +# Lint / tool caches +.eslintcache +.cache/ +packages/cli/package/ + +# Agent planning artifacts (local, not the curated docs/ contract) +docs/superpowers/ +docs/archive/superpowers/ +.superpowers/ +.worktrees/ +/.codex/config.toml +/.screenshot-studio/ +/apps/web/.screenshot-studio/ + +# E2E scratch (parity harness temp renders) +e2e/.tmp/ +fixtures/slice-2-screenshot/_missing.json + +# Certs / keys +*.pem + +# Editor +.vscode/* +!.vscode/extensions.json +.idea/ +*.swp + +fixtures/slice-3-device-frame/_missing.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3efcbcd9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# AGENTS.md — Screenshot Studio + +Before any task, read and apply the repository-wide workflow rules in +[`RULES.md`](RULES.md). + +## Core contracts + +- `app-store-screenshots.json` is the single persisted project document. The enforced strict schema + in `packages/core/src/model/schema.ts` is the source of truth; inspect it before changing project + fields or fixtures. +- Render/export parity is the primary invariant. The editor and CLI must use the same shared core and + browser renderer; never introduce a second pixel-rendering path. +- Store dimensions come from `store-specs.json`, not duplicated constants. +- Schema widening requires explicit task authorization and coordinated schema, architecture, + renderer, fixture, and parity updates. Do not invent project fields from prose or future ideas. +- Publication is separate from implementation. Follow `docs/releasing.md`; builds and green checks do + not authorize tags, registry publication, GitHub releases, or announcements. + +## Task-based reading + +Read only the smallest relevant set: + +| Task | Read first | +|---|---| +| Schema, renderer, export, or CLI behavior | `docs/architecture.md` and the relevant source/tests | +| Editor/UI behavior | `docs/editor.md` | +| Test selection or CI behavior | `docs/testing.md` | +| Project source, active session, catalog, or request fencing | `docs/architecture.md` and the relevant web source/tests | +| Project locking or concurrent writes | `docs/project-mutation-locking.md` | +| Asset/output publication safety | `docs/safe-file-publication.md` | +| Package or release work | `docs/releasing.md` | +| User-facing setup or documentation | `README.md` | +| Generated screenshot project | that project's `AGENTS.md` | + +## Context discipline + +- Read only task-relevant files and narrow ranges. Never recursively scan or print `.superpowers/`, + `.worktrees/`, `node_modules/`, build output, lockfiles, logs, traces, or session transcripts. +- Keep command output bounded and summarize results. Prefer tracked files, exact paths, and targeted + `rg`; do not dump repository-wide file lists or passing-test logs into context. +- Do not use sub-agents, Browser, Computer Use, live preview, or browser developer mode unless the + user explicitly requests them or the changed behavior cannot be verified without that surface. +- Run exact tests first. Never run the full local E2E suite; use one browser file only when required + by the changed UI/render contract. `docs/testing.md` owns the exact matrix. + +## GitHub authentication boundary + +- Repository setup, development, builds, and tests must not require GitHub credentials. +- Never read, copy, print, modify, or request credential files, access tokens, or private keys. +- Treat sandbox or container `gh` failures as credentials unavailable in that environment, not proof + that the contributor is logged out. Prefer an authorized GitHub connector, then a trusted host + credential broker or a narrowly scoped elevated command. +- If authenticated access remains unavailable, report the blocker and provide the relevant GitHub + URL for manual completion. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8db6d995 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +# CLAUDE.md + +@AGENTS.md +@RULES.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..19a2d1e9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing to Screenshot Studio + +Thanks for helping improve Screenshot Studio. Small, focused changes are easiest to review and the +render/export parity contract takes priority over convenience in any one shell. + +## Set up the repository + +Requirements are Node.js 22.13+, Corepack, and the Playwright Chromium builds used by export tests. + +```bash +git clone https://github.com/VitaliiDeveloper/Screenshot-Studio.git +cd Screenshot-Studio +corepack enable +pnpm install +pnpm exec playwright install chromium chromium-headless-shell +pnpm dev +``` + +Before changing the project, read `AGENTS.md`, `RULES.md`, and only the architecture or tracked +contract section relevant to your change. Keep project JSON inside the enforced schema and keep UI +and CLI pixels on the shared browser renderer. + +## Make a change + +1. Open an issue for behavior that changes the schema, renderer, export, or documented product contract. +2. Keep the patch to one concern and add the smallest practical regression test. +3. Add `SPDX-License-Identifier: Apache-2.0` to new authored `.ts`, `.tsx`, and `.mjs` source files. + Do not add text headers to JSON, media, vendored/generated files, lockfiles, or third-party fonts. +4. Start with the narrow package test that covers the changed behavior. Add or run an exact E2E + file only when that behavior requires a real browser, render host, or packed consumer. Do not run + the full browser suite locally; CI owns `pnpm test:e2e` for the integration PR from `dev` into + `main`. The leaf-group ownership and cost matrix lives in `docs/testing.md`. +5. Update public documentation when the user-visible behavior or install path changes. + +Commit subjects use `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `build:`, `chore:`, `style:`, or +`perf:` followed by a concise imperative description. Do not add agent attribution trailers. + +## License + +The project is licensed under Apache-2.0. Unless you explicitly state otherwise, a contribution +intentionally submitted for inclusion is provided under that same license, as described in +`LICENSE`. Preserve applicable notices and keep third-party license texts with their assets. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..623464a1 --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +Screenshot Studio +Copyright 2026 Vitalii Lavreniuk and Screenshot Studio contributors + +This product is licensed under the Apache License, Version 2.0 +(see the LICENSE file in the repository root). diff --git a/README.md b/README.md new file mode 100644 index 00000000..36b89379 --- /dev/null +++ b/README.md @@ -0,0 +1,378 @@ +# Screenshot Studio + +A code-first, open-source editor for creating App Store, Google Play, and Microsoft Store marketing +screenshots visually, from the CLI, or together with an AI coding agent. + +Screenshot Studio keeps an entire campaign in one git-trackable JSON document. The visual editor and +CLI use the same browser renderer, so the preview you approve is the image you export at the exact +store size. + +The application is local-first, works without an AI provider, and supports reproducible campaigns +across platforms, layouts, exact-size variants, locales, and RTL languages. + +## Why Screenshot Studio + +Store screenshots are rarely a one-image task. A complete campaign must stay visually consistent +across device sizes, platforms, locales, RTL languages, product revisions, and repeated exports. +Traditional design files are convenient to edit but difficult to automate and review in Git. +Code-only generators are reproducible but inconvenient for visual iteration. + +Screenshot Studio combines both workflows: + +- a responsive multi-artboard visual editor; +- one strict, portable, version-controlled project document; +- a CLI for repeatable editing, import, and export; +- exact store-size outputs produced by the same renderer as the editor preview; +- project-local instructions that let an AI agent work against explicit schema and workflow rules; +- no required cloud service, model, API key, or provider-specific project state. + +## Current capabilities + +- **One strict project document.** A complete strict schema v1 JSON file owns shared metadata and + fonts plus ordered independent design layouts, locales, screens, backgrounds, elements, groups, + crop/style literals, output-family policies, and store target variants. +- **One UI/CLI renderer.** Editor previews, exact exports, bundles, Web Share, and the packaged CLI + use the same Konva stage through the Chromium render host. Store PNGs are exact-size and opaque. +- **Visual authoring.** Focus and Overview modes, isolated and connected canvases, multi-selection, + one-level groups, snapping, guides, zoom, inline text editing, history, and schema-bounded + inspectors work across layouts and locales. +- **Complete creative surface.** Text, shapes, screenshots, decorative images, trusted vector icons, + device frames, editable badge groups, solid/gradient/project-image backgrounds, media crop, + shadows, gradients, and strokes share render/export parity. +- **Store and locale workflows.** Apple, Android, Windows, and responsive Web output families, + explicit exact-size variants, synchronized or independent screen slots, complete locale sets, and + baked RTL geometry use the same project model. +- **Reusable local resources.** Built-in starters and Design Packs work alongside project-local + fonts, themes, templates, assets, auto-crop, and flat-background removal. +- **CLI and agent workflows.** The `screenshots` CLI manages layouts, screens, imports, fonts, + themes, locales, local assets, and exports. Generated project-local guidance and the optional + Codex skill operate on the same strict JSON and CLI contract. +- **Safe project lifecycle.** Local projects can be edited in place; browser imports create validated + managed copies. Project archives, rendered exports, Close, Remove from recents, and permanent + deletion remain separate, explicit operations. + +## How Codex and GPT-5.6 were used + +Screenshot Studio was built through an AI-assisted product and engineering workflow using OpenAI +Codex and GPT-5.6. + +These tools were involved in approximately 99% of the project's development activities: from the +initial product idea and planning through repeated product and UI redesigns, architecture, +implementation, testing, refactoring, documentation, and release-readiness work. The percentage +describes the breadth of AI involvement across the development process; it does not mean the product +was generated in one prompt or completed without human direction, review, and acceptance. + +The product vision, priorities, constraints, final decisions, reviews, and acceptance remained the +responsibility of the human author. + +### Collaboration model + +The project used a repeated human–AI development loop rather than a one-shot generation workflow: + +1. The human author defined the desired product outcome, constraints, and acceptance criteria. +2. Codex inspected the existing repository and the smallest relevant architecture, product, schema, + and testing contracts. +3. Codex and GPT-5.6 helped explore alternatives, surface edge cases, and explain trade-offs. +4. The human author selected the direction and authorized the implementation scope. +5. Codex implemented the scoped change and ran verification proportionate to its risk. +6. The human author reviewed behavior, product quality, visual results, and proposed follow-up work. +7. Review findings were fed back into the next focused iteration. + +Repository-level [`AGENTS.md`](AGENTS.md) and [`RULES.md`](RULES.md) files made this collaboration +explicit. They route work to the relevant source of truth, protect the strict schema and shared +renderer, constrain broad or destructive actions, define test ownership, and keep release +publication separate from implementation. + +### Product discovery, strategy, and scope + +Codex and GPT-5.6 helped turn the original idea into a concrete product: + +- break the concept into implementation stages and reviewable milestones; +- identify developers, indie teams, localization workflows, and AI-assisted authoring as target use + cases; +- compare a visual-only editor with code-first and hybrid approaches; +- define local-first operation and a portable project format as product constraints; +- distinguish editable project archives from rendered store-image export; +- prioritize render/export parity over shortcuts that would create a second rendering path; +- refine Screenshot Studio's positioning as both a visual tool and an automation-friendly project + format. + +The human author determined the product vision, which trade-offs to accept, which features to ship, +and when an iteration met the intended outcome. + +### UI/UX design and repeated redesigns + +AI supported many iterations of the application experience, including: + +- project creation, complete project import, screenshot capture import, and first-run onboarding; +- the Projects gallery, recent-project behavior, project details, archive export, and deletion; +- Workspace navigation, responsive toolbars, panels, empty states, and compact layouts; +- Focus and Overview editing modes; +- isolated and connected canvas models; +- layout, output-family, exact-size, locale, screen, layer, and selection interactions; +- accessible dialogs, focus behavior, keyboard controls, destructive confirmations, and status + feedback; +- alternative interface concepts, mockups, visual hierarchy, and product copy. + +Design tools and plugins were used during exploration and implementation to create and inspect +mockups, compare visual directions, and translate selected concepts into implementable UI. Codex +helped turn accepted directions into production React components and focused interaction tests. The +human author compared the alternatives, judged the visual result, and approved the final product +decisions. + +### Architecture and implementation + +Codex assisted with TypeScript, React, and Next.js development across the repository. Important +areas included: + +- the strict Zod schema and the single `app-store-screenshots.json` document; +- independent layouts, connected compositions, exact-size variants, output-family screen-slot + policies, locale cloning, and baked RTL geometry; +- the shared core used by the visual editor and CLI; +- the browser rendering pipeline and render/export parity; +- project-local fonts, themes, templates, Design Packs, icons, frames, and image assets; +- local-file and managed project sources, active-session identity, recent-project history, and + request fencing; +- bounded Folder, ZIP, JSON, and raw screenshot import workflows; +- project mutation locking and recovery across CLI and editor processes; +- safe, collision-resistant asset and output publication; +- the installable CLI package and its standalone shared render host; +- Web Share packaging without introducing a second pixel renderer; +- the optional Screenshot Studio Codex skill and generated project-local agent guide. + +One architectural invariant guided the work throughout: the editor and CLI must use the same shared +core and browser renderer. A feature was not considered complete if preview and export could disagree +about pixels, fonts, crops, geometry, or output size. The durable technical contract is documented in +[`docs/architecture.md`](docs/architecture.md). + +### Testing, investigation, and review + +Codex and GPT-5.6 were used not only to write implementation code but also to challenge it: + +- identify failure modes and edge cases before and after implementation; +- select the smallest meaningful test layer for each change; +- write and maintain unit, integration, render-parity, package, accessibility, and browser tests; +- validate exact output dimensions and opaque store PNGs; +- compare headed editor rendering with headless CLI rendering; +- review local path, symlink, archive, upload, mutation, and publication boundaries; +- diagnose regressions from test output and concrete repository evidence; +- review documentation against the current schema, source code, tests, workflows, and packaged + artifact; +- audit the local release candidate's contents, licenses, runtime dependencies, and portability. + +Tests were chosen according to risk rather than by running every browser scenario for every change. +Exact unit or integration tests own deterministic behavior; focused browser files own workflows that +require a real Next.js host, browser interaction, persistence, or rendering. The complete ownership +matrix is in [`docs/testing.md`](docs/testing.md). + +### Documentation and developer experience + +Codex helped create, review, and maintain: + +- this README and the public project positioning; +- architecture and editor interaction contracts; +- testing, contribution, and release guidance; +- project mutation-locking and safe file-publication documentation; +- repository and project-local agent workflow rules; +- CLI and example-project documentation; +- the generated `AGENTS.md` included in every initialized screenshot project. + +Documentation was treated as part of the product rather than a final summary. It was repeatedly +checked against the live schema, command dispatcher, UI routes, CI workflows, tests, and locally +packed CLI. Detailed contracts live in `docs/` so the main README can explain the product and the +AI-assisted development story without becoming the only source of technical truth. + +### Human responsibility and control + +The human author remained responsible for: + +- the original idea, product goals, priorities, and scope; +- choosing between proposed product, design, and architecture alternatives; +- reviewing visual quality and interaction behavior; +- accepting or rejecting implementation changes; +- deciding when additional verification was required; +- approving all final product and technical decisions; +- authorizing any release or publication action. + +Codex did not independently publish packages, create Git tags or GitHub releases, or broaden a task +beyond its authorized scope. Builds and passing checks never served as publication approval. + +### AI-assisted, not AI-dependent + +AI was central to building Screenshot Studio, but it is not a runtime dependency of the product. +The editor and CLI require no model, provider SDK, API key, or persisted AI state. Projects remain +readable, editable, exportable, and portable without an agent. + +For users who want an AI-assisted workflow, `screenshots init` generates a project-local `AGENTS.md` +that documents the enforced schema, authoring boundaries, supported CLI operations, and verification +recipes. The optional [`skills/screenshot-studio`](skills/screenshot-studio/SKILL.md) Codex skill only +discovers or initializes a project and then delegates to that local contract. This separation lets AI +accelerate authoring without locking the project to one model, provider, or hidden project format. + +## Quick start + +Requirements: Node.js 22.13+, Corepack, and the pinned pnpm 11 toolchain. + +```bash +git clone https://github.com/VitaliiDeveloper/Screenshot-Studio.git && cd Screenshot-Studio +corepack enable +pnpm install +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000), then choose **New project** and a built-in +starter, or use **Import** to create a managed copy of an existing Folder, project ZIP, or complete +`app-store-screenshots.json` project. + +To open the bundled sample directly in place: + +```bash +PROJECT_PATH="$PWD/fixtures/sample/app-store-screenshots.json" pnpm dev +``` + +The sample appears in **Recent projects** and opens directly in Workspace. Editor saves update the +fixture in place, so copy it to a separate directory before experimenting with changes you do not +want to keep. + +Exact PNG export additionally requires the matching Playwright browser builds once per machine: + +```bash +pnpm exec playwright install chromium chromium-headless-shell +pnpm screenshots export --project "$PWD/fixtures/sample/app-store-screenshots.json" +``` + +## How it works + +```mermaid +flowchart LR + P["app-store-screenshots.json"] <--> E["Visual editor"] + P <--> C["CLI or AI agent"] + E --> R["Shared browser renderer"] + C --> R + S["store-specs.json"] --> R + R --> O["Exact store PNGs and bundles"] +``` + +`app-store-screenshots.json` is the single persisted project document. Store dimensions come from +[`store-specs.json`](store-specs.json). The visual editor and CLI both use the framework-agnostic +core and the same browser render stage; Web Share packages those rendered PNGs and never substitutes +another pixel path. + +Projects can be opened explicitly as local files or imported into app-managed storage. A browser +Folder, ZIP, or JSON selection is always treated as import input, validated, and copied before the +managed project becomes active. Detailed source, session, rendering, and persistence contracts are +in [`docs/architecture.md`](docs/architecture.md). + +## CLI workflow + +The monorepo CLI covers the common project lifecycle: + +```bash +# Create a strict project with local agent guidance +pnpm screenshots init --dir ./my-app-screenshots + +# Open its visual editor +pnpm screenshots dev --project ./my-app-screenshots/app-store-screenshots.json + +# Export the preferred layout +pnpm screenshots export --project ./my-app-screenshots/app-store-screenshots.json + +# Import organized PNG captures into a selected layout +pnpm screenshots import ./my-app-screenshots/screenshots \ + --layout "Feature campaign" \ + --project ./my-app-screenshots/app-store-screenshots.json +``` + +Layout-aware mutations require `--layout ` when a project has multiple layouts. +`dev`, `screen`, and `export` accept `--store-specs ` for an explicit store specification. +Bundle export writes locale roots at their device lead target and all explicit persisted variants at +their own targets. A single-screen export resolves explicit `--size`, then its persisted `storeTargetId`, +then its device lead target. `screen rm --cascade` explicitly removes a primary +screen together with paired variants. + +The full command and export reference lives in +[`packages/cli/README.md`](packages/cli/README.md). + +## Project format and resources + +A portable screenshot project commonly has this shape: + +```text +my-app-screenshots/ +├── app-store-screenshots.json # the only persisted project document +├── AGENTS.md # generated schema and workflow guidance +├── assets/ # screenshots, images, fonts, and processed assets +├── screenshots/ # organized PNG captures for import +├── templates/.json # project-local screen templates +├── themes/.json # project-local themes +└── export/ # generated outputs +``` + +Built-in screen starters, Design Packs, frames, icons, and fonts are application resources and do +not become hidden project state. Applying a starter, template, or pack materializes ordinary strict +project fields that remain editable. Project-local resources stay beside the document and can travel +with its portable project archive. + +Four living starter workflows cover isolated, connected, RTL/multi-locale, and capture-import paths. +The self-contained projects under [`examples/`](examples/README.md) are also validated and exported +as part of the repository's release surfaces. + +## Installable CLI candidate + +To build the local installable artifact without publishing it: + +```bash +pnpm build:cli-package +pnpm --dir packages/cli/package pack +pnpm add --global ./packages/cli/package/screenshot-studio-cli-0.1.0.tgz +screenshots init --dir ./my-app-screenshots +``` + +The package contains the built `screenshots` binary, shared store specifications, and prebuilt Next +render host. Version `0.1.0` is an unpublished dry-run candidate; it is not a registry release. +Building or verifying it does not create an npm publication, Git tag, GitHub release, or +announcement. Publication requires explicit owner authorization under +[`docs/releasing.md`](docs/releasing.md). + +## Repository structure + +- `packages/core` — strict model, project operations, renderer mapping, export, resources, and store + specifications. +- `packages/cli` — the `screenshots` command and packaged standalone render host. +- `apps/web` — Projects, Project details, Workspace, API routes, and the shared `/render` target. +- `fixtures` — sample and behavior-specific regression projects. +- `examples` — portable connected and RTL living projects. +- `e2e` — focused Playwright/Vitest workflow and render-parity gates. +- `docs` — durable architecture, interaction, testing, safety, and release contracts. + +## Documentation + +- [Architecture](docs/architecture.md) — model, sources, sessions, rendering, export, and CLI. +- [Editor UI and UX](docs/editor.md) — durable interaction, navigation, and accessibility semantics. +- [Testing](docs/testing.md) — test ownership, focused browser groups, and CI policy. +- [Project mutation locking](docs/project-mutation-locking.md) — concurrent write and recovery contract. +- [Safe file publication](docs/safe-file-publication.md) — asset and output publication boundaries. +- [Releasing](docs/releasing.md) — candidate verification, publication, upgrade, and uninstall. +- [Contributing](CONTRIBUTING.md) and [Security](SECURITY.md) — contributor workflow and private + vulnerability reporting. + +## Development + +```bash +pnpm test # full unit suite +pnpm test:core # core unit tests +pnpm test:cli # CLI unit tests +pnpm test:web # web unit tests +pnpm typecheck # all packages +pnpm lint # repository lint +``` + +Use focused browser files only for behavior that requires a real Next.js host, rendering, user +interaction, or persistence. Do not run the complete `pnpm test:e2e` gate locally; GitHub CI owns it +for the integration PR from `dev` into `main`. See [`docs/testing.md`](docs/testing.md) for the exact +matrix and release-surface commands. + +## License + +Apache-2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE). Contributions are welcome under the same +terms; see [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/RULES.md b/RULES.md new file mode 100644 index 00000000..35f49fe5 --- /dev/null +++ b/RULES.md @@ -0,0 +1,62 @@ +# RULES.md — Agent & Contributor Collaboration Rules + +[AGENTS.md](AGENTS.md) routes agents to the sources relevant to the active task. Keep this file +focused on repository workflow; product and architecture contracts belong in their routed sources +of truth. + +## Decision making + +- Challenge assumptions and surface material constraints or trade-offs before acting. +- Keep review requests read-only. For diagnosis, identify and explain the cause; implement a fix only + when the request includes a change. +- For implementation, make the smallest maintainable change that satisfies the request. Do not pull + unrelated cleanup or speculative future work into scope. +- Do not invoke optional workflows, sub-agents, browser tools, or broad verification unless the task + requires them. Ask only when a missing decision makes a reasonable choice risky. +- Comments should state durable intent, invariants, or non-obvious constraints. Keep temporary + measurements and one-off verification evidence out of source comments. + +## Agent planning artifacts + +- Superpowers specs and implementation plans are local execution artifacts. Keep them under the tool + defaults `docs/superpowers/specs/` and `docs/superpowers/plans/`. +- Do not create tracked `docs/specs/` or `docs/plans/`. Never stage or commit execution-only agent + artifacts; `docs/superpowers/` and `.superpowers/` are ignored. +- Add durable architecture, product contracts, or retained evidence to tracked documentation only + when the task explicitly requires it. + +## Git workflow + +- Use `prefix: concise description` with `feat`, `fix`, `refactor`, `chore`, `test`, `docs`, `style`, + `perf`, or `build`. +- Use imperative English, no trailing period or attribution trailers, and split unrelated changes. +- Resolve the base from the explicit task and live local/remote topology. Never invent a conventional + branch such as `dev` when it does not exist. +- Use `/` for agent branches. If Git has a real slash-namespace conflict, + fall back to `-` and report it. +- Target `main` only for an explicitly requested integration/release PR. PR titles follow the commit + convention; bodies record scope, validation, and risks. + +## Verification + +`docs/testing.md` owns the exact test matrix, leaf groups, and CI policy. + +- Choose the lightest check that can fail for the changed behavior. +- Run exact unit/integration files first; run the affected package suite only when a behavior or + contract changed. +- Use E2E only when the risk requires a real browser, render host, interaction, persistence path, or + packed consumer. Prefer one exact file; generic regression confidence is not a reason to run E2E. +- Never run the full `pnpm test:e2e` suite locally. GitHub CI owns it for the `dev` to `main` + integration PR. If that gate fails, reproduce only the failing file locally and let CI rerun the + complete gate. +- Run cross-browser, package, skill, and example checks only for that surface or authorized release + readiness. A missing test script is a failure, not a pass. +- Bug fixes with a practical seam get a targeted regression test. Render/export changes require an + exact-dimension or visual-diff check as appropriate. +- Docs-only changes may skip runtime tests; report exactly what was and was not verified. Surface all + failures instead of hiding them. + +## Stop conditions + +Stop and request direction when the safe solution requires broader authorization, materially changes +the requested scope, weakens the strict schema, or risks render/export parity, security, or data loss. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..de20a349 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security policy + +## Supported code + +No package has been published yet. Security fixes are prepared only for the current code on `main`. + +| Code | Supported | +|------|-----------| +| Current `main` branch | Yes | +| Earlier commits, feature branches, and unpublished artifacts | No | + +After the first public release, this policy will identify the supported release lines. + +## Report a vulnerability + +Use GitHub's private +[security advisory form](https://github.com/VitaliiDeveloper/Screenshot-Studio/security/advisories/new). +Do not include exploit details, secrets, personal data, or a proof of concept in a public issue. +If the private form is unavailable, open a minimal public issue asking the maintainer for a private +contact route without disclosing the vulnerability. + +Include the affected commit, operating system and Node version, impact, reproduction steps, and any +safe mitigation you know. Reports are triaged privately; response, remediation, and coordinated +disclosure timing depend on impact and reproducibility. + +## Scope + +Reports about project-file path traversal, packaged-host extraction, local asset access, generated +output integrity, dependency or archive substitution, and renderer process isolation are in scope. +Ordinary product bugs without a confidentiality, integrity, or availability impact belong in the +public issue tracker. diff --git a/apps/web/app/api/asset/route.test.ts b/apps/web/app/api/asset/route.test.ts new file mode 100644 index 00000000..39bf6304 --- /dev/null +++ b/apps/web/app/api/asset/route.test.ts @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { writeFileSync, mkdtempSync, rmSync, mkdirSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + PROJECT_SESSION_COOKIE, + projectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { GET } from "./route.js"; + +let dir: string; +let projectFile: string; +let outsideDir: string; + +function projectJson(name = "Asset"): string { + return JSON.stringify({ + meta: { name, schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ + id: "primary", + name: "Primary", + mode: "isolated", + screens: [{ + id: "hero", name: "Hero", platform: "apple", device: "iphone", locale: "en", + artboard: { width: 660, height: 1434 }, + background: { type: "solid", color: "#000000" }, + elements: [], + }], + }], + }); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "asset-")); + projectFile = join(dir, "app-store-screenshots.json"); + writeFileSync(projectFile, projectJson()); + mkdirSync(join(dir, "assets"), { recursive: true }); + writeFileSync(join(dir, "assets", "shot.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + mkdirSync(join(dir, "assets", "fonts"), { recursive: true }); + for (const ext of ["ttf", "otf", "woff", "woff2"]) { + writeFileSync(join(dir, "assets", "fonts", `demo.${ext}`), Buffer.from([0x00, 0x01])); + } + process.env.PROJECT_PATH = projectFile; + outsideDir = mkdtempSync(join(tmpdir(), "asset-outside-")); +}); + +afterEach(() => { + delete process.env.PROJECT_PATH; + rmSync(dir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); +}); + +function cookieFor(sessionId: string): string { + return `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(sessionId)}`; +} + +function scopedRequest( + projectId: string, + sessionId: string, + path: string | null, + revision = '"revision-1"', +): Request { + const query = new URLSearchParams({ projectId, revision }); + if (path !== null) query.set("path", path); + return new Request(`http://x/api/asset?${query}`, { + headers: { cookie: cookieFor(sessionId) }, + }); +} + +async function activeRequest(path: string | null): Promise { + const session = await projectSessionRegistry.openLocal(projectFile); + return scopedRequest(session.projectId, session.descriptor.sessionId, path); +} + +describe("GET /api/asset", () => { + it("resolves assets through each request's active project session", async () => { + const first = await projectSessionRegistry.openLocal(projectFile); + const secondDir = mkdtempSync(join(tmpdir(), "asset-second-")); + const secondProject = join(secondDir, "app-store-screenshots.json"); + mkdirSync(join(secondDir, "assets"), { recursive: true }); + writeFileSync(secondProject, projectJson("Second")); + writeFileSync(join(secondDir, "assets", "shot.png"), Buffer.from([0x42])); + + try { + const firstResponse = await GET(scopedRequest( + first.projectId, + first.descriptor.sessionId, + "assets/shot.png", + )); + const second = await projectSessionRegistry.openLocal(secondProject); + const secondResponse = await GET(scopedRequest( + second.projectId, + second.descriptor.sessionId, + "assets/shot.png", + )); + + expect(new Uint8Array(await firstResponse.arrayBuffer())[0]).toBe(0x89); + expect(new Uint8Array(await secondResponse.arrayBuffer())[0]).toBe(0x42); + } finally { + rmSync(secondDir, { recursive: true, force: true }); + } + }); + + it("rejects an old project-scoped URL instead of serving the active project's bytes", async () => { + const first = await projectSessionRegistry.openLocal(projectFile); + const secondDir = mkdtempSync(join(tmpdir(), "asset-second-")); + const secondProject = join(secondDir, "app-store-screenshots.json"); + mkdirSync(join(secondDir, "assets"), { recursive: true }); + writeFileSync(secondProject, projectJson("Second")); + writeFileSync(join(secondDir, "assets", "shot.png"), Buffer.from([0x42])); + const second = await projectSessionRegistry.openLocal(secondProject); + + try { + const response = await GET(scopedRequest( + first.projectId, + second.descriptor.sessionId, + "assets/shot.png", + )); + + expect(response.status).toBe(409); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(new Uint8Array(await response.arrayBuffer())[0]).not.toBe(0x42); + } finally { + rmSync(secondDir, { recursive: true, force: true }); + } + }); + + it("returns 409 without a selected or configured project", async () => { + delete process.env.PROJECT_PATH; + + expect((await GET(new Request("http://x/api/asset?path=assets/shot.png"))).status).toBe(409); + }); + + it("keeps the explicit project override for headless rendering", async () => { + delete process.env.PROJECT_PATH; + + const response = await GET(new Request( + `http://x/api/asset?project=${encodeURIComponent(projectFile)}&path=assets/shot.png`, + )); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(new Uint8Array(await response.arrayBuffer())[0]).toBe(0x89); + }); + + it("serves a scoped file under the project dir with private no-store semantics", async () => { + const res = await GET(await activeRequest("assets/shot.png")); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("image/png"); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + expect(res.headers.get("vary")).toContain("Cookie"); + expect(new Uint8Array(await res.arrayBuffer())[0]).toBe(0x89); + }); + + it("rejects an unscoped interactive URL", async () => { + const session = await projectSessionRegistry.openLocal(projectFile); + const response = await GET(new Request("http://x/api/asset?path=assets/shot.png", { + headers: { cookie: cookieFor(session.descriptor.sessionId) }, + })); + + expect(response.status).toBe(400); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + }); + + it.each([ + ["unquoted", "revision-1"], + ["weak", 'W/"revision-1"'], + ["control character", '"revision\n1"'], + ])("rejects a %s interactive revision", async (_kind, revision) => { + const session = await projectSessionRegistry.openLocal(projectFile); + const response = await GET(scopedRequest( + session.projectId, + session.descriptor.sessionId, + "assets/shot.png", + revision, + )); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "revision must be one strong quoted ETag", + }); + }); + + it("rejects an empty interactive revision", async () => { + const session = await projectSessionRegistry.openLocal(projectFile); + const response = await GET(scopedRequest( + session.projectId, + session.descriptor.sessionId, + "assets/shot.png", + "", + )); + + expect(response.status).toBe(400); + }); + + it("400s on a missing path param", async () => { + expect((await GET(await activeRequest(null))).status).toBe(400); + }); + + it("rejects path traversal outside the base dir", async () => { + expect((await GET(await activeRequest("../../etc/passwd"))).status).toBe(400); + }); + + it("rejects a symlink escape outside the project", async () => { + const outside = join(outsideDir, "outside.png"); + writeFileSync(outside, "outside"); + symlinkSync(outside, join(dir, "assets", "escape.png")); + expect((await GET(await activeRequest("assets/escape.png"))).status).toBe(400); + }); + + it("404s a nonexistent file", async () => { + expect((await GET(await activeRequest("assets/nope.png"))).status).toBe(404); + }); + + it("serves font files with font content types", async () => { + expect((await GET(await activeRequest("assets/fonts/demo.ttf"))).headers.get("content-type")).toContain("font/ttf"); + expect((await GET(await activeRequest("assets/fonts/demo.otf"))).headers.get("content-type")).toContain("font/otf"); + expect((await GET(await activeRequest("assets/fonts/demo.woff"))).headers.get("content-type")).toContain("font/woff"); + expect((await GET(await activeRequest("assets/fonts/demo.woff2"))).headers.get("content-type")).toContain("font/woff2"); + }); +}); diff --git a/apps/web/app/api/asset/route.ts b/apps/web/app/api/asset/route.ts new file mode 100644 index 00000000..fc8d604a --- /dev/null +++ b/apps/web/app/api/asset/route.ts @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +import { isAbsolute } from "node:path"; +import { isStrongQuotedETag } from "@web/lib/http-etag.js"; +import { withManagedProjectReadGuard } from "@web/lib/managed-project-mutation-guard.server.js"; +import { + serveProjectAssetPath, + withPrivateAssetPolicy, +} from "@web/lib/project-asset.server.js"; +import { withActiveProjectSession } from "@web/lib/project-session.server.js"; + +/** + * Serves the project's asset files over HTTP so /editor and the headless /render resolve the SAME + * URL for a given screenshot assetUrl — parity by construction, and same-origin so images never + * taint the canvas. Dev-only, local, self-hosted (same model as /render, /api/project). + * `project` (optional) lets the headless render pass its own project path; interactive requests use + * the active project session. + */ +export const dynamic = "force-dynamic"; + +export async function GET(req: Request): Promise { + const url = new URL(req.url); + const rel = url.searchParams.get("path"); + const explicitProjectPath = url.searchParams.get("project"); + const interactive = !explicitProjectPath; + const finish = (response: Response) => withPrivateAssetPolicy(response, interactive); + if (!rel) return finish(Response.json({ error: "missing path" }, { status: 400 })); + + if (isAbsolute(rel)) { + return finish(Response.json({ error: "path outside project" }, { status: 400 })); + } + + if (explicitProjectPath) return finish(await serveProjectAssetPath(explicitProjectPath, rel)); + const requestedProjectId = url.searchParams.get("projectId")?.trim(); + const resourceRevision = url.searchParams.get("revision")?.trim(); + const response = await withActiveProjectSession(req, (session) => { + const { projectId, projectPath } = session; + if (!requestedProjectId || !resourceRevision) { + return Promise.resolve(Response.json( + { error: "projectId and revision are required for interactive assets" }, + { status: 400 }, + )); + } + if (!isStrongQuotedETag(resourceRevision)) { + return Promise.resolve(Response.json( + { error: "revision must be one strong quoted ETag" }, + { status: 400 }, + )); + } + if (requestedProjectId !== projectId) { + return Promise.resolve(Response.json( + { error: "Asset URL belongs to a different active project" }, + { status: 409 }, + )); + } + return withManagedProjectReadGuard(session, () => serveProjectAssetPath(projectPath, rel)); + }); + return finish(response); +} diff --git a/apps/web/app/api/assets/handler.ts b/apps/web/app/api/assets/handler.ts new file mode 100644 index 00000000..671575a8 --- /dev/null +++ b/apps/web/app/api/assets/handler.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + importProjectImageMutation, + MAX_PROJECT_IMAGE_BYTES, + ProjectImageValidationError, + type ProjectImageServiceDependencies, +} from "@screenshot-studio/cli/project-image-service"; +import { projectRevisionConflictResponse } from "@web/lib/project-request-fence.server.js"; + +type UploadFile = { + name: string; + size: number; + arrayBuffer: () => Promise; +}; + +function isUploadFile(value: FormDataEntryValue | null): value is File & UploadFile { + return typeof value === "object" + && value !== null + && "name" in value + && "size" in value + && "arrayBuffer" in value; +} + +function errorResponse(error: unknown): Response { + const revisionConflict = projectRevisionConflictResponse(error); + if (revisionConflict) return revisionConflict; + const message = error instanceof Error ? error.message : String(error); + return Response.json({ error: message }, { + status: error instanceof ProjectImageValidationError ? 400 : 500, + }); +} + +export async function handleAssetPost( + projectPath: string, + req: Request, + dependencies: ProjectImageServiceDependencies = {}, + expectedRevision?: string, +): Promise { + try { + const form = await req.formData(); + const file = form.get("file"); + if (!isUploadFile(file)) throw new ProjectImageValidationError("Missing image file"); + if (file.size === 0) throw new ProjectImageValidationError("Image file must not be empty"); + if (file.size > MAX_PROJECT_IMAGE_BYTES) { + throw new ProjectImageValidationError("Image file must be 25 MB or smaller"); + } + + const result = await importProjectImageMutation(projectPath, { + filename: file.name, + bytes: new Uint8Array(await file.arrayBuffer()), + }, dependencies, { expectedRevision }); + + return Response.json({ asset: result.asset }, { + status: 201, + headers: { etag: `"${result.revision}"` }, + }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/apps/web/app/api/assets/route.test.ts b/apps/web/app/api/assets/route.test.ts new file mode 100644 index 00000000..98de94e8 --- /dev/null +++ b/apps/web/app/api/assets/route.test.ts @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { projectRevision } from "@screenshot-studio/core/storage"; +import { + PROJECT_SESSION_COOKIE, + projectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { handleAssetPost } from "./handler.js"; +import { GET, POST } from "./route.js"; + +const validProject = { + meta: { name: "Assets", schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ + id: "primary", + name: "Primary", + mode: "isolated", + screens: [{ + id: "hero", name: "Hero", platform: "apple", device: "iphone", locale: "en", + artboard: { width: 660, height: 1434 }, + background: { type: "solid", color: "#111111" }, elements: [], + }], + }], +}; + +// The first successful import performs a real Sharp decode. Native decoder startup can exceed +// Vitest's 5 s default on a cold, contended CI runner even though warm local runs take about 1 s. +const COLD_IMAGE_DECODE_TIMEOUT_MS = 15_000; + +function projectJson(name = "Assets"): string { + return JSON.stringify({ + ...validProject, + meta: { ...validProject.meta, name }, + }); +} + +let directory: string; +let projectPath: string; +let imageBytes: Buffer; +let sessionId: string | undefined; +let sessionProjectId: string | undefined; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "asset-route-")); + projectPath = join(directory, "app-store-screenshots.json"); + process.env.PROJECT_PATH = projectPath; + await writeFile(projectPath, projectJson()); + await mkdir(join(directory, "assets"), { recursive: true }); + await writeFile(join(directory, "assets", "shot.png"), "image"); + imageBytes = await readFile(new URL("../../../../../fixtures/sample/assets/demo-logistics-app.png", import.meta.url)); +}); + +afterEach(async () => { + if (sessionId && sessionProjectId) projectSessionRegistry.close(sessionId, sessionProjectId); + sessionId = undefined; + sessionProjectId = undefined; + delete process.env.PROJECT_PATH; + await rm(directory, { recursive: true, force: true }); +}); + +function uploadRequest(name = "Finder capture.png", bytes: Uint8Array = imageBytes): Request { + const form = new FormData(); + form.set("file", new Blob([Uint8Array.from(bytes).buffer], { type: "image/png" }), name); + return new Request("http://localhost/api/assets", { method: "POST", body: form }); +} + +describe("/api/assets", () => { + it("imports a valid local image into project-owned assets without changing project JSON", async () => { + const before = await readFile(projectPath); + const response = await handleAssetPost( + projectPath, + uploadRequest(), + {}, + projectRevision(before), + ); + + expect(response.status).toBe(201); + expect(response.headers.get("etag")).toBe(`"${projectRevision(before)}"`); + expect(await response.json()).toEqual({ + asset: { + path: "assets/imported/Finder-capture.png", + name: "Finder-capture.png", + source: "assets", + }, + }); + expect(await readFile(join(directory, "assets/imported/Finder-capture.png"))).toEqual(imageBytes); + expect(await readFile(projectPath)).toEqual(before); + }, COLD_IMAGE_DECODE_TIMEOUT_MS); + + it("rejects undecodable bytes and a stale project revision without publishing", async () => { + const invalid = await handleAssetPost( + projectPath, + uploadRequest("invalid.png", new TextEncoder().encode("not an image")), + ); + expect(invalid.status).toBe(400); + + const stale = await handleAssetPost( + projectPath, + uploadRequest("stale.png"), + {}, + "stale-revision", + ); + expect(stale.status).toBe(412); + await expect(readFile(join(directory, "assets/imported/invalid.png"))) + .rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(directory, "assets/imported/stale.png"))) + .rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("fences upload and listing to the cookie-selected project session", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + sessionId = session.descriptor.sessionId; + sessionProjectId = session.projectId; + process.env.PROJECT_PATH = join(directory, "configured-missing.json"); + const cookie = `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(session.descriptor.sessionId)}`; + const upload = uploadRequest(); + + const imported = await POST(new Request(upload.url, { + method: "POST", + headers: { + ...Object.fromEntries(upload.headers), + cookie, + "if-match": `"${projectRevision(await readFile(projectPath))}"`, + "x-project-id": session.projectId, + }, + body: await upload.arrayBuffer(), + })); + expect(imported.status).toBe(201); + + const listed = await GET(new Request("http://localhost/api/assets", { + headers: { cookie, "x-project-id": session.projectId }, + })); + expect(listed.status).toBe(200); + expect((await listed.json()).assets).toContainEqual({ + path: "assets/imported/Finder-capture.png", + name: "Finder-capture.png", + source: "assets", + }); + }); +}); + +describe("GET /api/assets", () => { + it("rejects a stale workspace identity after the session cookie changes", async () => { + const staleWorkspace = await projectSessionRegistry.openLocal(projectPath); + const activeDirectory = await mkdtemp(join(tmpdir(), "assets-route-active-")); + const activeProjectPath = join(activeDirectory, "app-store-screenshots.json"); + await writeFile(activeProjectPath, projectJson("Active")); + await mkdir(join(activeDirectory, "assets"), { recursive: true }); + await writeFile(join(activeDirectory, "assets", "active.png"), "image"); + + try { + const activeSession = await projectSessionRegistry.openLocal(activeProjectPath); + const response = await GET(new Request("http://x/api/assets", { + headers: { + cookie: `${PROJECT_SESSION_COOKIE}=${activeSession.descriptor.sessionId}`, + "x-project-id": staleWorkspace.projectId, + }, + })); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ error: expect.stringMatching(/changed/i) }); + } finally { + await rm(activeDirectory, { recursive: true, force: true }); + } + }); + + it("requires the loaded workspace identity when a project session exists", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + + const response = await GET(new Request("http://x/api/assets", { + headers: { cookie: `${PROJECT_SESSION_COOKIE}=${session.descriptor.sessionId}` }, + })); + + expect(response.status).toBe(428); + }); + + it("lists assets through each request's active project session", async () => { + const first = await projectSessionRegistry.openLocal(projectPath); + const secondDirectory = await mkdtemp(join(tmpdir(), "assets-route-second-")); + const secondProjectPath = join(secondDirectory, "app-store-screenshots.json"); + await writeFile(secondProjectPath, projectJson("Second")); + await mkdir(join(secondDirectory, "assets"), { recursive: true }); + await writeFile(join(secondDirectory, "assets", "second.png"), "image"); + + try { + const firstResponse = await GET(new Request("http://x/api/assets", { + headers: { + cookie: `${PROJECT_SESSION_COOKIE}=${first.descriptor.sessionId}`, + "x-project-id": first.projectId, + }, + })); + const second = await projectSessionRegistry.openLocal(secondProjectPath); + const secondResponse = await GET(new Request("http://x/api/assets", { + headers: { + cookie: `${PROJECT_SESSION_COOKIE}=${second.descriptor.sessionId}`, + "x-project-id": second.projectId, + }, + })); + + expect(await firstResponse.json()).toEqual({ + assets: [{ path: "assets/shot.png", name: "shot.png", source: "assets" }], + }); + expect(await secondResponse.json()).toEqual({ + assets: [{ path: "assets/second.png", name: "second.png", source: "assets" }], + }); + } finally { + await rm(secondDirectory, { recursive: true, force: true }); + } + }); + + it("returns 409 without a selected or configured project", async () => { + delete process.env.PROJECT_PATH; + + expect((await GET(new Request("http://x/api/assets"))).status).toBe(409); + }); + + it("returns safe project image descriptors", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + const response = await GET(new Request("http://x/api/assets", { + headers: { "x-project-id": session.projectId }, + })); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + assets: [{ path: "assets/shot.png", name: "shot.png", source: "assets" }], + }); + }); +}); diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts new file mode 100644 index 00000000..1c48d8aa --- /dev/null +++ b/apps/web/app/api/assets/route.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +import { listProjectImageAssets } from "@web/lib/project-assets.server.js"; +import { + withProjectMutationRequest, + withProjectReadRequest, +} from "@web/lib/project-request-fence.server.js"; +import { handleAssetPost } from "./handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(req: Request): Promise { + return withProjectReadRequest(req, async ({ projectPath }) => { + try { + return Response.json({ assets: await listProjectImageAssets(projectPath) }); + } catch (error) { + return Response.json( + { error: (error as Error).message || "Failed to list project assets" }, + { status: 500 }, + ); + } + }); +} + +export async function POST(req: Request): Promise { + return withProjectMutationRequest( + req, + ({ projectPath }, expectedRevision) => ( + handleAssetPost(projectPath, req, {}, expectedRevision) + ), + ); +} diff --git a/apps/web/app/api/capture-import/apply/route.test.ts b/apps/web/app/api/capture-import/apply/route.test.ts new file mode 100644 index 00000000..2f1818c9 --- /dev/null +++ b/apps/web/app/api/capture-import/apply/route.test.ts @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: Apache-2.0 +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseStoreSpecs } from "@screenshot-studio/core/export"; +import { type Project } from "@screenshot-studio/core/model"; +import { projectRevision, serializeProject } from "@screenshot-studio/core/storage"; +import { + applyCaptureImportToProject, + SafeFileConflictError, +} from "@screenshot-studio/cli/capture-import-transaction"; +import { resolveManagedProjectStorageConfig } from "@web/lib/managed-project-config.server.js"; +import { ManagedProjectCatalogStore } from "@web/lib/managed-project-catalog.server.js"; +import { ManagedProjectCreationService } from "@web/lib/managed-project-creation.server.js"; +import { ManagedProjectRepository } from "@web/lib/managed-project-repository.server.js"; +import { ManagedProjectSourceAdapter, ProjectSessionRegistry } from "@web/lib/project-session.server.js"; +import { + handleCaptureImportApplyPost, + handleCaptureImportPreviewPost, +} from "@web/app/api/capture-import/handler.js"; +import { POST } from "./route.js"; + +let root: string; +let projectPath: string; +let revision: string; +let repository: ManagedProjectRepository; +let creationService: ManagedProjectCreationService; +let registry: ProjectSessionRegistry; +let sessionId: string; +const projectId = "project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const png = new Uint8Array(await readFile(new URL( + "../../../../../../fixtures/regressions/screenshot-parity/assets/shot.png", + import.meta.url, +))); +const specs = parseStoreSpecs({ + apple: { iphone: [{ id: "lead", label: "Apple lead", width: 1320, height: 2868, orientation: "portrait" }] }, + android: { phone: [{ id: "lead", label: "Android lead", width: 1080, height: 1920, orientation: "portrait" }] }, + windows: { desktop: [{ id: "lead", label: "Windows lead", width: 1920, height: 1080, orientation: "landscape" }] }, +}); + +function currentProject(): Project { + return { + meta: { name: "Current receiver", schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "default", + layouts: [{ + id: "default", name: "Primary", mode: "isolated", screens: [{ + id: "hero", name: "Hero", platform: "apple", device: "iphone", locale: "en", + direction: "ltr", artboard: { width: 1320, height: 2868 }, + background: { type: "solid", color: "#101827" }, elements: [], + }], + }], + }; +} + +function request(input: { + destinationKind: "current-project" | "new-project"; + confirmationFingerprint?: string; + revision?: string; + signal?: AbortSignal; +}): Request { + const form = new FormData(); + form.set("destinationKind", input.destinationKind); + if (input.destinationKind === "current-project") form.set("layoutId", "default"); + else { + form.set("name", "Imported capture project"); + form.set("platform", "apple"); + form.set("layoutMode", "connected"); + form.set("templateId", ""); + } + form.append("paths", "screenshots/apple/iphone/en/01.png"); + form.append("files", new Blob([png]), "01.png"); + if (input.confirmationFingerprint) { + form.set("confirmationFingerprint", input.confirmationFingerprint); + } + return new Request("http://localhost/api/capture-import/apply", { + method: "POST", + headers: input.destinationKind === "current-project" ? { + cookie: `screenshot_studio_project_session=${sessionId}`, + "x-project-id": projectId, + "if-match": `"${input.revision ?? revision}"`, + } : undefined, + body: form, + signal: input.signal, + }); +} + +async function preview(kind: "current-project" | "new-project") { + const response = await handleCaptureImportPreviewPost(request({ destinationKind: kind }), { + repository, creationService, registry, + }); + expect(response.status).toBe(200); + return (await response.json() as { preview: { confirmationFingerprint: string } }).preview; +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "capture-apply-route-")); + const config = resolveManagedProjectStorageConfig({ + SCREENSHOT_STUDIO_DATA_ROOT: join(root, "managed"), + SCREENSHOT_STUDIO_MAX_PROJECT_FILES: "20", + SCREENSHOT_STUDIO_MAX_PROJECT_BYTES: "10000000", + SCREENSHOT_STUDIO_MAX_IMPORT_UPLOAD_BYTES: "10000000", + }); + repository = new ManagedProjectRepository(config); + await repository.initialize(); + const projectDirectory = join(config.projectsRoot, projectId); + await mkdir(projectDirectory); + projectPath = join(projectDirectory, "app-store-screenshots.json"); + const bytes = serializeProject(currentProject()); + await writeFile(projectPath, bytes); + revision = projectRevision(bytes); + const timestamp = "2026-07-19T00:00:00.000Z"; + await new ManagedProjectCatalogStore(config).insert({ + id: projectId, + directoryName: projectId, + name: "Current receiver", + lifecycle: "active", + lifecycleRevision: 1, + createdAt: timestamp, + updatedAt: timestamp, + }); + creationService = new ManagedProjectCreationService(repository, { storeSpecs: specs }); + let index = 0; + registry = new ProjectSessionRegistry({ + createSessionId: () => `capture-session-${index++}`, + managedSource: new ManagedProjectSourceAdapter(repository), + }); + sessionId = registry.openManagedRecord({ + id: projectId, + name: "Current receiver", + projectDirectory, + projectPath, + lifecycleRevision: 1, + }).descriptor.sessionId; +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("POST /api/capture-import/apply", () => { + it("applies the confirmed current-project plan under identity and revision fences", async () => { + const plan = await preview("current-project"); + const response = await handleCaptureImportApplyPost(request({ + destinationKind: "current-project", + confirmationFingerprint: plan.confirmationFingerprint, + }), { repository, creationService, registry }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get("etag")).not.toBe(`"${revision}"`); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(body).toMatchObject({ + project: { sessionId, name: "Current receiver" }, + imported: 1, + receiving: { kind: "current-project", layoutId: "default" }, + }); + const persisted = JSON.parse(await readFile(projectPath, "utf8")) as Project; + expect(persisted.layouts[0]!.screens.map(({ id }) => id)).toEqual([ + "hero", "apple-iphone-en-01", + ]); + }); + + it("publishes and activates only the confirmed new managed destination", async () => { + const recordRecent = vi.fn(async () => undefined); + const previous = registry.get(sessionId); + const plan = await preview("new-project"); + const response = await handleCaptureImportApplyPost(request({ + destinationKind: "new-project", + confirmationFingerprint: plan.confirmationFingerprint, + }), { repository, creationService, registry, recentProjects: { record: recordRecent } }); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(response.headers.get("set-cookie")).toContain("screenshot_studio_project_session=capture-session-1"); + expect(response.headers.get("x-project-id")).toMatch(/^project_[0-9a-f]{32}$/u); + expect(body).toMatchObject({ + project: { + sessionId: "capture-session-1", + name: "Imported capture project", + sourceKind: "managed-project", + }, + imported: 1, + }); + expect(registry.get(sessionId)).toBe(previous); + await expect(repository.list()).resolves.toHaveLength(2); + expect(recordRecent).toHaveBeenCalledOnce(); + expect(recordRecent).toHaveBeenCalledWith(expect.objectContaining({ + descriptor: expect.objectContaining({ name: "Imported capture project" }), + })); + }); + + it("rejects missing or changed confirmation with no project, catalog, session, or resource writes", async () => { + const before = await readFile(projectPath); + const missing = await handleCaptureImportApplyPost(request({ + destinationKind: "current-project", + }), { repository, creationService, registry }); + const currentChanged = await handleCaptureImportApplyPost(request({ + destinationKind: "current-project", confirmationFingerprint: "0".repeat(64), + }), { repository, creationService, registry }); + const newChanged = await handleCaptureImportApplyPost(request({ + destinationKind: "new-project", confirmationFingerprint: "0".repeat(64), + }), { repository, creationService, registry }); + + expect(missing.status).toBe(400); + expect(currentChanged.status).toBe(409); + expect(newChanged.status).toBe(409); + expect(currentChanged.headers.get("set-cookie")).toBeNull(); + expect(newChanged.headers.get("set-cookie")).toBeNull(); + expect(await readFile(projectPath)).toEqual(before); + await expect(readdir(join(projectPath, "..", "assets"))).rejects.toMatchObject({ code: "ENOENT" }); + await expect(repository.list()).resolves.toHaveLength(1); + expect(registry.get("capture-session-1")).toBeUndefined(); + }); + + it("rejects a stale current revision and cancellation without one-shot artifacts", async () => { + const plan = await preview("current-project"); + const stale = await handleCaptureImportApplyPost(request({ + destinationKind: "current-project", + confirmationFingerprint: plan.confirmationFingerprint, + revision: "0".repeat(64), + }), { repository, creationService, registry }); + const controller = new AbortController(); + controller.abort(); + const cancelled = await handleCaptureImportApplyPost(request({ + destinationKind: "new-project", + confirmationFingerprint: "0".repeat(64), + signal: controller.signal, + }), { repository, creationService, registry }); + + expect(stale.status).toBe(412); + expect(cancelled.status).toBe(499); + await expect(repository.list()).resolves.toHaveLength(1); + }); + + it("maps cancellation during confirmed scaffold planning and current transaction work to 499", async () => { + const newPlan = await preview("new-project"); + const newController = new AbortController(); + const abortingCreationService = new ManagedProjectCreationService(repository, { + loadStoreSpecs: async () => { + newController.abort(); + return specs; + }, + }); + const cancelledNew = await handleCaptureImportApplyPost(request({ + destinationKind: "new-project", + confirmationFingerprint: newPlan.confirmationFingerprint, + signal: newController.signal, + }), { repository, creationService: abortingCreationService, registry }); + + const currentPlan = await preview("current-project"); + const currentController = new AbortController(); + const cancelledCurrent = await handleCaptureImportApplyPost(request({ + destinationKind: "current-project", + confirmationFingerprint: currentPlan.confirmationFingerprint, + signal: currentController.signal, + }), { + repository, + creationService, + registry, + applyCurrent: async (input) => { + currentController.abort(); + return applyCaptureImportToProject(input); + }, + }); + + expect(cancelledNew.status).toBe(499); + expect(cancelledCurrent.status).toBe(499); + expect(cancelledNew.headers.get("set-cookie")).toBeNull(); + expect(cancelledCurrent.headers.get("set-cookie")).toBeNull(); + await expect(repository.list()).resolves.toHaveLength(1); + await expect(readdir(join(projectPath, "..", "assets"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("does not disclose server paths from apply failures and wires the dedicated route", async () => { + const plan = await preview("new-project"); + const privatePath = join(root, "private-stage"); + const failingService = new ManagedProjectCreationService(repository, { + loadStoreSpecs: async () => { throw new Error(`cannot read ${privatePath}`); }, + }); + const response = await handleCaptureImportApplyPost(request({ + destinationKind: "new-project", + confirmationFingerprint: plan.confirmationFingerprint, + }), { repository, creationService: failingService, registry }); + const body = await response.text(); + + expect(response.status).toBe(422); + expect(body).not.toContain(root); + expect(body).not.toContain("private-stage"); + expect(POST).toBeTypeOf("function"); + }); + + it("classifies a final publication collision as a changed preview", async () => { + const plan = await preview("current-project"); + const response = await handleCaptureImportApplyPost(request({ + destinationKind: "current-project", + confirmationFingerprint: plan.confirmationFingerprint, + }), { + repository, + creationService, + registry, + applyCurrent: async () => { + throw new SafeFileConflictError(join(projectPath, "..", "assets", "occupied.png")); + }, + }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "Capture import preview changed; preview again before applying", + code: "preview-changed", + }); + }); +}); diff --git a/apps/web/app/api/capture-import/apply/route.ts b/apps/web/app/api/capture-import/apply/route.ts new file mode 100644 index 00000000..9e06ea7b --- /dev/null +++ b/apps/web/app/api/capture-import/apply/route.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleCaptureImportApplyPost } from "@web/app/api/capture-import/handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + return handleCaptureImportApplyPost(request); +} diff --git a/apps/web/app/api/capture-import/handler.ts b/apps/web/app/api/capture-import/handler.ts new file mode 100644 index 00000000..59235d32 --- /dev/null +++ b/apps/web/app/api/capture-import/handler.ts @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + CaptureImportConfirmationError, + CaptureImportCancelledError, + DEFAULT_CAPTURE_IMPORT_LIMITS, + discoverCaptureImport, + planCaptureImport, + type CaptureImportDiscovery, + type CaptureImportInputFile, + type CaptureImportPlan, +} from "@screenshot-studio/core/capture-import"; +import { ProjectScaffoldInput, type ProjectScaffoldInput as ScaffoldInput } from "@screenshot-studio/core/scaffold"; +import { + ProjectRevisionConflictError, + readIdentifiedProjectSnapshot, +} from "@screenshot-studio/core/storage"; +import { + applyCaptureImportToProject, + preflightCaptureImportPlanResources, + SafeFileConflictError, +} from "@screenshot-studio/cli/capture-import-transaction"; +import { + createManagedProjectFromCaptureImport, + previewManagedProjectCaptureImport, +} from "@web/lib/managed-capture-import.server.js"; +import { resolveManagedProjectStorageConfig } from "@web/lib/managed-project-config.server.js"; +import { + ManagedProjectCreationError, + ManagedProjectCreationService, +} from "@web/lib/managed-project-creation.server.js"; +import { ManagedProjectRepository } from "@web/lib/managed-project-repository.server.js"; +import { + projectRevisionConflictResponse, + withProjectMutationRequest, + withProjectVersionedReadRequest, +} from "@web/lib/project-request-fence.server.js"; +import { + projectSessionRegistry, + type ProjectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { + recentProjectsStore, + type RecentProjectsStore, +} from "@web/lib/recent-projects.server.js"; +import { projectSessionResponseHeaders } from "@web/app/api/project-session/handler.js"; + +class CaptureUploadLimitError extends Error {} +class CaptureRequestError extends Error {} + +type CurrentDestination = { kind: "current-project"; layoutId: string }; +type NewDestination = { kind: "new-project"; scaffoldInput: ScaffoldInput }; +type CaptureDestination = CurrentDestination | NewDestination; + +interface ParsedCaptureRequest { + destination: CaptureDestination; + discovery: CaptureImportDiscovery; + confirmationFingerprint?: string; +} + +export interface CaptureImportRouteDependencies { + repository?: ManagedProjectRepository; + creationService?: ManagedProjectCreationService; + registry?: ProjectSessionRegistry; + applyCurrent?: typeof applyCaptureImportToProject; + recentProjects?: Pick; +} + +const COMMON_FIELDS = new Set(["destinationKind", "paths", "files"]); +const CURRENT_FIELDS = new Set([...COMMON_FIELDS, "layoutId", "confirmationFingerprint"]); +const NEW_FIELDS = new Set([ + ...COMMON_FIELDS, + "name", + "platform", + "layoutMode", + "templateId", + "confirmationFingerprint", +]); + +function errorResponse(status: number, error: string, code: string): Response { + return Response.json({ error, code }, { + status, + headers: { "cache-control": "no-store" }, + }); +} + +function quotedRevision(revision: string): string { + return `"${revision}"`; +} + +function isUpload(value: FormDataEntryValue): value is File { + return typeof value !== "string" + && typeof value.arrayBuffer === "function" + && typeof value.size === "number"; +} + +function oneString(form: FormData, name: string): string | undefined { + const values = form.getAll(name); + if (values.length === 0) return undefined; + if (values.length !== 1 || typeof values[0] !== "string") { + throw new CaptureRequestError(`Capture import field "${name}" is invalid`); + } + return values[0]; +} + +function parseDestination(form: FormData): CaptureDestination { + const kind = oneString(form, "destinationKind"); + const allowed = kind === "current-project" ? CURRENT_FIELDS + : kind === "new-project" ? NEW_FIELDS + : undefined; + if (!allowed) throw new CaptureRequestError("Capture import destination is invalid"); + for (const key of form.keys()) { + if (!allowed.has(key)) throw new CaptureRequestError(`Unexpected capture import field "${key}"`); + } + if (kind === "current-project") { + const layoutId = oneString(form, "layoutId")?.trim(); + if (!layoutId) throw new CaptureRequestError("Capture import layout is required"); + return { kind: "current-project", layoutId }; + } + if (kind !== "new-project") { + throw new CaptureRequestError("Capture import destination is invalid"); + } + const templateId = oneString(form, "templateId"); + if (templateId === undefined) { + throw new CaptureRequestError("Capture import template choice is required"); + } + const scaffold = ProjectScaffoldInput.safeParse({ + name: oneString(form, "name"), + platform: oneString(form, "platform"), + layoutMode: oneString(form, "layoutMode"), + templateId: templateId || null, + }); + if (!scaffold.success) throw new CaptureRequestError("Capture import project fields are invalid"); + return { kind: "new-project", scaffoldInput: scaffold.data }; +} + +async function boundedFormData(request: Request, maxBytes: number): Promise { + if (!request.body) throw new CaptureRequestError("Capture import request body is missing"); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (!Number.isSafeInteger(total) || total > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new CaptureUploadLimitError(); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new Request(request.url, { + method: request.method, + headers: request.headers, + body: bytes, + signal: request.signal, + }).formData(); +} + +async function parseCaptureRequest( + request: Request, + repository: ManagedProjectRepository, + requireConfirmation: boolean, +): Promise { + const contentType = request.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().startsWith("multipart/form-data;")) { + throw new CaptureRequestError("Capture import requires multipart form data"); + } + const declared = request.headers.get("content-length"); + if (declared !== null) { + const bytes = Number(declared); + if (!Number.isSafeInteger(bytes) || bytes < 0) { + throw new CaptureRequestError("Capture import content length is invalid"); + } + if (bytes > repository.config.maxImportUploadBytes) { + await request.body?.cancel().catch(() => undefined); + throw new CaptureUploadLimitError(); + } + } + if (request.signal.aborted) throw new DOMException("Capture import was cancelled", "AbortError"); + let form: FormData; + try { + form = await boundedFormData(request, repository.config.maxImportUploadBytes); + } catch (error) { + if (error instanceof CaptureUploadLimitError) throw error; + if (request.signal.aborted) { + throw new DOMException("Capture import was cancelled", "AbortError"); + } + throw new CaptureRequestError("Capture import form data is invalid"); + } + const destination = parseDestination(form); + const fileValues = form.getAll("files"); + const pathValues = form.getAll("paths"); + if (fileValues.length === 0 || fileValues.length !== pathValues.length + || fileValues.some((value) => !isUpload(value)) + || pathValues.some((value) => typeof value !== "string")) { + throw new CaptureRequestError("Capture import files and paths must be aligned"); + } + const files = fileValues as File[]; + const paths = pathValues as string[]; + const maxFiles = Math.min( + DEFAULT_CAPTURE_IMPORT_LIMITS.maxFiles, + repository.config.maxProjectFiles, + ); + if (files.length > maxFiles) throw new CaptureUploadLimitError(); + const totalBytes = files.reduce((total, file) => total + file.size, 0); + if (!Number.isSafeInteger(totalBytes) || totalBytes > repository.config.maxImportUploadBytes) { + throw new CaptureUploadLimitError(); + } + const inputFiles: CaptureImportInputFile[] = await Promise.all(files.map(async (file, index) => ({ + relativePath: paths[index]!, + bytes: new Uint8Array(await file.arrayBuffer()), + }))); + if (request.signal.aborted) throw new DOMException("Capture import was cancelled", "AbortError"); + const discovery = discoverCaptureImport({ + files: inputFiles, + limits: { + maxFiles, + maxBytes: Math.min( + DEFAULT_CAPTURE_IMPORT_LIMITS.maxBytes, + repository.config.maxProjectBytes, + repository.config.maxImportUploadBytes, + ), + }, + }); + const confirmationFingerprint = oneString(form, "confirmationFingerprint")?.trim(); + if (requireConfirmation && !/^[0-9a-f]{64}$/u.test(confirmationFingerprint ?? "")) { + throw new CaptureRequestError("Capture import confirmation is required"); + } + if (!requireConfirmation && confirmationFingerprint !== undefined) { + throw new CaptureRequestError("Capture import preview cannot include a confirmation"); + } + return { + destination, + discovery, + ...(confirmationFingerprint === undefined ? {} : { confirmationFingerprint }), + }; +} + +function publicPlan(plan: CaptureImportPlan) { + return { + receiving: plan.receiving, + summary: plan.summary, + screens: plan.screens, + resources: plan.resources.map(({ sourcePath, destinationPath, digest }) => ({ + sourcePath, + destinationPath, + digest, + })), + warnings: plan.warnings, + invalidEntries: plan.invalidEntries, + canApply: plan.canApply, + confirmationFingerprint: plan.confirmationFingerprint, + }; +} + +function routeError(error: unknown): Response { + const revisionConflict = projectRevisionConflictResponse(error); + if (revisionConflict) return revisionConflict; + if (error instanceof CaptureUploadLimitError + || (error instanceof Error && /configured (?:file|byte) limit/iu.test(error.message))) { + return errorResponse(413, "Capture import exceeds the configured limit", "limit"); + } + if (error instanceof CaptureRequestError) { + const media = error.message.includes("multipart form data"); + return errorResponse(media ? 415 : 400, error.message, media ? "unsupported-media-type" : "invalid-request"); + } + if (error instanceof CaptureImportConfirmationError) { + return errorResponse(409, error.message, "preview-changed"); + } + if (error instanceof SafeFileConflictError) { + return errorResponse( + 409, + "Capture import preview changed; preview again before applying", + "preview-changed", + ); + } + if (error instanceof CaptureImportCancelledError) { + return errorResponse(499, error.message, "cancelled"); + } + if (error instanceof DOMException && error.name === "AbortError") { + return errorResponse(499, "Capture import was cancelled", "cancelled"); + } + if (error instanceof ManagedProjectCreationError) { + if (error.code === "cancelled") return errorResponse(499, "Capture import was cancelled", "cancelled"); + if (error.code === "limit") return errorResponse(413, "Capture import exceeds the configured limit", "limit"); + if (error.code === "invalid-scaffold") { + return errorResponse(422, "Capture import project fields are incompatible", "invalid-scaffold"); + } + } + if (error instanceof Error && /invalid entries|cannot be applied|layout/iu.test(error.message)) { + return errorResponse(422, "Capture import preview is invalid", "invalid-preview"); + } + return errorResponse(500, "Capture import failed", "internal"); +} + +function resolveDependencies(dependencies: CaptureImportRouteDependencies) { + const repository = dependencies.repository + ?? new ManagedProjectRepository(resolveManagedProjectStorageConfig()); + return { + repository, + creationService: dependencies.creationService ?? new ManagedProjectCreationService(repository), + registry: dependencies.registry ?? projectSessionRegistry, + applyCurrent: dependencies.applyCurrent ?? applyCaptureImportToProject, + recentProjects: dependencies.recentProjects ?? recentProjectsStore, + }; +} + +export async function handleCaptureImportPreviewPost( + request: Request, + dependencies: CaptureImportRouteDependencies = {}, +): Promise { + const resolved = resolveDependencies(dependencies); + let parsed: ParsedCaptureRequest; + try { + parsed = await parseCaptureRequest(request, resolved.repository, false); + } catch (error) { + return routeError(error); + } + if (parsed.destination.kind === "new-project") { + try { + const plan = await previewManagedProjectCaptureImport({ + scaffoldInput: parsed.destination.scaffoldInput, + discovery: parsed.discovery, + creationService: resolved.creationService, + signal: request.signal, + }); + return Response.json({ preview: publicPlan(plan) }, { + headers: { "cache-control": "no-store" }, + }); + } catch (error) { + return routeError(error); + } + } + const destination = parsed.destination; + return withProjectVersionedReadRequest(request, async (session, expectedRevision) => { + try { + const snapshot = await readIdentifiedProjectSnapshot(session.projectPath); + if (snapshot.revision !== expectedRevision) { + throw new ProjectRevisionConflictError(expectedRevision, snapshot.revision); + } + const plan = await preflightCaptureImportPlanResources(session.projectPath, planCaptureImport({ + project: snapshot.project, + layoutId: destination.layoutId, + destinationKind: "current-project", + discovery: parsed.discovery, + })); + return Response.json({ + preview: publicPlan(plan), + }, { + headers: { + "cache-control": "no-store", + etag: quotedRevision(snapshot.revision), + "x-project-id": session.projectId, + }, + }); + } catch (error) { + return routeError(error); + } + }, { + registry: resolved.registry, + managedStorageConfig: resolved.repository.config, + }); +} + +export async function handleCaptureImportApplyPost( + request: Request, + dependencies: CaptureImportRouteDependencies = {}, +): Promise { + const resolved = resolveDependencies(dependencies); + let parsed: ParsedCaptureRequest; + try { + parsed = await parseCaptureRequest(request, resolved.repository, true); + } catch (error) { + return routeError(error); + } + if (parsed.destination.kind === "new-project") { + let reservedSessionId: string; + try { + reservedSessionId = resolved.registry.reserveSessionId(); + } catch { + return errorResponse(500, "Capture import failed", "internal"); + } + try { + const record = await createManagedProjectFromCaptureImport({ + scaffoldInput: parsed.destination.scaffoldInput, + discovery: parsed.discovery, + confirmationFingerprint: parsed.confirmationFingerprint, + creationService: resolved.creationService, + signal: request.signal, + }); + const session = resolved.registry.openManagedRecord(record, reservedSessionId); + await resolved.recentProjects.record(session).catch(() => undefined); + const headers = projectSessionResponseHeaders(session, request); + headers.set("x-project-id", session.projectId); + return Response.json({ project: session.descriptor, imported: parsed.discovery.summary.screenCount }, { + status: 201, + headers, + }); + } catch (error) { + resolved.registry.releaseReservedSessionId(reservedSessionId); + return routeError(error); + } + } + const destination = parsed.destination; + return withProjectMutationRequest(request, async (session, expectedRevision) => { + try { + const result = await resolved.applyCurrent({ + projectPath: session.projectPath, + expectedRevision, + layoutId: destination.layoutId, + discovery: parsed.discovery, + confirmationFingerprint: parsed.confirmationFingerprint, + signal: request.signal, + }); + return Response.json({ + project: session.descriptor, + imported: result.imported, + receiving: result.plan.receiving, + }, { + headers: { + "cache-control": "no-store", + etag: quotedRevision(result.revision), + "x-project-id": session.projectId, + }, + }); + } catch (error) { + return routeError(error); + } + }, { + registry: resolved.registry, + managedStorageConfig: resolved.repository.config, + }); +} diff --git a/apps/web/app/api/capture-import/preview/route.test.ts b/apps/web/app/api/capture-import/preview/route.test.ts new file mode 100644 index 00000000..e2d4ab8a --- /dev/null +++ b/apps/web/app/api/capture-import/preview/route.test.ts @@ -0,0 +1,411 @@ +// SPDX-License-Identifier: Apache-2.0 +import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseStoreSpecs } from "@screenshot-studio/core/export"; +import { type Project } from "@screenshot-studio/core/model"; +import { projectRevision, serializeProject } from "@screenshot-studio/core/storage"; +import { resolveManagedProjectStorageConfig } from "@web/lib/managed-project-config.server.js"; +import { ManagedProjectCatalogStore } from "@web/lib/managed-project-catalog.server.js"; +import { ManagedProjectCreationService } from "@web/lib/managed-project-creation.server.js"; +import { ManagedProjectRepository } from "@web/lib/managed-project-repository.server.js"; +import { ManagedProjectSourceAdapter, ProjectSessionRegistry } from "@web/lib/project-session.server.js"; +import { handleCaptureImportPreviewPost } from "@web/app/api/capture-import/handler.js"; +import { POST } from "./route.js"; + +let root: string; +let projectPath: string; +let projectDirectory: string; +let revision: string; +let repository: ManagedProjectRepository; +let creationService: ManagedProjectCreationService; +let registry: ProjectSessionRegistry; +let sessionId: string; +const projectId = "project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const png = new Uint8Array(await readFile(new URL( + "../../../../../../fixtures/regressions/screenshot-parity/assets/shot.png", + import.meta.url, +))); +const specs = parseStoreSpecs({ + apple: { iphone: [{ id: "lead", label: "Apple lead", width: 1320, height: 2868, orientation: "portrait" }] }, + android: { phone: [{ id: "lead", label: "Android lead", width: 1080, height: 1920, orientation: "portrait" }] }, + windows: { desktop: [{ id: "lead", label: "Windows lead", width: 1920, height: 1080, orientation: "landscape" }] }, +}); + +function currentProject(): Project { + return { + meta: { name: "Current receiver", schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "default", + layouts: [{ + id: "default", name: "Primary", mode: "isolated", screens: [{ + id: "hero", name: "Hero", platform: "apple", device: "iphone", locale: "en", + direction: "ltr", artboard: { width: 1320, height: 2868 }, + background: { type: "solid", color: "#101827" }, elements: [], + }], + }], + }; +} + +function captureRequest(input: { + destinationKind: "current-project" | "new-project"; + revision?: string; + projectId?: string; + extra?: [string, string]; + assetBytes?: string; +}): Request { + const form = new FormData(); + form.set("destinationKind", input.destinationKind); + if (input.destinationKind === "current-project") form.set("layoutId", "default"); + else { + form.set("name", "New receiver"); + form.set("platform", "apple"); + form.set("layoutMode", "isolated"); + form.set("templateId", ""); + } + form.append("paths", "Selected/screenshots/apple/iphone/en/01.png"); + form.append("files", new Blob([png]), "01.png"); + if (input.assetBytes) { + form.append("paths", "Selected/assets/icons/product.png"); + form.append("files", new Blob([input.assetBytes]), "product.png"); + } + if (input.extra) form.set(...input.extra); + return new Request("http://localhost/api/capture-import/preview", { + method: "POST", + headers: input.destinationKind === "current-project" ? { + cookie: `screenshot_studio_project_session=${sessionId}`, + "x-project-id": input.projectId ?? projectId, + "if-match": `"${input.revision ?? revision}"`, + } : undefined, + body: form, + }); +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "capture-preview-route-")); + const config = resolveManagedProjectStorageConfig({ + SCREENSHOT_STUDIO_DATA_ROOT: join(root, "managed"), + SCREENSHOT_STUDIO_MAX_PROJECT_FILES: "20", + SCREENSHOT_STUDIO_MAX_PROJECT_BYTES: "10000000", + SCREENSHOT_STUDIO_MAX_IMPORT_UPLOAD_BYTES: "10000000", + }); + repository = new ManagedProjectRepository(config); + await repository.initialize(); + projectDirectory = join(config.projectsRoot, projectId); + await mkdir(projectDirectory); + projectPath = join(projectDirectory, "app-store-screenshots.json"); + const bytes = serializeProject(currentProject()); + await writeFile(projectPath, bytes); + revision = projectRevision(bytes); + const timestamp = "2026-07-19T00:00:00.000Z"; + await new ManagedProjectCatalogStore(config).insert({ + id: projectId, + directoryName: projectId, + name: "Current receiver", + lifecycle: "active", + lifecycleRevision: 1, + createdAt: timestamp, + updatedAt: timestamp, + }); + creationService = new ManagedProjectCreationService(repository, { storeSpecs: specs }); + registry = new ProjectSessionRegistry({ + createSessionId: () => "current-session", + managedSource: new ManagedProjectSourceAdapter(repository), + }); + sessionId = registry.openManagedRecord({ + id: projectId, + name: "Current receiver", + projectDirectory, + projectPath, + lifecycleRevision: 1, + }).descriptor.sessionId; +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await rm(root, { recursive: true, force: true }); +}); + +describe("POST /api/capture-import/preview", () => { + it("returns a public repeatable current-project plan while performing zero writes", async () => { + const before = await readFile(projectPath); + const first = await handleCaptureImportPreviewPost(captureRequest({ + destinationKind: "current-project", + }), { repository, creationService, registry }); + const second = await handleCaptureImportPreviewPost(captureRequest({ + destinationKind: "current-project", + }), { repository, creationService, registry }); + const firstBody = await first.json(); + const secondBody = await second.json(); + + expect(first.status).toBe(200); + expect(first.headers.get("etag")).toBe(`"${revision}"`); + expect(firstBody).toEqual(secondBody); + expect(firstBody).toMatchObject({ + preview: { + receiving: { kind: "current-project", projectName: "Current receiver", layoutId: "default" }, + summary: { screenCount: 1, platforms: [{ id: "apple", count: 1 }] }, + canApply: true, + }, + }); + expect(firstBody.preview.screens[0]).toMatchObject({ + screenId: "apple-iphone-en-01", sourcePath: "screenshots/apple/iphone/en/01.png", + }); + expect(firstBody.preview.resources[0]).not.toHaveProperty("bytes"); + expect(firstBody.preview).not.toHaveProperty("project"); + expect(firstBody.preview).not.toHaveProperty("sourceFingerprint"); + expect(firstBody.preview).not.toHaveProperty("targetRevision"); + expect(first.headers.get("set-cookie")).toBeNull(); + expect(await readFile(projectPath)).toEqual(before); + await expect(repository.list()).resolves.toHaveLength(1); + }); + + it("rejects a cached current-project preview after permanent deletion is reserved", async () => { + const request = captureRequest({ destinationKind: "current-project" }); + const catalog = new ManagedProjectCatalogStore(repository.config); + const priorEntry = (await catalog.read()).entries[0]!; + const identity = await stat(projectDirectory, { bigint: true }); + await catalog.reserveDeletion({ + operationId: "operation_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + operation: "delete", + state: "reserved", + projectId, + lifecycleRevision: 1, + statusSecretHash: "2".repeat(64), + projectRevision: revision, + priorEntry, + sourceIdentity: { device: identity.dev.toString(), inode: identity.ino.toString() }, + }); + + const response = await handleCaptureImportPreviewPost(request, { + repository, + creationService, + registry, + }); + + expect(response.status).toBe(409); + expect(registry.peek(sessionId)).toBeUndefined(); + await expect(access(projectPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("shows an existing receiver-resource mismatch as invalid before confirmation", async () => { + const assetPath = join(projectDirectory, "assets", "icons", "product.png"); + await mkdir(join(projectDirectory, "assets", "icons"), { recursive: true }); + await writeFile(assetPath, "receiver bytes"); + const beforeProject = await readFile(projectPath); + + const response = await handleCaptureImportPreviewPost(captureRequest({ + destinationKind: "current-project", + assetBytes: "different source bytes", + }), { repository, creationService, registry }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.preview).toMatchObject({ + canApply: false, + invalidEntries: [{ + code: "destination-conflict", + path: "assets/icons/product.png", + message: expect.stringMatching(/already exists.*different bytes/i), + }], + }); + expect(await readFile(projectPath)).toEqual(beforeProject); + await expect(readFile(assetPath, "utf8")).resolves.toBe("receiver bytes"); + }); + + it("previews a new managed destination without staging, catalog, or session activation", async () => { + const createStage = vi.spyOn(repository, "createStage"); + const response = await handleCaptureImportPreviewPost(captureRequest({ + destinationKind: "new-project", + }), { repository, creationService, registry }); + + expect(response.status).toBe(200); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(await response.json()).toMatchObject({ + preview: { receiving: { kind: "new-project", projectName: "New receiver", layoutId: "default" } }, + }); + expect(createStage).not.toHaveBeenCalled(); + await expect(access(repository.config.stagingRoot)).resolves.toBeUndefined(); + await expect(repository.list()).resolves.toHaveLength(1); + }); + + it("rejects missing, stale, or cross-project current target fences before reading a plan", async () => { + const missingRevision = captureRequest({ destinationKind: "current-project" }); + missingRevision.headers.delete("if-match"); + const missing = await handleCaptureImportPreviewPost(missingRevision, { repository, registry }); + const stale = await handleCaptureImportPreviewPost(captureRequest({ + destinationKind: "current-project", revision: "0".repeat(64), + }), { repository, registry }); + const crossed = await handleCaptureImportPreviewPost(captureRequest({ + destinationKind: "current-project", projectId: "project_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }), { repository, registry }); + + expect(missing.status).toBe(428); + expect(stale.status).toBe(412); + expect(crossed.status).toBe(409); + }); + + it("does not touch active-session LRU order while previewing", async () => { + let lruIndex = 0; + const lruRegistry = new ProjectSessionRegistry({ + createSessionId: () => `lru-session-${lruIndex++}`, + managedSource: new ManagedProjectSourceAdapter(repository), + }); + const lruSessionId = lruRegistry.openManagedRecord({ + id: projectId, + name: "Current receiver", + projectDirectory, + projectPath, + lifecycleRevision: 1, + }).descriptor.sessionId; + for (let index = 1; index < 30; index += 1) { + lruRegistry.openManagedRecord({ + id: `project_${index.toString(16).padStart(32, "0")}`, + name: `Other ${index}`, + projectDirectory: join(root, `other-${index}`), + projectPath, + lifecycleRevision: 1, + }); + } + + const request = captureRequest({ destinationKind: "current-project" }); + request.headers.set("cookie", `screenshot_studio_project_session=${lruSessionId}`); + const response = await handleCaptureImportPreviewPost(request, { + repository, creationService, registry: lruRegistry, + }); + expect(response.status).toBe(200); + + lruRegistry.openManagedRecord({ + id: `project_${"f".repeat(32)}`, + name: "Overflow", + projectDirectory: join(root, "overflow"), + projectPath, + lifecycleRevision: 1, + }); + expect(lruRegistry.get(lruSessionId)).toBeUndefined(); + }); + + it("does not rehydrate a missing session from a managed recovery cookie", async () => { + const openManaged = vi.fn(async () => ({ + name: "Recovered", + projectId, + projectPath, + lifecycleRevision: 1, + capabilities: { + writeInPlace: true, + revealLocation: false, + watchExternalChanges: false, + nativePicker: false, + projectLocalResources: true, + exportProjectArchive: true, + deleteProjectPermanently: true, + }, + })); + const readOnlyRegistry = new ProjectSessionRegistry({ + createSessionId: () => "should-not-register", + managedSource: { kind: "managed-project", open: openManaged }, + }); + const request = captureRequest({ destinationKind: "current-project" }); + request.headers.set("cookie", `screenshot_studio_managed_project=${projectId}`); + + const response = await handleCaptureImportPreviewPost(request, { + repository, creationService, registry: readOnlyRegistry, + }); + + expect(response.status).toBe(409); + expect(openManaged).not.toHaveBeenCalled(); + expect(readOnlyRegistry.get("should-not-register")).toBeUndefined(); + }); + + it("keeps invalid entries and warnings understandable instead of mutating", async () => { + const form = new FormData(); + form.set("destinationKind", "current-project"); + form.set("layoutId", "default"); + form.append("paths", "../escape.png"); + form.append("files", new Blob([png]), "escape.png"); + form.append("paths", "notes.txt"); + form.append("files", new Blob(["note"]), "notes.txt"); + const request = new Request("http://localhost/api/capture-import/preview", { + method: "POST", + headers: { + cookie: `screenshot_studio_project_session=${sessionId}`, + "x-project-id": projectId, + "if-match": `"${revision}"`, + }, + body: form, + }); + + const response = await handleCaptureImportPreviewPost(request, { repository, registry }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.preview.canApply).toBe(false); + expect(body.preview.invalidEntries).toMatchObject([{ code: "unsafe-path", path: "../escape.png" }]); + expect(body.preview.warnings).toMatchObject([{ code: "unsupported-input", path: "notes.txt" }]); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + + it("rejects whole-project terminology fields, malformed alignment, media, and upload limits", async () => { + const unexpected = await handleCaptureImportPreviewPost(captureRequest({ + destinationKind: "new-project", extra: ["kind", "folder"], + }), { repository, creationService, registry }); + const wrongMedia = await handleCaptureImportPreviewPost(new Request( + "http://localhost/api/capture-import/preview", + { method: "POST", body: "plain" }, + ), { repository, creationService, registry }); + const malformedMultipart = await handleCaptureImportPreviewPost(new Request( + "http://localhost/api/capture-import/preview", + { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=missing" }, + body: "not-a-multipart-body", + }, + ), { repository, creationService, registry }); + const misalignedForm = new FormData(); + misalignedForm.set("destinationKind", "new-project"); + misalignedForm.set("name", "Broken"); + misalignedForm.set("platform", "apple"); + misalignedForm.set("layoutMode", "isolated"); + misalignedForm.set("templateId", ""); + misalignedForm.set("paths", "apple/iphone/en/01.png"); + const misaligned = await handleCaptureImportPreviewPost(new Request( + "http://localhost/api/capture-import/preview", { method: "POST", body: misalignedForm }, + ), { repository, creationService, registry }); + const missingTemplateForm = new FormData(); + missingTemplateForm.set("destinationKind", "new-project"); + missingTemplateForm.set("name", "Missing explicit template choice"); + missingTemplateForm.set("platform", "apple"); + missingTemplateForm.set("layoutMode", "isolated"); + missingTemplateForm.set("paths", "apple/iphone/en/01.png"); + missingTemplateForm.set("files", new Blob([png]), "01.png"); + const missingTemplate = await handleCaptureImportPreviewPost(new Request( + "http://localhost/api/capture-import/preview", + { method: "POST", body: missingTemplateForm }, + ), { repository, creationService, registry }); + const limited = new Request("http://localhost/api/capture-import/preview", { + method: "POST", + headers: { + "content-type": "multipart/form-data; boundary=unused", + "content-length": "10000001", + }, + body: new ReadableStream(), + duplex: "half", + } as RequestInit & { duplex: "half" }); + const overLimit = await handleCaptureImportPreviewPost(limited, { + repository, creationService, registry, + }); + + expect(unexpected.status).toBe(400); + expect(wrongMedia.status).toBe(415); + expect(malformedMultipart.status).toBe(400); + expect(misaligned.status).toBe(400); + expect(missingTemplate.status).toBe(400); + expect(overLimit.status).toBe(413); + await expect(repository.list()).resolves.toHaveLength(1); + }); + + it("wires the dedicated preview route", () => { + expect(POST).toBeTypeOf("function"); + }); +}); diff --git a/apps/web/app/api/capture-import/preview/route.ts b/apps/web/app/api/capture-import/preview/route.ts new file mode 100644 index 00000000..c95a132a --- /dev/null +++ b/apps/web/app/api/capture-import/preview/route.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleCaptureImportPreviewPost } from "@web/app/api/capture-import/handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + return handleCaptureImportPreviewPost(request); +} diff --git a/apps/web/app/api/export/artifacts/[token]/handler.ts b/apps/web/app/api/export/artifacts/[token]/handler.ts new file mode 100644 index 00000000..485f4664 --- /dev/null +++ b/apps/web/app/api/export/artifacts/[token]/handler.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Readable } from "node:stream"; +import { + consumeExportArtifact, + type ExportArtifactLookup, +} from "@web/lib/export-artifacts.server.js"; + +export interface ExportArtifactRouteDependencies { + consumeArtifact?: (token: string) => Promise; +} + +function unavailable(status: 404 | 410): Response { + return Response.json({ + error: status === 410 ? "Export artifact expired" : "Export artifact not found", + }, { + status, + headers: { "cache-control": "no-store" }, + }); +} + +export async function handleExportArtifactGet( + token: string, + dependencies: ExportArtifactRouteDependencies = {}, +): Promise { + const consumeArtifact = dependencies.consumeArtifact ?? consumeExportArtifact; + const lookup = await consumeArtifact(token); + if (lookup.status === "expired") return unavailable(410); + if (lookup.status !== "ready") return unavailable(404); + + const { artifact } = lookup; + // Stream completion happens after this handler returns. Observe private-file cleanup explicitly so + // a filesystem failure cannot surface as an unhandled rejection; the store retains it for retry. + void artifact.cleanup.catch(() => undefined); + try { + return new Response( + Readable.toWeb(artifact.stream) as ReadableStream, + { + status: 200, + headers: { + "content-type": "application/zip", + "content-disposition": `attachment; filename="${artifact.filename}"`, + "content-length": artifact.size.toString(), + "cache-control": "no-store", + }, + }, + ); + } catch (error) { + await artifact.dispose(); + throw error; + } +} diff --git a/apps/web/app/api/export/artifacts/[token]/route.test.ts b/apps/web/app/api/export/artifacts/[token]/route.test.ts new file mode 100644 index 00000000..d9a0fc9e --- /dev/null +++ b/apps/web/app/api/export/artifacts/[token]/route.test.ts @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 +import { access, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { Readable } from "node:stream"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createExportArtifactStore, type ExportArtifactStore } from "@web/lib/export-artifacts.server.js"; +import { handleExportArtifactGet } from "./handler.js"; +import { GET } from "./route.js"; + +let root: string; +let artifactRoot: string; +let sourcePath: string; +let store: ExportArtifactStore; + +function tokenFrom(downloadUrl: string): string { + return downloadUrl.split("/").at(-1)!; +} + +async function artifactPath(): Promise { + const [filename] = await readdir(artifactRoot); + if (!filename) throw new Error("No staged artifact"); + return join(artifactRoot, filename); +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "artifact-route-test-")); + artifactRoot = join(root, "artifacts"); + sourcePath = join(root, "source.zip"); + await writeFile(sourcePath, "streamed zip bytes"); + store = createExportArtifactStore({ rootDirectory: artifactRoot, ttlMs: 60_000 }); +}); + +afterEach(async () => { + await store.close(); + await rm(root, { recursive: true, force: true }); +}); + +describe("GET /api/export/artifacts/[token]", () => { + it.each([ + ["missing", 404], + ["expired", 410], + ["invalid", 404], + ] as const)("maps a %s lookup without exposing internals", async (status, expectedHttpStatus) => { + const response = await handleExportArtifactGet("a".repeat(43), { + consumeArtifact: vi.fn(async () => ({ status })), + }); + + expect(response.status).toBe(expectedHttpStatus); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ error: status === "expired" + ? "Export artifact expired" + : "Export artifact not found" }); + }); + + it("streams exact bytes with attachment metadata and consumes the ticket once", async () => { + const ticket = await store.stage(sourcePath); + const token = tokenFrom(ticket.downloadUrl); + const path = await artifactPath(); + + const response = await handleExportArtifactGet(token, { + consumeArtifact: (candidate) => store.consume(candidate), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/zip"); + expect(response.headers.get("content-disposition")).toBe( + 'attachment; filename="app-store-screenshots-bundle.zip"', + ); + expect(response.headers.get("content-length")).toBe(String(Buffer.byteLength("streamed zip bytes"))); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(Buffer.from(await response.arrayBuffer())).toEqual(Buffer.from("streamed zip bytes")); + await vi.waitFor(async () => { await expect(access(path)).rejects.toThrow(); }); + await expect(store.consume(token)).resolves.toEqual({ status: "missing" }); + }); + + it("deletes the private artifact when the web stream is cancelled", async () => { + const ticket = await store.stage(sourcePath); + const path = await artifactPath(); + const response = await handleExportArtifactGet(tokenFrom(ticket.downloadUrl), { + consumeArtifact: (candidate) => store.consume(candidate), + }); + + await response.body!.cancel(); + + await vi.waitFor(async () => { await expect(access(path)).rejects.toThrow(); }); + }); + + it("observes asynchronous cleanup failures after returning the streaming response", async () => { + const cleanup = Promise.resolve(); + const catchCleanup = vi.spyOn(cleanup, "catch"); + const response = await handleExportArtifactGet("a".repeat(43), { + consumeArtifact: vi.fn(async () => ({ + status: "ready" as const, + artifact: { + stream: Readable.from([Buffer.from("zip")]), + size: 3n, + filename: "bundle.zip", + cleanup, + dispose: vi.fn(async () => undefined), + }, + })), + }); + + expect(response.status).toBe(200); + expect(catchCleanup).toHaveBeenCalledOnce(); + await response.arrayBuffer(); + }); + + it("resolves the Next dynamic params before dispatch", async () => { + const response = await GET( + new Request("http://localhost/api/export/artifacts/token"), + { params: Promise.resolve({ token: "a".repeat(43) }) }, + ); + + expect(response.status).toBe(404); + }); +}); diff --git a/apps/web/app/api/export/artifacts/[token]/route.ts b/apps/web/app/api/export/artifacts/[token]/route.ts new file mode 100644 index 00000000..03c99d67 --- /dev/null +++ b/apps/web/app/api/export/artifacts/[token]/route.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleExportArtifactGet } from "./handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET( + _request: Request, + context: { params: Promise<{ token: string }> }, +): Promise { + const { token } = await context.params; + return handleExportArtifactGet(token); +} diff --git a/apps/web/app/api/export/handler.ts b/apps/web/app/api/export/handler.ts new file mode 100644 index 00000000..37df44f5 --- /dev/null +++ b/apps/web/app/api/export/handler.ts @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: Apache-2.0 +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { assertProjectFontsResolved, getLayout } from "@screenshot-studio/core/model"; +import { + buildBundleEntries, + bundlePreflightWarnings, + parseExportProfile, + parseStoreSpecs, + validateBundleArtboards, + type BundleFilters, +} from "@screenshot-studio/core/export"; +import { + ProjectRevisionConflictError, + readProjectSnapshot, + resolveExistingProjectPath, +} from "@screenshot-studio/core/storage"; +import { exportBundle, withStableProjectExport } from "@screenshot-studio/cli/export"; +import { createRenderBrowserSession } from "@screenshot-studio/cli/render-driver"; +import { resolveStoreSpecsPath } from "@web/lib/store-specs-path.js"; +import { stageExportArtifact as stageExportArtifactDefault } from "@web/lib/export-artifacts.server.js"; +import { EXPORT_OPERATION_ID_PATTERN } from "@web/lib/export-progress.js"; +import { exportProgressRegistry, type ExportProgressRegistry } from "@web/lib/export-progress.server.js"; +import { projectRevisionConflictResponse } from "@web/lib/project-request-fence.server.js"; + +interface ExportRequest { + layoutId?: string; + platform?: BundleFilters["platform"]; + device?: BundleFilters["device"]; + locale?: string; + profile?: string; + operationId?: string; +} + +export interface ExportRouteDependencies { + createRenderBrowserSession?: typeof createRenderBrowserSession; + exportBundle?: typeof exportBundle; + stageExportArtifact?: typeof stageExportArtifactDefault; + progressRegistry?: Pick; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function withExportCleanupErrors( + primary: unknown, + cleanupFailures: Array<{ label: string; error: unknown }>, +): AggregateError { + return new AggregateError( + [primary, ...cleanupFailures.map(({ error }) => error)], + [ + errorText(primary), + ...cleanupFailures.map(({ label, error }) => `${label} cleanup failed: ${errorText(error)}`), + ].join("; "), + { cause: primary }, + ); +} + +/** + * UI export renders the same lead-size roots plus opt-in exact variants that the CLI writes, from the + * same on-disk project JSON and shared headless render path. Unlike the CLI, its published result is + * a browser download, so unpacked outputs live only in a request-owned staging directory. + */ +export async function handleExportPost( + projectPath: string, + req: Request, + dependencies: ExportRouteDependencies = {}, + expectedRevision?: string, +): Promise { + const createSession = dependencies.createRenderBrowserSession ?? createRenderBrowserSession; + const exportBundleOutputs = dependencies.exportBundle ?? exportBundle; + const stageExportArtifact = dependencies.stageExportArtifact ?? stageExportArtifactDefault; + const progressRegistry = dependencies.progressRegistry ?? exportProgressRegistry; + // Missing/invalid JSON coalesces to an empty body, meaning an unfiltered export. + const rawBody: unknown = await req.json().catch(() => ({})); + const body = typeof rawBody === "object" && rawBody !== null && !Array.isArray(rawBody) + ? rawBody as ExportRequest + : {}; + if (body.operationId !== undefined && ( + typeof body.operationId !== "string" || !EXPORT_OPERATION_ID_PATTERN.test(body.operationId) + )) { + return Response.json({ error: "Invalid export operation id" }, { status: 422 }); + } + const operationId = body.operationId; + const filters: BundleFilters = { platform: body.platform, device: body.device, locale: body.locale }; + let profile: ReturnType; + try { + profile = parseExportProfile(body.profile); + } catch (error) { + return Response.json({ error: errorText(error) }, { status: 422 }); + } + + // Validate before launching a browser so bad input fails fast. + let specs: ReturnType; + let layoutId: string; + let warnings: ReturnType; + let entryCount = 0; + try { + const snapshot = await readProjectSnapshot( + await resolveExistingProjectPath(projectPath), + ); + if (expectedRevision !== undefined && expectedRevision !== snapshot.revision) { + return projectRevisionConflictResponse(new ProjectRevisionConflictError( + expectedRevision, + snapshot.revision, + ))!; + } + const project = snapshot.project; + layoutId = getLayout(project, body.layoutId ?? project.preferredExportLayoutId).id; + assertProjectFontsResolved(project, layoutId); + specs = parseStoreSpecs(JSON.parse(await readFile(resolveStoreSpecsPath(), "utf8"))); + const entries = buildBundleEntries(project, layoutId, specs, filters); + validateBundleArtboards(entries); + entryCount = entries.length; + warnings = bundlePreflightWarnings(entries); + } catch (error) { + return Response.json({ error: errorText(error) }, { status: 422 }); + } + + if (operationId) { + try { + progressRegistry.begin(operationId, entryCount); + } catch (error) { + return Response.json({ error: errorText(error) }, { status: 409 }); + } + } + + const baseUrl = new URL(req.url).origin; + let session: Awaited> | undefined; + let result: Awaited> | undefined; + let stagingDir: string | undefined; + let operationError: unknown; + let hasOperationError = false; + try { + stagingDir = await mkdtemp(join(tmpdir(), "screenshot-studio-web-export-")); + session = await createSession(); + result = await withStableProjectExport(projectPath, async (context) => { + if (expectedRevision !== undefined && expectedRevision !== context.revision) { + throw new ProjectRevisionConflictError(expectedRevision, context.revision); + } + getLayout(context.project, layoutId); + assertProjectFontsResolved(context.project, layoutId); + const entries = buildBundleEntries(context.project, layoutId, specs, filters); + validateBundleArtboards(entries); + // Return warnings for the exact revision being rendered, not for the earlier fail-fast + // snapshot. This also keeps un-fenced API callers consistent if the project changes between + // preflight and the stable export lease. + warnings = bundlePreflightWarnings(entries); + const zipPath = await exportBundleOutputs({ + baseUrl, + projectPath: context.projectPath, + projectRevision: context.revision, + layoutId, + outDir: stagingDir!, + entries, + renderSession: session!, + publicationGuard: context.publish, + onProgress: operationId + ? (progress) => progressRegistry.update( + operationId, + progress.phase, + progress.completed, + progress.total, + ) + : undefined, + profile, + project: context.project, + }); + // The bundle filename is reusable. Copy it into its private immutable artifact while the + // stable project/export lease still prevents another export from replacing that pathname. + if (operationId) progressRegistry.update(operationId, "finalizing", entryCount, entryCount); + return stageExportArtifact(zipPath, basename(zipPath)); + }); + } catch (error) { + operationError = error; + hasOperationError = true; + } + + const cleanupFailures: Array<{ label: string; error: unknown }> = []; + if (session) { + try { + await session.close(); + } catch (error) { + cleanupFailures.push({ label: "browser", error }); + } + } + if (stagingDir) { + try { + await rm(stagingDir, { recursive: true, force: true }); + } catch (error) { + cleanupFailures.push({ label: "export staging", error }); + } + } + + if (hasOperationError || cleanupFailures.length > 0) { + if (operationId) progressRegistry.finish(operationId, "failed"); + if (hasOperationError && cleanupFailures.length === 0) { + const revisionConflict = projectRevisionConflictResponse(operationError); + if (revisionConflict) return revisionConflict; + } + const error = hasOperationError && cleanupFailures.length > 0 + ? withExportCleanupErrors(operationError, cleanupFailures) + : hasOperationError + ? operationError + : new Error( + cleanupFailures.map(({ label, error: cleanupError }) => ( + `${label} cleanup failed: ${errorText(cleanupError)}` + )).join("; "), + { cause: cleanupFailures[0]?.error }, + ); + return Response.json({ error: errorText(error) }, { status: 500 }); + } + + if (operationId) progressRegistry.finish(operationId, "ready"); + + return Response.json({ ...result!, warnings }, { + status: 201, + headers: { "cache-control": "no-store" }, + }); +} diff --git a/apps/web/app/api/export/progress/handler.ts b/apps/web/app/api/export/progress/handler.ts new file mode 100644 index 00000000..be35dcde --- /dev/null +++ b/apps/web/app/api/export/progress/handler.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + exportProgressRegistry, + type ExportProgressRegistry, +} from "@web/lib/export-progress.server.js"; + +export function handleExportProgressGet( + request: Request, + registry: Pick = exportProgressRegistry, +): Response { + const operationId = new URL(request.url).searchParams.get("operationId") ?? ""; + try { + const progress = registry.get(operationId); + if (!progress) { + return Response.json({ error: "Export operation was not found" }, { + status: 404, + headers: { "cache-control": "no-store" }, + }); + } + return Response.json(progress, { headers: { "cache-control": "no-store" } }); + } catch (error) { + return Response.json({ error: (error as Error).message }, { + status: 400, + headers: { "cache-control": "no-store" }, + }); + } +} diff --git a/apps/web/app/api/export/progress/route.test.ts b/apps/web/app/api/export/progress/route.test.ts new file mode 100644 index 00000000..b7df80da --- /dev/null +++ b/apps/web/app/api/export/progress/route.test.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it, vi } from "vitest"; +import { handleExportProgressGet } from "./handler.js"; + +const operationId = `export_${"ab".repeat(16)}`; + +describe("GET /api/export/progress", () => { + it("returns an uncached progress snapshot", async () => { + const get = vi.fn(() => ({ operationId, phase: "rendering" as const, completed: 2, total: 4 })); + const response = handleExportProgressGet( + new Request(`http://localhost/api/export/progress?operationId=${operationId}`), + { get }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ operationId, phase: "rendering", completed: 2, total: 4 }); + }); + + it("distinguishes unknown and malformed operation ids", async () => { + const missing = handleExportProgressGet( + new Request(`http://localhost/api/export/progress?operationId=${operationId}`), + { get: vi.fn(() => undefined) }, + ); + expect(missing.status).toBe(404); + + const malformed = handleExportProgressGet( + new Request("http://localhost/api/export/progress?operationId=../escape"), + { get: vi.fn(() => { throw new TypeError("Invalid export operation id"); }) }, + ); + expect(malformed.status).toBe(400); + }); +}); diff --git a/apps/web/app/api/export/progress/route.ts b/apps/web/app/api/export/progress/route.ts new file mode 100644 index 00000000..0b45ea01 --- /dev/null +++ b/apps/web/app/api/export/progress/route.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleExportProgressGet } from "./handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export function GET(request: Request): Response { + return handleExportProgressGet(request); +} diff --git a/apps/web/app/api/export/route.test.ts b/apps/web/app/api/export/route.test.ts new file mode 100644 index 00000000..a8d59541 --- /dev/null +++ b/apps/web/app/api/export/route.test.ts @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, describe, expect, it, vi } from "vitest"; +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { projectRevision } from "@screenshot-studio/core/storage"; +import { + PROJECT_SESSION_COOKIE, + projectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { handleExportPost, type ExportRouteDependencies } from "./handler.js"; +import { POST } from "./route.js"; + +const temporaryDirectories: string[] = []; +const originalInitCwd = process.env.INIT_CWD; +const exportOperationId = `export_${"ab".repeat(16)}`; + +function completeProject(elements: unknown[] = []) { + return { + meta: { name: "Export route test", schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ + id: "primary", + name: "Primary", + mode: "isolated", + screens: [{ + id: "hero", name: "Hero", platform: "apple", device: "iphone", locale: "en", + artboard: { width: 660, height: 1434 }, + background: { type: "solid", color: "#111111" }, + elements, + }], + }], + }; +} + +async function useProject(project = completeProject()): Promise<{ directory: string; projectPath: string }> { + const directory = await mkdtemp(join(tmpdir(), "export-route-")); + temporaryDirectories.push(directory); + const projectPath = join(directory, "app-store-screenshots.json"); + await writeFile(projectPath, JSON.stringify(project)); + return { directory, projectPath }; +} + +function req(body: unknown): Request { + return new Request("http://localhost/api/export", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function sessionReq( + projectPath: string, + body: unknown, + override: { projectId?: string; revision?: string } = {}, +): Promise { + const session = await projectSessionRegistry.openLocal(projectPath); + const revision = projectRevision(await readFile(projectPath)); + process.env.PROJECT_PATH = join(dirname(projectPath), "configured-missing.json"); + return new Request("http://localhost/api/export", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(session.descriptor.sessionId)}`, + "if-match": override.revision ?? `"${revision}"`, + "x-project-id": override.projectId ?? session.projectId, + }, + body: JSON.stringify(body), + }); +} + +afterEach(async () => { + delete process.env.PROJECT_PATH; + if (originalInitCwd === undefined) delete process.env.INIT_CWD; + else process.env.INIT_CWD = originalInitCwd; + await Promise.all(temporaryDirectories.splice(0).map((directory) => ( + rm(directory, { recursive: true, force: true }) + ))); +}); + +describe("POST /api/export", () => { + it("reports render, packaging, finalization, and ready progress", async () => { + const { directory, projectPath } = await useProject(); + const zipPath = join(directory, "bundle.zip"); + await writeFile(zipPath, "zip bytes"); + const progressRegistry = { + begin: vi.fn(), + update: vi.fn(), + finish: vi.fn(), + }; + const exportBundle = vi.fn(async (options: Parameters>[0]) => { + options.onProgress?.({ phase: "rendering", completed: 0, total: 1 }); + options.onProgress?.({ phase: "rendering", completed: 1, total: 1 }); + options.onProgress?.({ phase: "packaging", completed: 1, total: 1 }); + return zipPath; + }); + + const response = await handleExportPost(projectPath, req({ operationId: exportOperationId }), { + createRenderBrowserSession: vi.fn(async () => ({ + renderScreenToPng: vi.fn(), + close: vi.fn(async () => undefined), + })), + exportBundle, + progressRegistry, + stageExportArtifact: vi.fn(async (_source, filename) => ({ + downloadUrl: `/api/export/artifacts/${"a".repeat(43)}`, + expiresAt: "2026-07-15T00:05:00.000Z", + filename: filename!, + })), + }); + + expect(response.status).toBe(201); + expect(progressRegistry.begin).toHaveBeenCalledWith(exportOperationId, 1); + expect(progressRegistry.update.mock.calls).toEqual([ + [exportOperationId, "rendering", 0, 1], + [exportOperationId, "rendering", 1, 1], + [exportOperationId, "packaging", 1, 1], + [exportOperationId, "finalizing", 1, 1], + ]); + expect(progressRegistry.finish).toHaveBeenCalledWith(exportOperationId, "ready"); + }); + + it("rejects malformed progress operation ids before resolving the project", async () => { + const response = await handleExportPost("/missing/project.json", req({ operationId: "../escape" })); + expect(response.status).toBe(422); + }); + + it("rejects a stale expected revision inside the stable export lease", async () => { + const { directory, projectPath } = await useProject(); + const zipPath = join(directory, "bundle.zip"); + await writeFile(zipPath, "zip bytes"); + const exportBundle = vi.fn(async () => zipPath); + + const response = await handleExportPost(projectPath, req({}), { + createRenderBrowserSession: vi.fn(async () => ({ + renderScreenToPng: vi.fn(), + close: vi.fn(async () => undefined), + })), + exportBundle, + }, "stale-revision"); + + expect(response.status).toBe(412); + expect(exportBundle).not.toHaveBeenCalled(); + }); + + it("treats valid non-object JSON as an unfiltered request", async () => { + const { directory, projectPath } = await useProject(); + const zipPath = join(directory, "bundle.zip"); + await writeFile(zipPath, "zip bytes"); + const exportBundle = vi.fn(async () => zipPath); + + const response = await handleExportPost(projectPath, req(null), { + createRenderBrowserSession: vi.fn(async () => ({ + renderScreenToPng: vi.fn(), + close: vi.fn(async () => undefined), + })), + exportBundle, + stageExportArtifact: vi.fn(async (_source, filename) => ({ + downloadUrl: `/api/export/artifacts/${"a".repeat(43)}`, + expiresAt: "2026-07-15T00:05:00.000Z", + filename: filename!, + })), + }); + + expect(response.status).toBe(201); + expect(exportBundle).toHaveBeenCalledOnce(); + }); + + it("returns a one-shot artifact ticket without placing archive bytes in the POST response", async () => { + const { directory, projectPath } = await useProject(); + const zipPath = join(directory, "bundle.zip"); + await writeFile(zipPath, "zip bytes that must not be returned here"); + const session = { renderScreenToPng: vi.fn(), close: vi.fn(async () => undefined) }; + const stageExportArtifact = vi.fn(async (_sourceZipPath: string, filename?: string) => ({ + downloadUrl: `/api/export/artifacts/${"a".repeat(43)}`, + expiresAt: "2026-07-15T00:05:00.000Z", + filename: filename!, + warnings: [], + })); + + const response = await handleExportPost(projectPath, req({}), { + createRenderBrowserSession: vi.fn(async () => session), + exportBundle: vi.fn(async () => zipPath), + stageExportArtifact, + }); + + expect(response.status).toBe(201); + expect(response.headers.get("content-type")).toMatch(/^application\/json/); + expect(response.headers.get("x-export-dir")).toBeNull(); + const body = await response.json(); + expect(body).toEqual({ + downloadUrl: `/api/export/artifacts/${"a".repeat(43)}`, + expiresAt: "2026-07-15T00:05:00.000Z", + filename: "bundle.zip", + warnings: [], + }); + expect(JSON.stringify(body)).not.toContain(zipPath); + expect(stageExportArtifact).toHaveBeenCalledWith(zipPath, "bundle.zip"); + expect(session.close).toHaveBeenCalledOnce(); + }); + + it("removes request-owned unpacked outputs after staging the browser download", async () => { + const { directory, projectPath } = await useProject(); + let stagingDir = ""; + const stageExportArtifact = vi.fn(async (sourceZipPath: string, filename?: string) => { + expect(await readFile(sourceZipPath, "utf8")).toBe("download bytes"); + return { + downloadUrl: `/api/export/artifacts/${"a".repeat(43)}`, + expiresAt: "2026-07-15T00:05:00.000Z", + filename: filename!, + }; + }); + + const response = await handleExportPost(projectPath, req({}), { + createRenderBrowserSession: vi.fn(async () => ({ + renderScreenToPng: vi.fn(), + close: vi.fn(async () => undefined), + })), + exportBundle: vi.fn(async ({ outDir }) => { + stagingDir = outDir; + const zipPath = join(outDir, "app-store-screenshots-bundle.zip"); + await writeFile(zipPath, "download bytes"); + return zipPath; + }), + stageExportArtifact, + }); + + expect(response.status).toBe(201); + expect(stagingDir).not.toBe(join(directory, "export")); + await expect(access(stagingDir)).rejects.toThrow(); + expect(stageExportArtifact).toHaveBeenCalledWith( + expect.stringMatching(/app-store-screenshots-bundle\.zip$/), + "app-store-screenshots-bundle.zip", + ); + }); + + it("stages the exact archive before browser cleanup can expose the path to replacement", async () => { + const { directory, projectPath } = await useProject(); + const zipPath = join(directory, "bundle.zip"); + await writeFile(zipPath, "request-owned zip bytes"); + let stagedBytes = ""; + const session = { + renderScreenToPng: vi.fn(), + close: vi.fn(async () => { await writeFile(zipPath, "replacement zip bytes"); }), + }; + + const response = await handleExportPost(projectPath, req({}), { + createRenderBrowserSession: vi.fn(async () => session), + exportBundle: vi.fn(async () => zipPath), + stageExportArtifact: vi.fn(async (source, filename) => { + stagedBytes = await readFile(source, "utf8"); + return { + downloadUrl: `/api/export/artifacts/${"a".repeat(43)}`, + expiresAt: "2026-07-15T00:05:00.000Z", + filename: filename!, + }; + }), + }); + + expect(response.status).toBe(201); + expect(stagedBytes).toBe("request-owned zip bytes"); + expect(session.close).toHaveBeenCalledOnce(); + }); + + it("returns a structured 500 when artifact staging fails", async () => { + const { projectPath } = await useProject(); + let stagingDir = ""; + const session = { renderScreenToPng: vi.fn(), close: vi.fn(async () => undefined) }; + + const response = await handleExportPost(projectPath, req({}), { + createRenderBrowserSession: vi.fn(async () => session), + exportBundle: vi.fn(async ({ outDir }) => { + stagingDir = outDir; + const zipPath = join(outDir, "app-store-screenshots-bundle.zip"); + await writeFile(zipPath, "zip bytes"); + return zipPath; + }), + stageExportArtifact: vi.fn(async () => { throw new Error("artifact staging failed"); }), + }); + + expect(response.status).toBe(500); + expect((await response.json()).error).toMatch(/artifact staging failed/); + expect(session.close).toHaveBeenCalledOnce(); + await expect(access(stagingDir)).rejects.toThrow(); + }); + + it("returns a structured error when a successful export cannot close its browser session", async () => { + const { directory, projectPath } = await useProject(); + const zipPath = join(directory, "bundle.zip"); + await writeFile(zipPath, "zip bytes"); + const closeError = new Error("browser close failed"); + const session = { + renderScreenToPng: vi.fn(), + close: vi.fn(async () => { throw closeError; }), + }; + + const response = await handleExportPost(projectPath, req({}), { + createRenderBrowserSession: vi.fn(async () => session), + exportBundle: vi.fn(async () => zipPath), + }); + + expect(response.status).toBe(500); + expect((await response.json()).error).toMatch(/browser close failed/); + }); + + it("aggregates export and browser cleanup failures in one structured response", async () => { + const { projectPath } = await useProject(); + const exportError = new Error("render failed"); + const closeError = new Error("browser close failed"); + const session = { + renderScreenToPng: vi.fn(), + close: vi.fn(async () => { throw closeError; }), + }; + + const response = await handleExportPost(projectPath, req({}), { + createRenderBrowserSession: vi.fn(async () => session), + exportBundle: vi.fn(async () => { throw exportError; }), + }); + + expect(response.status).toBe(500); + expect((await response.json()).error).toMatch(/render failed.*browser cleanup failed.*browser close failed/); + }); + + it("returns 422 without rendering when filters match no screens", async () => { + const { projectPath } = await useProject(); + const res = await POST(await sessionReq(projectPath, { locale: "does-not-exist" })); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toMatch(/No screens match/); + }); + + it("returns 422 before rendering when text has no file-backed font", async () => { + const { projectPath } = await useProject(completeProject([{ + id: "title", type: "text", x: 0, y: 0, width: 100, height: 100, + content: "Title", fontFamily: "sans-serif", size: 24, color: "#FFFFFF", + }])); + + const res = await POST(await sessionReq(projectPath, {})); + expect(res.status).toBe(422); + expect((await res.json()).error).toMatch(/unresolved project fonts.*sans-serif/); + }); + + it("rejects stale project identity and revision before export validation", async () => { + const { projectPath } = await useProject(); + + const staleProject = await POST(await sessionReq( + projectPath, + { locale: "does-not-exist" }, + { projectId: "stale-project" }, + )); + expect(staleProject.status).toBe(409); + + const staleRevision = await POST(await sessionReq( + projectPath, + { locale: "does-not-exist" }, + { revision: '"stale-revision"' }, + )); + expect(staleRevision.status).toBe(412); + }); + + it("returns 409 without a session before creating export resources or changing the revision", async () => { + delete process.env.PROJECT_PATH; + const { directory, projectPath } = await useProject(); + process.env.INIT_CWD = directory; + const before = await readFile(projectPath); + + const response = await POST(req({})); + + expect(response.status).toBe(409); + expect(projectRevision(await readFile(projectPath))).toBe(projectRevision(before)); + await expect(readFile(join(directory, "export", "bundle.zip"))).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts new file mode 100644 index 00000000..42ea2b1a --- /dev/null +++ b/apps/web/app/api/export/route.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleExportPost } from "./handler.js"; +import { withProjectMutationRequest } from "@web/lib/project-request-fence.server.js"; + +/** + * UI export writes the same lead-size roots plus opt-in exact variants that the CLI writes, from the same + * on-disk project JSON, through the same headless /render path — but reuses THIS Next host as the + * render host (decision 4): one browser session, `baseUrl` = request origin, no spawned server. + * All pre-render validation runs before the browser launches, so bad input fails fast as 422. + */ +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(req: Request): Promise { + return withProjectMutationRequest( + req, + ({ projectPath }, expectedRevision) => ( + handleExportPost(projectPath, req, {}, expectedRevision) + ), + ); +} diff --git a/apps/web/app/api/fonts/handler.ts b/apps/web/app/api/fonts/handler.ts new file mode 100644 index 00000000..e3e94d21 --- /dev/null +++ b/apps/web/app/api/fonts/handler.ts @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + addGoogleFontMutation, + addUploadedFontMutation, + FontConflictError, + FontInUseError, + FontNotFoundError, + FontValidationError, + GoogleFontError, + fontFamily, + fontWeight, + loadFontProject, + removeFontMutation, + type FontServiceDependencies, +} from "@screenshot-studio/cli/font-service"; +import { projectRevisionConflictResponse } from "@web/lib/project-request-fence.server.js"; + +const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; + +type UploadFile = { + name: string; + size: number; + arrayBuffer: () => Promise; +}; + +function isUploadFile(value: FormDataEntryValue | null): value is File & UploadFile { + return typeof value === "object" + && value !== null + && "name" in value + && "size" in value + && "arrayBuffer" in value; +} + +function errorResponse(error: unknown): Response { + const revisionConflict = projectRevisionConflictResponse(error); + if (revisionConflict) return revisionConflict; + const message = error instanceof Error ? error.message : String(error); + const status = error instanceof FontValidationError ? 400 + : error instanceof FontConflictError ? 409 + : error instanceof FontInUseError ? 409 + : error instanceof FontNotFoundError ? 404 + : error instanceof GoogleFontError ? 422 + : 500; + return Response.json({ error: message }, { status }); +} + +function mutationResponse( + result: { project: unknown; revision: string }, + status = 200, +): Response { + return Response.json(result.project, { + status, + headers: { etag: `"${result.revision}"` }, + }); +} + +async function jsonBody(req: Request): Promise> { + try { + const body: unknown = await req.json(); + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw new FontValidationError("request body must be a JSON object"); + } + return body as Record; + } catch { + throw new FontValidationError("request body must be a valid JSON object"); + } +} + +export async function handleFontGet(projectPath: string): Promise { + try { + const project = await loadFontProject(projectPath); + return Response.json({ fonts: project.fonts }); + } catch (error) { + return errorResponse(error); + } +} + +export async function handleFontPost( + projectPath: string, + req: Request, + dependencies: FontServiceDependencies = {}, + expectedRevision?: string, +): Promise { + try { + const contentType = req.headers.get("content-type") ?? ""; + if (contentType.includes("multipart/form-data")) { + const form = await req.formData(); + const file = form.get("file"); + if (!isUploadFile(file)) throw new FontValidationError("missing font file"); + if (file.size === 0) throw new FontValidationError("font file must not be empty"); + if (file.size > MAX_UPLOAD_BYTES) throw new FontValidationError("font file must be 20 MB or smaller"); + const result = await addUploadedFontMutation(projectPath, { + family: fontFamily(form.get("family")), + weight: fontWeight(form.get("weight")), + filename: file.name, + bytes: new Uint8Array(await file.arrayBuffer()), + }, dependencies, { expectedRevision }); + return mutationResponse(result, 201); + } + + const body = await jsonBody(req); + if (body.source !== "google") throw new FontValidationError('source must be "google"'); + const result = await addGoogleFontMutation(projectPath, { + family: fontFamily(body.family), + weight: fontWeight(body.weight), + }, dependencies, { expectedRevision }); + return mutationResponse(result, 201); + } catch (error) { + return errorResponse(error); + } +} + +export async function handleFontDelete( + projectPath: string, + req: Request, + dependencies: FontServiceDependencies = {}, + expectedRevision?: string, +): Promise { + try { + const body = await jsonBody(req); + const result = await removeFontMutation(projectPath, { + family: fontFamily(body.family), + weight: fontWeight(body.weight), + }, dependencies, { expectedRevision }); + return mutationResponse(result); + } catch (error) { + return errorResponse(error); + } +} diff --git a/apps/web/app/api/fonts/route.test.ts b/apps/web/app/api/fonts/route.test.ts new file mode 100644 index 00000000..f2888331 --- /dev/null +++ b/apps/web/app/api/fonts/route.test.ts @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { projectRevision } from "@screenshot-studio/core/storage"; +import { + PROJECT_SESSION_COOKIE, + projectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { handleFontDelete, handleFontGet, handleFontPost } from "./handler.js"; +import { DELETE, GET, POST } from "./route.js"; + +const validProject = { + meta: { name: "Fonts", schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ + id: "primary", + name: "Primary", + mode: "isolated", + screens: [{ + id: "hero", name: "Hero", platform: "apple", device: "iphone", locale: "en", + artboard: { width: 660, height: 1434 }, + background: { type: "solid", color: "#111111" }, elements: [], + }], + }], +}; + +let dir: string; +let projectPath: string; +let validTtf: Buffer; +const originalInitCwd = process.env.INIT_CWD; + +beforeAll(async () => { + validTtf = await readFile(new URL("../../../../../fixtures/sample/assets/fonts/Pacifico-Regular.ttf", import.meta.url)); +}); + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "font-route-")); + projectPath = join(dir, "app-store-screenshots.json"); + await writeFile(projectPath, JSON.stringify(validProject)); +}); + +afterEach(async () => { + delete process.env.PROJECT_PATH; + if (originalInitCwd === undefined) delete process.env.INIT_CWD; + else process.env.INIT_CWD = originalInitCwd; + await rm(dir, { recursive: true, force: true }); +}); + +function uploadRequest(family = "Inter", weight = "700"): Request { + const form = new FormData(); + form.set("family", family); + form.set("weight", weight); + form.set("file", new Blob([new Uint8Array(validTtf)]), "Inter.ttf"); + return new Request("http://localhost/api/fonts", { method: "POST", body: form }); +} + +describe("/api/fonts", () => { + it("uploads, lists, and removes a custom font", async () => { + const added = await handleFontPost(projectPath, uploadRequest()); + expect(added.status).toBe(201); + expect(added.headers.get("etag")).toBe(`"${projectRevision(await readFile(projectPath))}"`); + expect((await added.json()).fonts).toEqual([ + { family: "Inter", weight: 700, source: "uploaded", path: "assets/fonts/inter-700.ttf" }, + ]); + expect(await readFile(join(dir, "assets/fonts/inter-700.ttf"))).toEqual(validTtf); + + const listed = await handleFontGet(projectPath); + expect((await listed.json()).fonts).toHaveLength(1); + + const removed = await handleFontDelete(projectPath, new Request("http://localhost/api/fonts", { + method: "DELETE", + body: JSON.stringify({ family: "Inter", weight: 700 }), + })); + expect(removed.status).toBe(200); + expect(removed.headers.get("etag")).toBe(`"${projectRevision(await readFile(projectPath))}"`); + expect((await removed.json()).fonts).toEqual([]); + }); + + it("materializes a Google font server-side at add-time", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(new Response("@font-face { src: url(https://fonts.gstatic.com/s/inter/v1/inter.ttf); }")) + .mockResolvedValueOnce(new Response(new Uint8Array(validTtf))); + const response = await handleFontPost(projectPath, new Request("http://localhost/api/fonts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "google", family: "Inter", weight: 400 }), + }), { fetch }); + + expect(response.status).toBe(201); + expect(fetch).toHaveBeenCalledTimes(2); + expect(await readFile(join(dir, "assets/fonts/inter-400.ttf"))).toEqual(validTtf); + }); + + it("rejects invalid upload bytes without publishing and rejects duplicate identities", async () => { + const invalid = new FormData(); + invalid.set("family", "Inter"); + invalid.set("weight", "400"); + invalid.set("file", new Blob([""]), "Inter.ttf"); + expect((await handleFontPost(projectPath, new Request("http://localhost/api/fonts", { method: "POST", body: invalid }))).status) + .toBe(400); + await expect(readFile(join(dir, "assets/fonts/inter-400.ttf"))).rejects.toMatchObject({ code: "ENOENT" }); + expect((await JSON.parse(await readFile(projectPath, "utf8"))).fonts).toEqual([]); + + expect((await handleFontPost(projectPath, uploadRequest("Inter", "400"))).status).toBe(201); + expect((await handleFontPost(projectPath, uploadRequest("Inter", "400"))).status).toBe(409); + }); + + it("maps typed validation and Google materialization errors to explicit 4xx responses", async () => { + const invalidSource = await handleFontPost(projectPath, new Request("http://localhost/api/fonts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "system", family: "Inter", weight: 400 }), + })); + expect(invalidSource.status).toBe(400); + + const invalidJson = await handleFontPost(projectPath, new Request("http://localhost/api/fonts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + })); + expect(invalidJson.status).toBe(400); + + for (const request of [ + new Request("http://localhost/api/fonts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "null", + }), + new Request("http://localhost/api/fonts", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: "null", + }), + ]) { + const nullBody = request.method === "POST" + ? await handleFontPost(projectPath, request) + : await handleFontDelete(projectPath, request); + expect(nullBody.status).toBe(400); + expect((await nullBody.json()).error).toMatch(/JSON object/i); + } + + const noFontUrl = await handleFontPost(projectPath, new Request("http://localhost/api/fonts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "google", family: "Inter", weight: 400 }), + }), { fetch: vi.fn().mockResolvedValue(new Response("body {}")) }); + expect(noFontUrl.status).toBe(422); + + const invalidFontBytes = await handleFontPost(projectPath, new Request("http://localhost/api/fonts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "google", family: "Inter", weight: 400 }), + }), { fetch: vi.fn() + .mockResolvedValueOnce(new Response("@font-face { src: url(https://fonts.gstatic.com/s/inter/v1/inter.ttf); }")) + .mockResolvedValueOnce(new Response("not-a-font")) }); + expect(invalidFontBytes.status).toBe(422); + }); + + it("returns HTTP 409 with the usage count and preserves an in-use font", async () => { + const text = { + id: "top", + type: "text", + x: 0, + y: 0, + width: 100, + height: 50, + rotation: 0, + opacity: 1, + locked: false, + visible: true, + content: "Top", + fontFamily: "Inter", + size: 24, + weight: 400, + lineHeight: 1.2, + letterSpacing: 0, + align: "left", + color: "#ffffff", + }; + const inUseProject = { + ...validProject, + fonts: [{ + family: "Inter", weight: 400, source: "uploaded", path: "assets/fonts/inter-400.ttf", + }], + layouts: [{ + ...validProject.layouts[0]!, + screens: [{ + ...validProject.layouts[0]!.screens[0]!, + elements: [text, { + id: "group", + type: "group", + x: 0, + y: 0, + width: 100, + height: 50, + rotation: 0, + opacity: 1, + locked: false, + visible: true, + children: [{ ...text, id: "nested" }], + }], + }], + }], + }; + await writeFile(projectPath, JSON.stringify(inUseProject)); + + const response = await handleFontDelete(projectPath, new Request("http://localhost/api/fonts", { + method: "DELETE", + body: JSON.stringify({ family: "Inter", weight: 400 }), + })); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: 'Font "Inter" 400 is used by 2 text elements and cannot be removed', + }); + expect((await JSON.parse(await readFile(projectPath, "utf8"))).fonts).toHaveLength(1); + }); + + it("routes every method through the cookie-selected project session", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + process.env.PROJECT_PATH = join(dir, "configured-missing.json"); + const cookie = `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(session.descriptor.sessionId)}`; + let revision = projectRevision(await readFile(projectPath)); + const upload = uploadRequest(); + + const added = await POST(new Request(upload.url, { + method: "POST", + headers: { + ...Object.fromEntries(upload.headers), + cookie, + "if-match": `"${revision}"`, + "x-project-id": session.projectId, + }, + body: await upload.arrayBuffer(), + })); + expect(added.status).toBe(201); + + revision = projectRevision(await readFile(projectPath)); + const listed = await GET(new Request("http://localhost/api/fonts", { + headers: { cookie, "x-project-id": session.projectId }, + })); + expect((await listed.json()).fonts).toHaveLength(1); + + const removed = await DELETE(new Request("http://localhost/api/fonts", { + method: "DELETE", + headers: { + cookie, + "if-match": `"${revision}"`, + "x-project-id": session.projectId, + }, + body: JSON.stringify({ family: "Inter", weight: 700 }), + })); + expect(removed.status).toBe(200); + expect((await removed.json()).fonts).toEqual([]); + }); + + it("rejects a stale project identity before publishing an uploaded font", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + const before = await readFile(projectPath); + const upload = uploadRequest(); + + const response = await POST(new Request(upload.url, { + method: "POST", + headers: { + ...Object.fromEntries(upload.headers), + cookie: `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(session.descriptor.sessionId)}`, + "if-match": `"${projectRevision(before)}"`, + "x-project-id": "stale-project", + }, + body: await upload.arrayBuffer(), + })); + + expect(response.status).toBe(409); + expect(await readFile(projectPath)).toEqual(before); + await expect(readFile(join(dir, "assets/fonts/inter-700.ttf"))) + .rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects a stale project revision before publishing an uploaded font", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + const before = await readFile(projectPath); + const upload = uploadRequest(); + + const response = await POST(new Request(upload.url, { + method: "POST", + headers: { + ...Object.fromEntries(upload.headers), + cookie: `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(session.descriptor.sessionId)}`, + "if-match": '"stale-revision"', + "x-project-id": session.projectId, + }, + body: await upload.arrayBuffer(), + })); + + expect(response.status).toBe(412); + expect(await readFile(projectPath)).toEqual(before); + await expect(readFile(join(dir, "assets/fonts/inter-700.ttf"))) + .rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("returns 409 without a session before changing fonts or the project revision", async () => { + delete process.env.PROJECT_PATH; + process.env.INIT_CWD = dir; + const before = await readFile(projectPath); + + expect((await GET(new Request("http://localhost/api/fonts"))).status).toBe(409); + expect((await POST(uploadRequest())).status).toBe(409); + expect((await DELETE(new Request("http://localhost/api/fonts", { + method: "DELETE", + body: JSON.stringify({ family: "Inter", weight: 700 }), + }))).status).toBe(409); + + const after = await readFile(projectPath); + expect(projectRevision(after)).toBe(projectRevision(before)); + await expect(readFile(join(dir, "assets/fonts/inter-700.ttf"))).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/apps/web/app/api/fonts/route.ts b/apps/web/app/api/fonts/route.ts new file mode 100644 index 00000000..cfe61d68 --- /dev/null +++ b/apps/web/app/api/fonts/route.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleFontDelete, handleFontGet, handleFontPost } from "./handler.js"; +import { + withProjectMutationRequest, + withProjectReadRequest, +} from "@web/lib/project-request-fence.server.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(req: Request): Promise { + return withProjectReadRequest( + req, + ({ projectPath }) => handleFontGet(projectPath), + ); +} + +export async function POST(req: Request): Promise { + return withProjectMutationRequest( + req, + ({ projectPath }, expectedRevision) => ( + handleFontPost(projectPath, req, {}, expectedRevision) + ), + ); +} + +export async function DELETE(req: Request): Promise { + return withProjectMutationRequest( + req, + ({ projectPath }, expectedRevision) => ( + handleFontDelete(projectPath, req, {}, expectedRevision) + ), + ); +} diff --git a/apps/web/app/api/project-archive/artifacts/[token]/handler.ts b/apps/web/app/api/project-archive/artifacts/[token]/handler.ts new file mode 100644 index 00000000..abca295f --- /dev/null +++ b/apps/web/app/api/project-archive/artifacts/[token]/handler.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Readable } from "node:stream"; +import { + consumeProjectArchiveArtifact, +} from "@web/lib/project-archive-artifacts.server.js"; +import type { ExportArtifactLookup } from "@web/lib/export-artifacts.server.js"; + +export interface ProjectArchiveArtifactRouteDependencies { + consumeArtifact?: (token: string) => Promise; +} + +function unavailable(status: 404 | 410): Response { + return Response.json({ + error: status === 410 ? "Project archive expired" : "Project archive not found", + }, { status, headers: { "cache-control": "no-store" } }); +} + +export async function handleProjectArchiveArtifactGet( + token: string, + dependencies: ProjectArchiveArtifactRouteDependencies = {}, +): Promise { + const lookup = await (dependencies.consumeArtifact ?? consumeProjectArchiveArtifact)(token); + if (lookup.status === "expired") return unavailable(410); + if (lookup.status !== "ready") return unavailable(404); + const { artifact } = lookup; + void artifact.cleanup.catch(() => undefined); + try { + return new Response(Readable.toWeb(artifact.stream) as ReadableStream, { + status: 200, + headers: { + "content-type": "application/zip", + "content-disposition": `attachment; filename="${artifact.filename}"`, + "content-length": artifact.size.toString(), + "cache-control": "no-store", + }, + }); + } catch (error) { + await artifact.dispose(); + throw error; + } +} diff --git a/apps/web/app/api/project-archive/artifacts/[token]/route.test.ts b/apps/web/app/api/project-archive/artifacts/[token]/route.test.ts new file mode 100644 index 00000000..dc274946 --- /dev/null +++ b/apps/web/app/api/project-archive/artifacts/[token]/route.test.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +import { access, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createProjectArchiveArtifactStore, type ProjectArchiveArtifactStore } from "@web/lib/project-archive-artifacts.server.js"; +import { handleProjectArchiveArtifactGet } from "./handler.js"; +import { GET } from "./route.js"; + +let root: string; +let sourcePath: string; +let store: ProjectArchiveArtifactStore; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "project-archive-artifact-route-")); + sourcePath = join(root, "source.zip"); + await writeFile(sourcePath, "project archive bytes"); + store = createProjectArchiveArtifactStore({ rootDirectory: join(root, "artifacts"), ttlMs: 60_000 }); +}); + +afterEach(async () => { + await store.close(); + await rm(root, { recursive: true, force: true }); +}); + +describe("GET /api/project-archive/artifacts/[token]", () => { + it("streams the distinct archive filename and consumes the ticket once", async () => { + const ticket = await store.stage(sourcePath); + const token = ticket.downloadUrl.split("/").at(-1)!; + const response = await handleProjectArchiveArtifactGet(token, { + consumeArtifact: (candidate) => store.consume(candidate), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-disposition")) + .toBe('attachment; filename="screenshot-studio-project.zip"'); + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe("project archive bytes"); + await expect(store.consume(token)).resolves.toEqual({ status: "missing" }); + }); + + it.each([["missing", 404], ["expired", 410], ["invalid", 404]] as const)( + "maps %s tickets without exposing storage", + async (status, httpStatus) => { + const response = await handleProjectArchiveArtifactGet("a".repeat(43), { + consumeArtifact: vi.fn(async () => ({ status })), + }); + expect(response.status).toBe(httpStatus); + expect(response.headers.get("cache-control")).toBe("no-store"); + }, + ); + + it("deletes its private artifact after stream cancellation", async () => { + const ticket = await store.stage(sourcePath); + const token = ticket.downloadUrl.split("/").at(-1)!; + const [stagedPath] = await readdir(join(root, "artifacts")); + const response = await handleProjectArchiveArtifactGet(token, { + consumeArtifact: (candidate) => store.consume(candidate), + }); + await response.body!.cancel(); + await vi.waitFor(async () => { + await expect(access(join(root, "artifacts", stagedPath!))).rejects.toThrow(); + }); + }); + + it("resolves dynamic route params", async () => { + const response = await GET(new Request("http://localhost/api/project-archive/artifacts/token"), { + params: Promise.resolve({ token: "a".repeat(43) }), + }); + expect(response.status).toBe(404); + }); +}); diff --git a/apps/web/app/api/project-archive/artifacts/[token]/route.ts b/apps/web/app/api/project-archive/artifacts/[token]/route.ts new file mode 100644 index 00000000..07402ea6 --- /dev/null +++ b/apps/web/app/api/project-archive/artifacts/[token]/route.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleProjectArchiveArtifactGet } from "./handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET( + _request: Request, + context: { params: Promise<{ token: string }> }, +): Promise { + const { token } = await context.params; + return handleProjectArchiveArtifactGet(token); +} diff --git a/apps/web/app/api/project-archive/handler.ts b/apps/web/app/api/project-archive/handler.ts new file mode 100644 index 00000000..e7849197 --- /dev/null +++ b/apps/web/app/api/project-archive/handler.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +import { ManagedProjectArchiveService } from "@web/lib/managed-project-archive.server.js"; +import { + resolveManagedProjectStorageConfig, + type ManagedProjectStorageConfig, +} from "@web/lib/managed-project-config.server.js"; +import { ManagedProjectRepository } from "@web/lib/managed-project-repository.server.js"; +import { + stageProjectArchiveArtifact, + type ProjectArchiveArtifactTicket, +} from "@web/lib/project-archive-artifacts.server.js"; +import { + withProjectVersionedReadRequest, + type ProjectReadRequestDependencies, +} from "@web/lib/project-request-fence.server.js"; +import { + type ActiveProjectResolverDependencies, +} from "@web/lib/project-session.server.js"; +import type { ManagedProjectLifecycleLock } from "@web/lib/managed-project-lifecycle-lock.server.js"; + +type CreatedArchive = Awaited>; + +export interface ProjectArchiveRouteDependencies { + session?: ActiveProjectResolverDependencies; + managedStorageConfig?: ManagedProjectStorageConfig; + managedReadGuard?: ProjectReadRequestDependencies["managedReadGuard"]; + createArchive?: ( + projectId: string, + lifecycleLock?: ManagedProjectLifecycleLock, + ) => Promise; + stageArtifact?: (path: string) => Promise; +} + +export async function handleProjectArchivePost( + request: Request, + dependencies: ProjectArchiveRouteDependencies = {}, +): Promise { + const managedStorageConfig = dependencies.managedStorageConfig + ?? resolveManagedProjectStorageConfig(); + return withProjectVersionedReadRequest(request, async (session, expectedRevision, lifecycleLock) => { + if (session.descriptor.sourceKind !== "managed-project") { + return Response.json({ + error: "Project archive export is available only for managed projects", + code: "managed-project-required", + }, { status: 409, headers: { "cache-control": "no-store" } }); + } + const createArchive = dependencies.createArchive ?? ((projectId, heldLock) => ( + new ManagedProjectArchiveService( + new ManagedProjectRepository(managedStorageConfig), + ).create(projectId, heldLock) + )); + const stageArtifact = dependencies.stageArtifact ?? stageProjectArchiveArtifact; + let archive: CreatedArchive | undefined; + let operationError: unknown; + let revisionMismatch = false; + let ticket: ProjectArchiveArtifactTicket | undefined; + try { + archive = await createArchive(session.projectId, lifecycleLock); + revisionMismatch = archive.projectRevision !== expectedRevision; + if (!revisionMismatch) ticket = await stageArtifact(archive.path); + } catch (error) { + operationError = error; + } + if (archive) { + try { + await archive.dispose(); + } catch (cleanupError) { + operationError = operationError === undefined + ? cleanupError + : new AggregateError( + [operationError, cleanupError], + "Project archive staging and temporary-file cleanup both failed", + { cause: operationError }, + ); + } + } + if (operationError !== undefined) { + return Response.json({ + error: "Project archive export failed", + code: "archive-failed", + }, { status: 500, headers: { "cache-control": "no-store" } }); + } + if (revisionMismatch) { + return Response.json({ + error: "Project changed before the archive could be staged", + code: "project-revision-changed", + }, { + status: 412, + headers: { "cache-control": "no-store" }, + }); + } + return Response.json({ ...ticket!, projectRevision: archive!.projectRevision }, { + status: 201, + headers: { "cache-control": "no-store" }, + }); + }, { + ...dependencies.session, + managedStorageConfig, + managedReadGuard: dependencies.managedReadGuard, + }); +} diff --git a/apps/web/app/api/project-archive/route.test.ts b/apps/web/app/api/project-archive/route.test.ts new file mode 100644 index 00000000..1f6d0f9a --- /dev/null +++ b/apps/web/app/api/project-archive/route.test.ts @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ProjectSessionRegistry } from "@web/lib/project-session.server.js"; +import { handleProjectArchivePost } from "./handler.js"; +import { POST } from "./route.js"; + +let root: string; + +function managedRegistry() { + return new ProjectSessionRegistry({ + createSessionId: () => "managed-session", + managedSource: { + kind: "managed-project", + open: vi.fn(async (projectId: string) => ({ + name: "Managed", + projectId, + projectPath: "/private/managed/app-store-screenshots.json", + lifecycleRevision: 1, + capabilities: { + writeInPlace: true, + revealLocation: false, + watchExternalChanges: false, + nativePicker: false, + projectLocalResources: true, + exportProjectArchive: true, + deleteProjectPermanently: true, + }, + })), + }, + }); +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "project-archive-route-")); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("POST /api/project-archive", () => { + it("creates a separate archive ticket for an active managed project and disposes the source", async () => { + const registry = managedRegistry(); + const session = registry.openManagedRecord({ + id: "project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + name: "Managed", + projectDirectory: "/private/managed", + projectPath: "/private/managed/app-store-screenshots.json", + lifecycleRevision: 1, + }); + const archivePath = join(root, "archive.zip"); + await writeFile(archivePath, "zip"); + const dispose = vi.fn(async () => undefined); + const createArchive = vi.fn(async () => ({ + path: archivePath, + filename: "screenshot-studio-project.zip", + projectRevision: "1".repeat(64), + dispose, + })); + const stageArtifact = vi.fn(async () => ({ + downloadUrl: "/api/project-archive/artifacts/token", + expiresAt: "2026-07-18T21:00:00.000Z", + filename: "screenshot-studio-project.zip" as const, + })); + const request = new Request("http://localhost/api/project-archive", { + method: "POST", + headers: { + cookie: `screenshot_studio_project_session=${session.descriptor.sessionId}`, + "if-match": `"${"1".repeat(64)}"`, + "x-project-id": session.projectId, + }, + }); + + const response = await handleProjectArchivePost(request, { + session: { registry, configuredPath: () => null }, + managedReadGuard: (_session, operation) => operation(), + createArchive, + stageArtifact, + }); + + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ + downloadUrl: "/api/project-archive/artifacts/token", + expiresAt: "2026-07-18T21:00:00.000Z", + filename: "screenshot-studio-project.zip", + projectRevision: "1".repeat(64), + }); + expect(createArchive).toHaveBeenCalledWith(session.projectId, undefined); + expect(stageArtifact).toHaveBeenCalledWith(archivePath); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("requires the Project Home identity and rejects a stale project before archiving", async () => { + const registry = managedRegistry(); + const session = registry.openManagedRecord({ + id: "project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + name: "Managed", + projectDirectory: "/private/managed", + projectPath: "/private/managed/app-store-screenshots.json", + lifecycleRevision: 1, + }); + const createArchive = vi.fn(); + const cookie = `screenshot_studio_project_session=${session.descriptor.sessionId}`; + + const missing = await handleProjectArchivePost(new Request( + "http://localhost/api/project-archive", + { method: "POST", headers: { cookie } }, + ), { session: { registry, configuredPath: () => null }, createArchive }); + const stale = await handleProjectArchivePost(new Request( + "http://localhost/api/project-archive", + { method: "POST", headers: { cookie, "x-project-id": "stale-project" } }, + ), { session: { registry, configuredPath: () => null }, createArchive }); + + expect(missing.status).toBe(428); + expect(stale.status).toBe(409); + expect(createArchive).not.toHaveBeenCalled(); + }); + + it("rejects local-file sessions without generating an archive", async () => { + const registry = new ProjectSessionRegistry({ + createSessionId: () => "local-session", + localSource: { + kind: "local-file", + open: vi.fn(async () => ({ + name: "Local", + projectId: "local", + projectPath: "/local/app-store-screenshots.json", + displayLocation: "/local/app-store-screenshots.json", + capabilities: { + writeInPlace: true, + revealLocation: true, + watchExternalChanges: true, + nativePicker: false, + projectLocalResources: true, + exportProjectArchive: false, + deleteProjectPermanently: false, + }, + })), + }, + }); + const session = await registry.openLocal("local"); + const createArchive = vi.fn(); + const response = await handleProjectArchivePost(new Request( + "http://localhost/api/project-archive", + { method: "POST", headers: { + cookie: `screenshot_studio_project_session=${session.descriptor.sessionId}`, + "if-match": `"${"1".repeat(64)}"`, + "x-project-id": session.projectId, + } }, + ), { + session: { registry, configuredPath: () => null }, + createArchive, + }); + + expect(response.status).toBe(409); + expect(createArchive).not.toHaveBeenCalled(); + }); + + it("refuses to stage an archive whose exact Project bytes do not match If-Match", async () => { + const registry = managedRegistry(); + const session = registry.openManagedRecord({ + id: "project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + name: "Managed", + projectDirectory: "/private/managed", + projectPath: "/private/managed/app-store-screenshots.json", + lifecycleRevision: 1, + }); + const dispose = vi.fn(async () => undefined); + const stageArtifact = vi.fn(); + const response = await handleProjectArchivePost(new Request( + "http://localhost/api/project-archive", + { method: "POST", headers: { + cookie: `screenshot_studio_project_session=${session.descriptor.sessionId}`, + "if-match": `"${"1".repeat(64)}"`, + "x-project-id": session.projectId, + } }, + ), { + session: { registry, configuredPath: () => null }, + managedReadGuard: (_session, operation) => operation(), + createArchive: vi.fn(async () => ({ + path: join(root, "archive.zip"), + filename: "screenshot-studio-project.zip", + projectRevision: "2".repeat(64), + dispose, + })), + stageArtifact, + }); + + expect(response.status).toBe(412); + expect(stageArtifact).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("disposes a generated temporary archive when artifact staging fails", async () => { + const registry = managedRegistry(); + const session = registry.openManagedRecord({ + id: "project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + name: "Managed", + projectDirectory: "/private/managed", + projectPath: "/private/managed/app-store-screenshots.json", + lifecycleRevision: 1, + }); + const dispose = vi.fn(async () => undefined); + const response = await handleProjectArchivePost(new Request( + "http://localhost/api/project-archive", + { method: "POST", headers: { + cookie: `screenshot_studio_project_session=${session.descriptor.sessionId}`, + "if-match": `"${"1".repeat(64)}"`, + "x-project-id": session.projectId, + } }, + ), { + session: { registry, configuredPath: () => null }, + managedReadGuard: (_session, operation) => operation(), + createArchive: vi.fn(async () => ({ + path: join(root, "archive.zip"), + filename: "screenshot-studio-project.zip", + projectRevision: "1".repeat(64), + dispose, + })), + stageArtifact: vi.fn(async () => { throw new Error("private path"); }), + }); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: "Project archive export failed", + code: "archive-failed", + }); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("wires the node route", () => { + expect(POST).toBeTypeOf("function"); + }); +}); diff --git a/apps/web/app/api/project-archive/route.ts b/apps/web/app/api/project-archive/route.ts new file mode 100644 index 00000000..071c9973 --- /dev/null +++ b/apps/web/app/api/project-archive/route.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleProjectArchivePost } from "./handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + return handleProjectArchivePost(request); +} diff --git a/apps/web/app/api/project-create/handler.ts b/apps/web/app/api/project-create/handler.ts new file mode 100644 index 00000000..7783e26b --- /dev/null +++ b/apps/web/app/api/project-create/handler.ts @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +import { ProjectScaffoldInput } from "@screenshot-studio/core/scaffold"; +import { + ManagedProjectCreationError, + ManagedProjectCreationService, +} from "@web/lib/managed-project-creation.server.js"; +import { resolveManagedProjectStorageConfig } from "@web/lib/managed-project-config.server.js"; +import { ManagedProjectRepository } from "@web/lib/managed-project-repository.server.js"; +import { + projectSessionRegistry, + type ProjectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { + recentProjectsStore, + type RecentProjectsStore, +} from "@web/lib/recent-projects.server.js"; +import { projectSessionResponseHeaders } from "@web/app/api/project-session/handler.js"; + +const MAX_CREATE_REQUEST_BYTES = 16_384; + +class CreateRequestLimitError extends Error {} + +export interface ProjectCreateRouteDependencies { + service?: ManagedProjectCreationService; + registry?: ProjectSessionRegistry; + recentProjects?: Pick; +} + +function errorResponse(status: number, error: string, code: string): Response { + return Response.json({ error, code }, { + status, + headers: { "cache-control": "no-store" }, + }); +} + +async function readBoundedJson(request: Request): Promise { + if (!request.body) throw new TypeError("Project creation request body is missing"); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_CREATE_REQUEST_BYTES) { + await reader.cancel().catch(() => undefined); + throw new CreateRequestLimitError(); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(bytes)); +} + +export async function handleProjectCreatePost( + request: Request, + dependencies: ProjectCreateRouteDependencies = {}, +): Promise { + const contentType = request.headers.get("content-type") ?? ""; + if (!contentType.toLocaleLowerCase().startsWith("application/json")) { + return errorResponse(415, "Project creation requires JSON", "unsupported-media-type"); + } + const declaredLength = request.headers.get("content-length"); + if (declaredLength !== null) { + const bytes = Number(declaredLength); + if (!Number.isSafeInteger(bytes) || bytes < 0) { + return errorResponse(400, "Project creation content length is invalid", "invalid-request"); + } + if (bytes > MAX_CREATE_REQUEST_BYTES) { + await request.body?.cancel().catch(() => undefined); + return errorResponse(413, "Project creation request exceeds the byte limit", "limit"); + } + } + + let raw: unknown; + try { + raw = await readBoundedJson(request); + } catch (error) { + return error instanceof CreateRequestLimitError + ? errorResponse(413, "Project creation request exceeds the byte limit", "limit") + : errorResponse(400, "Project creation request is not valid JSON", "invalid-request"); + } + const parsed = ProjectScaffoldInput.safeParse(raw); + if (!parsed.success) { + return errorResponse(400, "Project creation fields are invalid", "invalid-request"); + } + + const service = dependencies.service ?? new ManagedProjectCreationService( + new ManagedProjectRepository(resolveManagedProjectStorageConfig()), + ); + const registry = dependencies.registry ?? projectSessionRegistry; + let reservedSessionId: string; + try { + reservedSessionId = registry.reserveSessionId(); + } catch { + return errorResponse(500, "Project creation failed", "internal"); + } + try { + const record = await service.create(parsed.data); + const session = registry.openManagedRecord(record, reservedSessionId); + await (dependencies.recentProjects ?? recentProjectsStore).record(session).catch(() => undefined); + const headers = projectSessionResponseHeaders(session, request); + headers.set("x-project-id", session.projectId); + return Response.json({ project: session.descriptor }, { + status: 201, + headers, + }); + } catch (error) { + registry.releaseReservedSessionId(reservedSessionId); + if (error instanceof ManagedProjectCreationError) { + if (error.code === "limit") { + return errorResponse(413, "Generated project exceeds configured limits", error.code); + } + if (error.code === "invalid-scaffold") { + return errorResponse( + 422, + "Project creation input is incompatible with the selected starter", + error.code, + ); + } + } + return errorResponse(500, "Project creation failed", "internal"); + } +} diff --git a/apps/web/app/api/project-create/route.test.ts b/apps/web/app/api/project-create/route.test.ts new file mode 100644 index 00000000..42843aa5 --- /dev/null +++ b/apps/web/app/api/project-create/route.test.ts @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: Apache-2.0 +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseStoreSpecs } from "@screenshot-studio/core/export"; +import { resolveManagedProjectStorageConfig } from "@web/lib/managed-project-config.server.js"; +import { ManagedProjectCreationService } from "@web/lib/managed-project-creation.server.js"; +import { ManagedProjectRepository } from "@web/lib/managed-project-repository.server.js"; +import { ProjectSessionRegistry } from "@web/lib/project-session.server.js"; +import { handleProjectCreatePost } from "./handler.js"; +import { POST } from "./route.js"; + +let root: string; +let repository: ManagedProjectRepository; +let registry: ProjectSessionRegistry; +let service: ManagedProjectCreationService; + +const specs = parseStoreSpecs({ + apple: { iphone: [{ id: "lead", label: "Apple lead", width: 1320, height: 2868, orientation: "portrait" }] }, + android: { phone: [{ id: "lead", label: "Android lead", width: 1080, height: 1920, orientation: "portrait" }] }, + windows: { desktop: [{ id: "lead", label: "Windows lead", width: 1920, height: 1080, orientation: "landscape" }] }, +}); + +function request(body: unknown, headers: Record = {}): Request { + return new Request("http://localhost/api/project-create", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "project-create-route-")); + repository = new ManagedProjectRepository(resolveManagedProjectStorageConfig({ + SCREENSHOT_STUDIO_DATA_ROOT: root, + })); + let sessionNumber = 0; + registry = new ProjectSessionRegistry({ + createSessionId: () => sessionNumber++ === 0 ? "created-session" : `created-session-${sessionNumber}`, + }); + service = new ManagedProjectCreationService(repository, { storeSpecs: specs }); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("POST /api/project-create", () => { + it("creates an empty managed project and activates only its new session", async () => { + const recordRecent = vi.fn(async () => undefined); + const response = await handleProjectCreatePost(request({ + name: "New managed project", platform: "windows", layoutMode: "connected", templateId: null, + }), { service, registry, recentProjects: { record: recordRecent } }); + + expect(response.status).toBe(201); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("x-project-id")).toMatch(/^project_[a-f0-9]{32}$/); + expect(response.headers.get("set-cookie")).toContain( + "screenshot_studio_project_session=created-session", + ); + expect(response.headers.get("set-cookie")).toContain( + "screenshot_studio_managed_project=project_", + ); + expect(await response.json()).toMatchObject({ + project: { + sessionId: "created-session", + name: "New managed project", + sourceKind: "managed-project", + capabilities: { revealLocation: false }, + }, + }); + await expect(repository.list()).resolves.toHaveLength(1); + expect(recordRecent).toHaveBeenCalledOnce(); + expect(recordRecent).toHaveBeenCalledWith(expect.objectContaining({ + descriptor: expect.objectContaining({ name: "New managed project" }), + })); + }); + + it("creates a compatible starter project template through the same endpoint", async () => { + const response = await handleProjectCreatePost(request({ + name: "Launch", platform: "android", layoutMode: "isolated", templateId: "product-launch", + }), { service, registry }); + + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ project: { name: "Launch" } }); + }); + + it("rejects paths, unknown fields, malformed JSON, and oversized bodies before publication", async () => { + const pathResponse = await handleProjectCreatePost(request({ + name: "Unsafe", platform: "apple", layoutMode: "isolated", templateId: null, + path: "/srv/unsafe", + }), { service, registry }); + const malformed = await handleProjectCreatePost(new Request("http://localhost/api/project-create", { + method: "POST", headers: { "content-type": "application/json" }, body: "{", + }), { service, registry }); + const oversized = await handleProjectCreatePost(request({ + name: "Large", platform: "apple", layoutMode: "isolated", templateId: null, + }, { "content-length": "20000" }), { service, registry }); + + expect(pathResponse.status).toBe(400); + expect(malformed.status).toBe(400); + expect(oversized.status).toBe(413); + expect(pathResponse.headers.get("set-cookie")).toBeNull(); + await expect(repository.list()).resolves.toEqual([]); + }); + + it("cancels an unknown-length request stream after it exceeds the byte limit", async () => { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(16_385)); + }, + cancel() { + cancelled = true; + }, + }); + const streamedRequest = new Request("http://localhost/api/project-create", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + duplex: "half", + } as RequestInit & { duplex: "half" }); + + const response = await handleProjectCreatePost(streamedRequest, { service, registry }); + + expect(response.status).toBe(413); + expect(cancelled).toBe(true); + await expect(repository.list()).resolves.toEqual([]); + }); + + it("cancels a declared oversized request stream before returning the byte-limit response", async () => { + let cancelled = false; + const body = new ReadableStream({ + cancel() { + cancelled = true; + }, + }); + const streamedRequest = new Request("http://localhost/api/project-create", { + method: "POST", + headers: { "content-type": "application/json", "content-length": "20000" }, + body, + duplex: "half", + } as RequestInit & { duplex: "half" }); + + const response = await handleProjectCreatePost(streamedRequest, { service, registry }); + + expect(response.status).toBe(413); + expect(cancelled).toBe(true); + await expect(repository.list()).resolves.toEqual([]); + }); + + it("preserves the prior session and catalog when template compatibility fails", async () => { + const previous = registry.openManagedRecord({ + id: "project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + name: "Previous", + projectDirectory: "/private/previous", + projectPath: "/private/previous/app-store-screenshots.json", + lifecycleRevision: 1, + }); + const failed = request({ + name: "Unsupported", platform: "windows", layoutMode: "isolated", templateId: "product-launch", + }, { cookie: `screenshot_studio_project_session=${previous.descriptor.sessionId}` }); + + const response = await handleProjectCreatePost(failed, { service, registry }); + + expect(response.status).toBe(422); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(registry.get(previous.descriptor.sessionId)).toBe(previous); + await expect(repository.list()).resolves.toEqual([]); + }); + + it("does not disclose server paths from scaffold or store-spec failures", async () => { + const privatePath = `${root}/private-store-specs.json`; + const failingService = new ManagedProjectCreationService(repository, { + loadStoreSpecs: async () => { throw new Error(`cannot read ${privatePath}`); }, + }); + + const response = await handleProjectCreatePost(request({ + name: "Path safe", platform: "apple", layoutMode: "isolated", templateId: null, + }), { service: failingService, registry }); + const body = await response.text(); + + expect(response.status).toBe(422); + expect(body).not.toContain(root); + expect(body).not.toContain("private-store-specs.json"); + }); + + it("fails before publication when a new session id cannot be reserved", async () => { + const failingRegistry = new ProjectSessionRegistry({ + createSessionId: () => { throw new Error("injected session id failure"); }, + }); + + const response = await handleProjectCreatePost(request({ + name: "No orphan", platform: "apple", layoutMode: "isolated", templateId: null, + }), { service, registry: failingRegistry }); + + expect(response.status).toBe(500); + expect(response.headers.get("set-cookie")).toBeNull(); + await expect(repository.list()).resolves.toEqual([]); + }); + + it("rejects non-JSON media and wires the Next route", async () => { + const response = await handleProjectCreatePost(new Request("http://localhost/api/project-create", { + method: "POST", headers: { "content-type": "text/plain" }, body: "plain", + }), { service, registry }); + + expect(response.status).toBe(415); + expect(POST).toBeTypeOf("function"); + }); +}); diff --git a/apps/web/app/api/project-create/route.ts b/apps/web/app/api/project-create/route.ts new file mode 100644 index 00000000..ec32c11d --- /dev/null +++ b/apps/web/app/api/project-create/route.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleProjectCreatePost } from "./handler.js"; + +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + return handleProjectCreatePost(request); +} diff --git a/apps/web/app/api/project-delete/handler.ts b/apps/web/app/api/project-delete/handler.ts new file mode 100644 index 00000000..d3be39de --- /dev/null +++ b/apps/web/app/api/project-delete/handler.ts @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; +import type { ManagedProjectDeletionEvidence } from "@web/lib/managed-project-catalog.server.js"; +import { + resolveManagedProjectStorageConfig, + type ManagedProjectStorageConfig, +} from "@web/lib/managed-project-config.server.js"; +import { + ManagedProjectLifecycleService, + type PermanentlyDeleteManagedProjectInput, + type ReconcileManagedProjectDeletionInput, +} from "@web/lib/managed-project-lifecycle.server.js"; +import type { ManagedProjectLifecycleLock } from "@web/lib/managed-project-lifecycle-lock.server.js"; +import { + withProjectVersionedReadRequest, + type ProjectReadRequestDependencies, +} from "@web/lib/project-request-fence.server.js"; +import { + projectSessionRegistry, + type ProjectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { expiredProjectSessionResponseHeaders } from "@web/app/api/project-session/handler.js"; + +const MAX_DELETE_REQUEST_BYTES = 2_048; +const OPERATION_ID_PATTERN = /^operation_[0-9a-f]{32}$/; +const SECRET_PATTERN = /^[0-9a-f]{64}$/; +const REVISION_PATTERN = /^[0-9a-f]{64}$/; +const JSON_CONTENT_TYPE_PATTERN = /^application\/json(?:\s*;\s*charset\s*=\s*(?:utf-8|"utf-8"))?\s*$/i; + +interface DeleteLifecycleService { + permanentlyDelete( + input: PermanentlyDeleteManagedProjectInput, + lifecycleLock?: ManagedProjectLifecycleLock, + ): Promise; + reconcileDeletionOperation( + input: ReconcileManagedProjectDeletionInput, + lifecycleLock?: ManagedProjectLifecycleLock, + ): Promise; +} + +export interface ProjectDeleteRouteDependencies { + lifecycle?: DeleteLifecycleService; + registry?: ProjectSessionRegistry; + managedStorageConfig?: ManagedProjectStorageConfig; + managedReadGuard?: ProjectReadRequestDependencies["managedReadGuard"]; +} + +export interface ProjectDeleteOperationRouteDependencies { + lifecycle?: Pick; + registry?: ProjectSessionRegistry; + managedStorageConfig?: ManagedProjectStorageConfig; +} + +class RequestLimitError extends Error {} + +function errorResponse(status: number, error: string, code: string): Response { + return Response.json({ error, code }, { + status, + headers: { "cache-control": "no-store" }, + }); +} + +function exactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === expected.length && actual.every((key) => expected.includes(key)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readBoundedJson(request: Request): Promise { + if (!request.body) throw new TypeError("Request body is missing"); + const declared = request.headers.get("content-length"); + if (declared !== null) { + const length = Number(declared); + if (!Number.isSafeInteger(length) || length < 0) throw new TypeError("Invalid content length"); + if (length > MAX_DELETE_REQUEST_BYTES) { + await request.body.cancel().catch(() => undefined); + throw new RequestLimitError(); + } + } + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_DELETE_REQUEST_BYTES) { + await reader.cancel().catch(() => undefined); + throw new RequestLimitError(); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(bytes)) as unknown; +} + +function statusSecretHash(secret: string): string { + return createHash("sha256").update(secret, "ascii").digest("hex"); +} + +function parseOperationCredentials(value: unknown): { operationId: string; statusSecret: string } { + if (!isRecord(value) || !exactKeys(value, ["operationId", "statusSecret"]) + || typeof value.operationId !== "string" || !OPERATION_ID_PATTERN.test(value.operationId) + || typeof value.statusSecret !== "string" || !SECRET_PATTERN.test(value.statusSecret)) { + throw new TypeError("Invalid deletion operation credentials"); + } + return { operationId: value.operationId, statusSecret: value.statusSecret }; +} + +interface ParsedDeleteRequest { + operationId: string; + statusSecret: string; + expectedLifecycleRevision: number; + confirmation: string; + backup: { kind: "none" } | { kind: "archive"; projectRevision: string }; +} + +function parseDeleteRequest(value: unknown): ParsedDeleteRequest { + if (!isRecord(value) || !exactKeys(value, [ + "operationId", "statusSecret", "expectedLifecycleRevision", "confirmation", "backup", + ])) throw new TypeError("Invalid permanent deletion fields"); + const credentials = parseOperationCredentials({ + operationId: value.operationId, + statusSecret: value.statusSecret, + }); + if (!Number.isSafeInteger(value.expectedLifecycleRevision) + || (value.expectedLifecycleRevision as number) <= 0 + || typeof value.confirmation !== "string" || value.confirmation.length > 1_024 + || !isRecord(value.backup)) { + throw new TypeError("Invalid permanent deletion fields"); + } + let backup: ParsedDeleteRequest["backup"]; + if (value.backup.kind === "none" && exactKeys(value.backup, ["kind", "acknowledged"]) + && value.backup.acknowledged === true) { + backup = { kind: "none" }; + } else if (value.backup.kind === "archive" + && exactKeys(value.backup, ["kind", "projectRevision"]) + && typeof value.backup.projectRevision === "string" + && REVISION_PATTERN.test(value.backup.projectRevision)) { + backup = { kind: "archive", projectRevision: value.backup.projectRevision }; + } else { + throw new TypeError("Invalid permanent deletion backup decision"); + } + return { + ...credentials, + expectedLifecycleRevision: value.expectedLifecycleRevision as number, + confirmation: value.confirmation, + backup, + }; +} + +function publicStatus(evidence: ManagedProjectDeletionEvidence): + "in-progress" | "deleted-cleanup-pending" | "deleted-complete" { + if (evidence.state === "reserved") return "in-progress"; + if (evidence.state === "cleanup-pending") return "deleted-cleanup-pending"; + return "deleted-complete"; +} + +function acceptedDeletionResponse( + request: Request, + operationId: string, + evidence: ManagedProjectDeletionEvidence, +): Response { + return Response.json({ + operationId, + status: publicStatus(evidence), + }, { + status: evidence.state === "complete" ? 200 : 202, + headers: expiredProjectSessionResponseHeaders(request), + }); +} + +function deletionFailureResponse(error: unknown): Response { + const message = error instanceof Error ? error.message : ""; + if (/project revision/i.test(message)) { + return errorResponse( + 412, + "Project changed before permanent deletion; reload and confirm again", + "project-revision-changed", + ); + } + if (/lifecycle|active|conflicting|intent|admission/i.test(message)) { + return errorResponse(409, "Permanent deletion could not start; reload and try again", "deletion-rejected"); + } + return errorResponse(500, "Permanent deletion failed", "internal"); +} + +function resolvedDependencies(dependencies: ProjectDeleteRouteDependencies) { + const config = dependencies.managedStorageConfig ?? resolveManagedProjectStorageConfig(); + const registry = dependencies.registry ?? projectSessionRegistry; + return { + config, + registry, + lifecycle: dependencies.lifecycle ?? new ManagedProjectLifecycleService(config, registry), + }; +} + +async function parseRequest(request: Request): Promise { + if (new URL(request.url).search.length > 0) { + return errorResponse(400, "Deletion credentials are accepted only in the request body", "invalid-request"); + } + const contentType = request.headers.get("content-type") ?? ""; + if (!JSON_CONTENT_TYPE_PATTERN.test(contentType)) { + return errorResponse(415, "Permanent deletion requires JSON", "unsupported-media-type"); + } + try { + return await readBoundedJson(request); + } catch (error) { + return error instanceof RequestLimitError + ? errorResponse(413, "Permanent deletion request exceeds the byte limit", "limit") + : errorResponse(400, "Permanent deletion request is not valid JSON", "invalid-request"); + } +} + +export async function handleProjectDeletePost( + request: Request, + dependencies: ProjectDeleteRouteDependencies = {}, +): Promise { + const raw = await parseRequest(request); + if (raw instanceof Response) return raw; + let parsed: ParsedDeleteRequest; + try { + parsed = parseDeleteRequest(raw); + } catch { + return errorResponse(400, "Permanent deletion fields are invalid", "invalid-request"); + } + const resolved = resolvedDependencies(dependencies); + const response = await withProjectVersionedReadRequest( + request, + async (session, expectedRevision, lifecycleLock) => { + if (session.descriptor.sourceKind !== "managed-project" + || !session.descriptor.capabilities.deleteProjectPermanently) { + return errorResponse(409, "Permanent deletion is available only for managed projects", "managed-project-required"); + } + if (session.descriptor.lifecycleRevision !== parsed.expectedLifecycleRevision) { + return errorResponse(409, "Project lifecycle changed; reload before deleting", "lifecycle-changed"); + } + if (parsed.confirmation !== session.descriptor.name) { + return errorResponse(400, "Project name confirmation does not match", "confirmation-mismatch"); + } + if (parsed.backup.kind === "archive" && parsed.backup.projectRevision !== expectedRevision) { + return errorResponse(412, "Project changed after archive export; create a new archive", "archive-stale"); + } + const hashedSecret = statusSecretHash(parsed.statusSecret); + const deletionInput = { + id: session.projectId, + expectedLifecycleRevision: parsed.expectedLifecycleRevision, + expectedProjectRevision: expectedRevision, + operationId: parsed.operationId, + statusSecretHash: hashedSecret, + }; + try { + const evidence = await resolved.lifecycle.permanentlyDelete(deletionInput, lifecycleLock); + return acceptedDeletionResponse(request, parsed.operationId, evidence); + } catch (error) { + let evidence: ManagedProjectDeletionEvidence | undefined; + try { + evidence = await resolved.lifecycle.reconcileDeletionOperation({ + operationId: parsed.operationId, + statusSecretHash: hashedSecret, + }, lifecycleLock); + } catch { + return errorResponse(500, "Permanent deletion status is temporarily unavailable", "internal"); + } + if (evidence) { + return acceptedDeletionResponse(request, parsed.operationId, evidence); + } + return deletionFailureResponse(error); + } + }, + { + registry: resolved.registry, + managedStorageConfig: resolved.config, + managedReadGuard: dependencies.managedReadGuard, + }, + ); + response.headers.set("cache-control", "no-store"); + return response; +} + +export async function handleProjectDeleteOperationPost( + request: Request, + dependencies: ProjectDeleteOperationRouteDependencies = {}, +): Promise { + const raw = await parseRequest(request); + if (raw instanceof Response) return raw; + let credentials: ReturnType; + try { + credentials = parseOperationCredentials(raw); + } catch { + return errorResponse(400, "Deletion operation credentials are invalid", "invalid-request"); + } + const config = dependencies.managedStorageConfig ?? resolveManagedProjectStorageConfig(); + const lifecycle = dependencies.lifecycle ?? new ManagedProjectLifecycleService( + config, + dependencies.registry ?? projectSessionRegistry, + ); + let evidence: ManagedProjectDeletionEvidence | undefined; + try { + evidence = await lifecycle.reconcileDeletionOperation({ + operationId: credentials.operationId, + statusSecretHash: statusSecretHash(credentials.statusSecret), + }); + } catch { + return errorResponse(500, "Deletion status is temporarily unavailable", "internal"); + } + if (!evidence) { + return Response.json({ status: "expired-or-unknown" }, { + status: 404, + headers: { "cache-control": "no-store" }, + }); + } + return Response.json({ + operationId: credentials.operationId, + status: publicStatus(evidence), + }, { + status: evidence.state === "complete" ? 200 : 202, + headers: { "cache-control": "no-store" }, + }); +} diff --git a/apps/web/app/api/project-delete/operation/route.ts b/apps/web/app/api/project-delete/operation/route.ts new file mode 100644 index 00000000..3e5e9e4f --- /dev/null +++ b/apps/web/app/api/project-delete/operation/route.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleProjectDeleteOperationPost } from "@web/app/api/project-delete/handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + return handleProjectDeleteOperationPost(request); +} diff --git a/apps/web/app/api/project-delete/route.test.ts b/apps/web/app/api/project-delete/route.test.ts new file mode 100644 index 00000000..d7915cf0 --- /dev/null +++ b/apps/web/app/api/project-delete/route.test.ts @@ -0,0 +1,417 @@ +// SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readProjectSnapshot } from "@screenshot-studio/core/storage"; +import { resolveManagedProjectStorageConfig } from "@web/lib/managed-project-config.server.js"; +import { ManagedProjectLifecycleService } from "@web/lib/managed-project-lifecycle.server.js"; +import { + ManagedProjectNotFoundError, + ManagedProjectRepository, +} from "@web/lib/managed-project-repository.server.js"; +import { + ManagedProjectSourceAdapter, + ProjectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { + handleProjectDeleteOperationPost, + handleProjectDeletePost, + type ProjectDeleteRouteDependencies, +} from "./handler.js"; + +const PROJECT_ID = "project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const OPERATION_ID = "operation_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const STATUS_SECRET = "3".repeat(64); +const PROJECT_REVISION = "4".repeat(64); +const roots: string[] = []; +const noReadGuard: NonNullable = ( + _session, + operation, +) => operation(); + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +function projectJson(): string { + return JSON.stringify({ + meta: { name: "Delete me", schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ + id: "primary", + name: "Primary", + mode: "isolated", + screens: [{ + id: "hero", name: "Hero", platform: "apple", device: "iphone", locale: "en", + artboard: { width: 660, height: 1434 }, + background: { type: "solid", color: "#000000" }, elements: [], + }], + }], + }); +} + +function registry(sourceKind: "managed-project" | "local-file" = "managed-project") { + const value = new ProjectSessionRegistry({ + createSessionId: () => "delete-session", + managedSource: { + kind: "managed-project", + open: vi.fn(async () => ({ + name: "Delete me", + projectId: PROJECT_ID, + projectPath: "/private/managed/app-store-screenshots.json", + lifecycleRevision: 7, + capabilities: { + writeInPlace: true, + revealLocation: false, + watchExternalChanges: false, + nativePicker: false, + projectLocalResources: true, + exportProjectArchive: true, + deleteProjectPermanently: true, + }, + })), + }, + }); + const session = value.openManagedRecord({ + id: PROJECT_ID, + name: "Delete me", + projectDirectory: "/private/managed", + projectPath: "/private/managed/app-store-screenshots.json", + lifecycleRevision: 7, + }); + if (sourceKind === "local-file") { + session.descriptor.sourceKind = "local-file"; + session.descriptor.capabilities.deleteProjectPermanently = false; + } + return { registry: value, session }; +} + +function request( + sessionId: string, + body: Record = {}, + url = "http://localhost/api/project-delete", +) { + return new Request(url, { + method: "POST", + headers: { + cookie: `screenshot_studio_project_session=${sessionId}`, + "content-type": "application/json", + "if-match": `"${PROJECT_REVISION}"`, + "x-project-id": PROJECT_ID, + }, + body: JSON.stringify({ + operationId: OPERATION_ID, + statusSecret: STATUS_SECRET, + expectedLifecycleRevision: 7, + confirmation: "Delete me", + backup: { kind: "none", acknowledged: true }, + ...body, + }), + }); +} + +function completeEvidence() { + return { + operationId: OPERATION_ID, + operation: "delete" as const, + state: "complete" as const, + projectId: PROJECT_ID, + lifecycleRevision: 7, + statusSecretHash: createHash("sha256").update(STATUS_SECRET).digest("hex"), + projectRevision: PROJECT_REVISION, + completedAt: "2026-07-19T00:00:00.000Z", + }; +} + +describe("POST /api/project-delete", () => { + it("permanently deletes the exact managed session and remains queryable only by operation secret", async () => { + const root = await mkdtemp(join(tmpdir(), "project-delete-route-")); + roots.push(root); + const config = resolveManagedProjectStorageConfig({ SCREENSHOT_STUDIO_DATA_ROOT: root }); + const repository = new ManagedProjectRepository(config); + const stage = await repository.createStage(); + await writeFile(join(stage.path, "app-store-screenshots.json"), projectJson()); + const project = await repository.publishStage({ token: stage.token }); + const registry = new ProjectSessionRegistry({ + createSessionId: () => "real-delete-session", + managedSource: new ManagedProjectSourceAdapter(repository), + }); + const session = await registry.openManaged(project.id); + const revision = (await readProjectSnapshot(project.projectPath)).revision; + const lifecycle = new ManagedProjectLifecycleService(config, registry); + const deleteRequest = new Request("http://localhost/api/project-delete", { + method: "POST", + headers: { + cookie: `screenshot_studio_project_session=${session.descriptor.sessionId}`, + "content-type": "application/json", + "if-match": `"${revision}"`, + "x-project-id": project.id, + }, + body: JSON.stringify({ + operationId: OPERATION_ID, + statusSecret: STATUS_SECRET, + expectedLifecycleRevision: project.lifecycleRevision, + confirmation: "Delete me", + backup: { kind: "none", acknowledged: true }, + }), + }); + + const deleted = await handleProjectDeletePost(deleteRequest, { + registry, + lifecycle, + managedStorageConfig: config, + }); + + expect(deleted.status).toBe(200); + expect(deleted.headers.get("set-cookie")).toContain("screenshot_studio_project_session="); + expect(deleted.headers.get("set-cookie")).toContain("screenshot_studio_managed_project="); + expect(deleted.headers.get("set-cookie")).toContain("Max-Age=0"); + expect(await deleted.json()).toEqual({ operationId: OPERATION_ID, status: "deleted-complete" }); + expect(registry.peek(session.descriptor.sessionId)).toBeUndefined(); + await expect(repository.open(project.id)).rejects.toBeInstanceOf(ManagedProjectNotFoundError); + + const status = await handleProjectDeleteOperationPost(new Request( + "http://localhost/api/project-delete/operation", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operationId: OPERATION_ID, statusSecret: STATUS_SECRET }), + }, + ), { lifecycle, managedStorageConfig: config }); + expect(status.status).toBe(200); + expect(await status.json()).toEqual({ operationId: OPERATION_ID, status: "deleted-complete" }); + }); + + it("dispatches an exact confirmed managed deletion with a server-side secret hash", async () => { + const input = registry(); + const lifecycle = { + permanentlyDelete: vi.fn(async () => completeEvidence()), + reconcileDeletionOperation: vi.fn(), + }; + + const response = await handleProjectDeletePost(request(input.session.descriptor.sessionId), { + registry: input.registry, + lifecycle, + managedReadGuard: noReadGuard, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ operationId: OPERATION_ID, status: "deleted-complete" }); + expect(lifecycle.permanentlyDelete).toHaveBeenCalledWith(expect.objectContaining({ + id: PROJECT_ID, + expectedLifecycleRevision: 7, + expectedProjectRevision: PROJECT_REVISION, + operationId: OPERATION_ID, + statusSecretHash: createHash("sha256").update(STATUS_SECRET).digest("hex"), + }), undefined); + expect(JSON.stringify(lifecycle.permanentlyDelete.mock.calls)).not.toContain(STATUS_SECRET); + }); + + it("rejects local-file capability, stale lifecycle, confirmation, and archive evidence", async () => { + const local = registry("local-file"); + const managed = registry(); + const lifecycle = { permanentlyDelete: vi.fn(), reconcileDeletionOperation: vi.fn() }; + const dependencies = { + lifecycle, + managedReadGuard: noReadGuard, + }; + + const localResponse = await handleProjectDeletePost(request(local.session.descriptor.sessionId), { + ...dependencies, + registry: local.registry, + }); + const staleLifecycle = await handleProjectDeletePost(request(managed.session.descriptor.sessionId, { + expectedLifecycleRevision: 8, + }), { ...dependencies, registry: managed.registry }); + const wrongConfirmation = await handleProjectDeletePost(request(managed.session.descriptor.sessionId, { + confirmation: "delete", + }), { ...dependencies, registry: managed.registry }); + const staleArchive = await handleProjectDeletePost(request(managed.session.descriptor.sessionId, { + backup: { kind: "archive", projectRevision: "5".repeat(64) }, + }), { ...dependencies, registry: managed.registry }); + + expect(localResponse.status).toBe(409); + expect(staleLifecycle.status).toBe(409); + expect(wrongConfirmation.status).toBe(400); + expect(staleArchive.status).toBe(412); + expect(lifecycle.permanentlyDelete).not.toHaveBeenCalled(); + }); + + it("rejects malformed or URL-carried credentials before lifecycle lookup", async () => { + const input = registry(); + const lifecycle = { permanentlyDelete: vi.fn(), reconcileDeletionOperation: vi.fn() }; + const dependencies = { + registry: input.registry, + lifecycle, + managedReadGuard: noReadGuard, + }; + const invalidBodies = [ + { operationId: "../escape" }, + { statusSecret: "é".repeat(64) }, + { statusSecretHash: "6".repeat(64) }, + { unknown: true }, + { backup: { kind: "none", acknowledged: false } }, + ]; + for (const body of invalidBodies) { + const response = await handleProjectDeletePost(request(input.session.descriptor.sessionId, body), dependencies); + expect(response.status).toBe(400); + expect(response.headers.get("cache-control")).toBe("no-store"); + } + const query = await handleProjectDeletePost(request( + input.session.descriptor.sessionId, + {}, + `http://localhost/api/project-delete?statusSecret=${STATUS_SECRET}`, + ), dependencies); + expect(query.status).toBe(400); + const lookalikeContentType = await handleProjectDeletePost(new Request( + "http://localhost/api/project-delete", + { + method: "POST", + headers: { "content-type": "application/jsonp" }, + body: JSON.stringify({}), + }, + ), dependencies); + expect(lookalikeContentType.status).toBe(415); + expect(lifecycle.permanentlyDelete).not.toHaveBeenCalled(); + }); + + it("returns committed cleanup status when the terminal call loses its response", async () => { + const input = registry(); + const pending = { + ...completeEvidence(), + state: "cleanup-pending" as const, + sourceIdentity: { device: "1", inode: "2" }, + }; + delete (pending as { completedAt?: string }).completedAt; + const lifecycle = { + permanentlyDelete: vi.fn(async () => { throw new Error(`/private/${STATUS_SECRET}`); }), + reconcileDeletionOperation: vi.fn(async () => pending), + }; + + const response = await handleProjectDeletePost(request(input.session.descriptor.sessionId), { + registry: input.registry, + lifecycle, + managedReadGuard: noReadGuard, + }); + + expect(response.status).toBe(202); + expect(response.headers.get("set-cookie")).toContain("screenshot_studio_project_session="); + expect(response.headers.get("set-cookie")).toContain("Max-Age=0"); + const body = await response.json(); + expect(body).toEqual({ + operationId: OPERATION_ID, + status: "deleted-cleanup-pending", + }); + expect(JSON.stringify(body)).not.toContain(STATUS_SECRET); + }); + + it("maps a pre-admission Project revision race to a redacted 412", async () => { + const input = registry(); + const lifecycle = { + permanentlyDelete: vi.fn(async () => { + throw new Error(`/private/project: Project revision changed before deletion admission ${STATUS_SECRET}`); + }), + reconcileDeletionOperation: vi.fn(async () => undefined), + }; + const response = await handleProjectDeletePost(request(input.session.descriptor.sessionId), { + registry: input.registry, + lifecycle, + managedReadGuard: noReadGuard, + }); + + expect(response.status).toBe(412); + const body = await response.json(); + expect(body).toEqual({ + error: "Project changed before permanent deletion; reload and confirm again", + code: "project-revision-changed", + }); + expect(JSON.stringify(body)).not.toContain("/private"); + expect(JSON.stringify(body)).not.toContain(STATUS_SECRET); + }); +}); + +describe("POST /api/project-delete operation status", () => { + it("reconciles without a project session and never places credentials in the URL", async () => { + const lifecycle = { + reconcileDeletionOperation: vi.fn(async () => completeEvidence()), + }; + const response = await handleProjectDeleteOperationPost(new Request( + "http://localhost/api/project-delete/operation", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operationId: OPERATION_ID, statusSecret: STATUS_SECRET }), + }, + ), { lifecycle }); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ operationId: OPERATION_ID, status: "deleted-complete" }); + expect(lifecycle.reconcileDeletionOperation).toHaveBeenCalledWith({ + operationId: OPERATION_ID, + statusSecretHash: createHash("sha256").update(STATUS_SECRET).digest("hex"), + }); + }); + + it("returns one expired-or-unknown result for missing or unauthorized operations", async () => { + const lifecycle = { reconcileDeletionOperation: vi.fn(async () => undefined) }; + const response = await handleProjectDeleteOperationPost(new Request( + "http://localhost/api/project-delete/operation", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operationId: OPERATION_ID, statusSecret: STATUS_SECRET }), + }, + ), { lifecycle }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ status: "expired-or-unknown" }); + }); + + it("rejects URL credentials and redacts reconciliation failures", async () => { + const lifecycle = { + reconcileDeletionOperation: vi.fn(async () => { + throw new Error(`/private/tombstone/${STATUS_SECRET}`); + }), + }; + const query = await handleProjectDeleteOperationPost(new Request( + `http://localhost/api/project-delete/operation?statusSecret=${STATUS_SECRET}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operationId: OPERATION_ID, statusSecret: STATUS_SECRET }), + }, + ), { lifecycle }); + expect(query.status).toBe(400); + expect(lifecycle.reconcileDeletionOperation).not.toHaveBeenCalled(); + + const lookalikeContentType = await handleProjectDeleteOperationPost(new Request( + "http://localhost/api/project-delete/operation", + { + method: "POST", + headers: { "content-type": "application/json-malformed" }, + body: JSON.stringify({ operationId: OPERATION_ID, statusSecret: STATUS_SECRET }), + }, + ), { lifecycle }); + expect(lookalikeContentType.status).toBe(415); + expect(lifecycle.reconcileDeletionOperation).not.toHaveBeenCalled(); + + const failed = await handleProjectDeleteOperationPost(new Request( + "http://localhost/api/project-delete/operation", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operationId: OPERATION_ID, statusSecret: STATUS_SECRET }), + }, + ), { lifecycle }); + expect(failed.status).toBe(500); + const body = await failed.json(); + expect(body).toEqual({ error: "Deletion status is temporarily unavailable", code: "internal" }); + expect(JSON.stringify(body)).not.toContain("/private"); + expect(JSON.stringify(body)).not.toContain(STATUS_SECRET); + }); +}); diff --git a/apps/web/app/api/project-delete/route.ts b/apps/web/app/api/project-delete/route.ts new file mode 100644 index 00000000..518e8ed7 --- /dev/null +++ b/apps/web/app/api/project-delete/route.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleProjectDeletePost } from "./handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + return handleProjectDeletePost(request); +} diff --git a/apps/web/app/api/project-import/handler.ts b/apps/web/app/api/project-import/handler.ts new file mode 100644 index 00000000..454f1238 --- /dev/null +++ b/apps/web/app/api/project-import/handler.ts @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + ManagedProjectImportError, + ManagedProjectImportService, + type ManagedProjectImportKind, + type ProjectImportFile, +} from "@web/lib/managed-project-import.server.js"; +import { resolveManagedProjectStorageConfig } from "@web/lib/managed-project-config.server.js"; +import { ManagedProjectRepository } from "@web/lib/managed-project-repository.server.js"; +import { + projectSessionRegistry, + type ProjectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { + recentProjectsStore, + type RecentProjectsStore, +} from "@web/lib/recent-projects.server.js"; +import { ProjectZipError, readProjectZipFiles } from "@web/lib/project-zip.server.js"; +import { projectSessionResponseHeaders } from "@web/app/api/project-session/handler.js"; + +export interface ProjectImportRouteDependencies { + repository?: ManagedProjectRepository; + registry?: ProjectSessionRegistry; + recentProjects?: Pick; +} + +class UploadLimitError extends Error {} + +function errorResponse( + status: number, + error: string, + code: string, + missingResources: readonly string[] = [], +): Response { + return Response.json({ + error, + code, + ...(missingResources.length === 0 ? {} : { missingResources }), + }, { status, headers: { "cache-control": "no-store" } }); +} + +function isUpload(value: FormDataEntryValue): value is File { + return typeof value !== "string" && typeof value.stream === "function" + && typeof value.size === "number"; +} + +function uploadFile(path: string, file: File): ProjectImportFile { + return { + relativePath: path, + declaredSize: file.size, + async *open() { + const reader = file.stream().getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + yield value; + } + } finally { + reader.releaseLock(); + } + }, + }; +} + +function asImportKind(value: FormDataEntryValue | null): ManagedProjectImportKind | null { + return value === "folder" || value === "archive" || value === "json" ? value : null; +} + +function statusForImportError(error: ManagedProjectImportError): number { + if (error.code === "cancelled") return 499; + if (error.code === "limit") return 413; + if (error.code === "invalid-project" || error.code === "missing-resources") return 422; + return 400; +} + +function publicImportErrorMessage(error: ManagedProjectImportError): string { + return error.code === "invalid-project" + ? "Project document failed strict validation" + : error.message; +} + +async function boundedFormData(request: Request, maxBytes: number): Promise { + if (!request.body) throw new TypeError("Project import request body is missing"); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let bytesRead = 0; + let exceeded = false; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytesRead += value.byteLength; + if (bytesRead > maxBytes) { + exceeded = true; + } else if (!exceeded) { + chunks.push(value); + } + } + } finally { + reader.releaseLock(); + } + if (exceeded) throw new UploadLimitError("Project import upload exceeds the configured byte limit"); + const boundedBody = new Uint8Array(bytesRead); + let offset = 0; + for (const chunk of chunks) { + boundedBody.set(chunk, offset); + offset += chunk.byteLength; + } + const boundedRequest = new Request(request.url, { + method: request.method, + headers: request.headers, + body: boundedBody, + signal: request.signal, + }); + return boundedRequest.formData(); +} + +export async function handleProjectImportPost( + request: Request, + dependencies: ProjectImportRouteDependencies = {}, +): Promise { + const repository = dependencies.repository + ?? new ManagedProjectRepository(resolveManagedProjectStorageConfig()); + const registry = dependencies.registry ?? projectSessionRegistry; + const contentType = request.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().startsWith("multipart/form-data;")) { + return errorResponse(415, "Project import requires multipart form data", "unsupported-media-type"); + } + const declaredRequestBytes = Number(request.headers.get("content-length")); + if (Number.isFinite(declaredRequestBytes) + && declaredRequestBytes > repository.config.maxImportUploadBytes) { + return errorResponse(413, "Project import upload exceeds the configured byte limit", "limit"); + } + if (request.signal.aborted) { + return errorResponse(499, "Project import was cancelled", "cancelled"); + } + + let form: FormData; + try { + form = await boundedFormData(request, repository.config.maxImportUploadBytes); + } catch (error) { + if (request.signal.aborted) return errorResponse(499, "Project import was cancelled", "cancelled"); + if (error instanceof UploadLimitError + || (error instanceof Error && error.cause instanceof UploadLimitError)) { + return errorResponse(413, "Project import upload exceeds the configured byte limit", "limit"); + } + return errorResponse(400, `Project import form data is invalid: ${error instanceof Error ? error.message : String(error)}`, "invalid-request"); + } + const kind = asImportKind(form.get("kind")); + const fileValues = form.getAll("files"); + const pathValues = form.getAll("paths"); + if (!kind || fileValues.some((value) => !isUpload(value)) + || pathValues.some((value) => typeof value !== "string")) { + return errorResponse(400, "Project import fields are invalid", "invalid-request"); + } + const files = fileValues as File[]; + const paths = pathValues as string[]; + if (files.length === 0 || paths.length !== files.length + || (kind !== "folder" && files.length !== 1)) { + return errorResponse(400, "Project import files and paths must be aligned", "invalid-request"); + } + const uploadBytes = files.reduce((total, file) => total + file.size, 0); + if (!Number.isSafeInteger(uploadBytes) || uploadBytes > repository.config.maxImportUploadBytes) { + return errorResponse(413, "Project import upload exceeds the configured byte limit", "limit"); + } + + try { + const normalizedFiles = kind === "archive" + ? await readProjectZipFiles(new Uint8Array(await files[0]!.arrayBuffer()), { + maxFiles: repository.config.maxProjectFiles, + maxBytes: repository.config.maxProjectBytes, + }) + : files.map((file, index) => uploadFile(paths[index]!, file)); + if (request.signal.aborted) { + throw new ManagedProjectImportError("Project import was cancelled", "cancelled"); + } + const record = await new ManagedProjectImportService(repository).import({ + kind, + files: normalizedFiles, + signal: request.signal, + }); + const session = registry.openManagedRecord(record); + await (dependencies.recentProjects ?? recentProjectsStore).record(session).catch(() => undefined); + return Response.json({ project: session.descriptor }, { + status: 201, + headers: projectSessionResponseHeaders(session, request), + }); + } catch (error) { + if (error instanceof ManagedProjectImportError) { + return errorResponse( + statusForImportError(error), + publicImportErrorMessage(error), + error.code, + error.missingResources, + ); + } + if (error instanceof ProjectZipError) { + return errorResponse(error.code === "limit" ? 413 : 400, error.message, error.code); + } + return errorResponse(500, "Project import failed", "internal"); + } +} diff --git a/apps/web/app/api/project-import/route.test.ts b/apps/web/app/api/project-import/route.test.ts new file mode 100644 index 00000000..8cc49877 --- /dev/null +++ b/apps/web/app/api/project-import/route.test.ts @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: Apache-2.0 +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import JSZip from "jszip"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveManagedProjectStorageConfig } from "@web/lib/managed-project-config.server.js"; +import { ManagedProjectRepository } from "@web/lib/managed-project-repository.server.js"; +import { ProjectSessionRegistry } from "@web/lib/project-session.server.js"; +import { handleProjectImportPost } from "./handler.js"; +import { POST } from "./route.js"; + +let root: string; +let repository: ManagedProjectRepository; +let registry: ProjectSessionRegistry; + +function projectJson(name = "Imported"): string { + return JSON.stringify({ + meta: { name, schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ + id: "primary", + name: "Primary", + mode: "isolated", + screens: [{ + id: "hero", + name: "Hero", + platform: "apple", + device: "iphone", + locale: "en", + artboard: { width: 660, height: 1434 }, + background: { type: "solid", color: "#000000" }, + elements: [], + }], + }], + }); +} + +function projectJsonWithAsset(path: string): string { + const project = JSON.parse(projectJson("With asset")) as { + layouts: Array<{ screens: Array<{ elements: unknown[] }> }>; + }; + project.layouts[0]!.screens[0]!.elements = [{ + id: "shot", + type: "screenshot", + x: 0, + y: 0, + width: 100, + height: 100, + assetUrl: path, + fit: "cover", + cornerRadius: 0, + }]; + return JSON.stringify(project); +} + +function uploadRequest(kind: "folder" | "archive" | "json", files: Array<[string, Blob, string]>): Request { + const form = new FormData(); + form.set("kind", kind); + for (const [path, blob, filename] of files) { + form.append("paths", path); + form.append("files", blob, filename); + } + return new Request("http://localhost/api/project-import", { method: "POST", body: form }); +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "project-import-route-")); + repository = new ManagedProjectRepository(resolveManagedProjectStorageConfig({ + SCREENSHOT_STUDIO_DATA_ROOT: root, + SCREENSHOT_STUDIO_MAX_PROJECT_FILES: "20", + SCREENSHOT_STUDIO_MAX_PROJECT_BYTES: "100000", + SCREENSHOT_STUDIO_MAX_IMPORT_UPLOAD_BYTES: "100000", + })); + registry = new ProjectSessionRegistry({ createSessionId: () => "imported-session" }); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("POST /api/project-import", () => { + it("imports standalone JSON into a managed copy and activates its opaque session", async () => { + const recordRecent = vi.fn(async () => undefined); + const response = await handleProjectImportPost(uploadRequest("json", [ + ["chosen.json", new Blob([projectJson("JSON project")], { type: "application/json" }), "chosen.json"], + ]), { repository, registry, recentProjects: { record: recordRecent } }); + + expect(response.status).toBe(201); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("set-cookie")).toContain( + "screenshot_studio_project_session=imported-session", + ); + expect(response.headers.get("set-cookie")).toContain( + "screenshot_studio_managed_project=project_", + ); + expect(await response.json()).toMatchObject({ + project: { + sessionId: "imported-session", + name: "JSON project", + sourceKind: "managed-project", + capabilities: { revealLocation: false }, + }, + }); + await expect(repository.list()).resolves.toHaveLength(1); + expect(recordRecent).toHaveBeenCalledOnce(); + expect(recordRecent).toHaveBeenCalledWith(expect.objectContaining({ + descriptor: expect.objectContaining({ name: "JSON project" }), + })); + }); + + it("imports a bounded ZIP through the same managed-copy pipeline", async () => { + const zip = new JSZip(); + zip.file("app-store-screenshots.json", projectJson("Archive project")); + zip.file("AGENTS.md", "# Guidance\n"); + const bytes = await zip.generateAsync({ type: "uint8array", compression: "DEFLATE" }); + + const response = await handleProjectImportPost(uploadRequest("archive", [ + ["project.zip", new Blob([new Uint8Array(bytes)], { type: "application/zip" }), "project.zip"], + ]), { repository, registry }); + + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ project: { name: "Archive project" } }); + await expect(repository.list()).resolves.toHaveLength(1); + }); + + it("preserves aligned browser folder paths and strips one selected root", async () => { + const response = await handleProjectImportPost(uploadRequest("folder", [ + ["Selected/app-store-screenshots.json", new Blob([projectJsonWithAsset("screenshots/hero.png")]), "app-store-screenshots.json"], + ["Selected/screenshots/hero.png", new Blob([new Uint8Array([1, 2, 3])]), "hero.png"], + ]), { repository, registry }); + + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ project: { name: "With asset" } }); + }); + + it("returns missing resources without publishing or changing the cookie", async () => { + const response = await handleProjectImportPost(uploadRequest("json", [ + ["chosen.json", new Blob([projectJsonWithAsset("screenshots/missing.png")]), "chosen.json"], + ]), { repository, registry }); + + expect(response.status).toBe(422); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(await response.json()).toMatchObject({ + code: "missing-resources", + missingResources: ["screenshots/missing.png"], + }); + await expect(repository.list()).resolves.toEqual([]); + }); + + it("maps browser cancellation without parsing, publication, or cookie mutation", async () => { + const controller = new AbortController(); + controller.abort(); + const form = new FormData(); + form.set("kind", "json"); + form.set("paths", "chosen.json"); + form.set("files", new Blob([projectJson()]), "chosen.json"); + const request = new Request("http://localhost/api/project-import", { + method: "POST", + body: form, + signal: controller.signal, + }); + + const response = await handleProjectImportPost(request, { repository, registry }); + + expect(response.status).toBe(499); + expect(response.headers.get("set-cookie")).toBeNull(); + await expect(repository.list()).resolves.toEqual([]); + }); + + it("preserves the prior session and catalog on validation failure", async () => { + const previous = registry.openManagedRecord({ + id: "project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + name: "Previous", + projectDirectory: "/private/previous", + projectPath: "/private/previous/app-store-screenshots.json", + lifecycleRevision: 1, + }); + const invalid = JSON.stringify({ unknown: true }); + const request = uploadRequest("json", [ + ["bad.json", new Blob([invalid], { type: "application/json" }), "bad.json"], + ]); + request.headers.set("cookie", `screenshot_studio_project_session=${previous.descriptor.sessionId}`); + + const response = await handleProjectImportPost(request, { repository, registry }); + + expect(response.status).toBe(422); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(registry.get(previous.descriptor.sessionId)).toBe(previous); + await expect(repository.list()).resolves.toEqual([]); + }); + + it("rejects malformed requests and upload limits without activation", async () => { + const wrongMedia = await handleProjectImportPost(new Request( + "http://localhost/api/project-import", + { method: "POST", body: "json" }, + ), { repository, registry }); + expect(wrongMedia.status).toBe(415); + + const oversized = uploadRequest("json", [ + ["project.json", new Blob([projectJson()]), "project.json"], + ]); + oversized.headers.set("content-length", "100001"); + const limited = await handleProjectImportPost(oversized, { repository, registry }); + expect(limited.status).toBe(413); + expect(limited.headers.get("set-cookie")).toBeNull(); + + const streamLimitedRepository = new ManagedProjectRepository(resolveManagedProjectStorageConfig({ + SCREENSHOT_STUDIO_DATA_ROOT: join(root, "stream-limited"), + SCREENSHOT_STUDIO_MAX_PROJECT_FILES: "20", + SCREENSHOT_STUDIO_MAX_PROJECT_BYTES: "100000", + SCREENSHOT_STUDIO_MAX_IMPORT_UPLOAD_BYTES: "100", + })); + const unknownLength = uploadRequest("json", [ + ["project.json", new Blob([projectJson()]), "project.json"], + ]); + expect(unknownLength.headers.get("content-length")).toBeNull(); + const streamedLimit = await handleProjectImportPost(unknownLength, { + repository: streamLimitedRepository, + registry, + }); + expect(streamedLimit.status).toBe(413); + }); + + it("wires the Next route without exposing any path activation endpoint", () => { + expect(POST).toBeTypeOf("function"); + }); +}); diff --git a/apps/web/app/api/project-import/route.ts b/apps/web/app/api/project-import/route.ts new file mode 100644 index 00000000..2963a908 --- /dev/null +++ b/apps/web/app/api/project-import/route.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleProjectImportPost } from "./handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + return handleProjectImportPost(request); +} diff --git a/apps/web/app/api/project-library/asset/handler.test.ts b/apps/web/app/api/project-library/asset/handler.test.ts new file mode 100644 index 00000000..b3930f75 --- /dev/null +++ b/apps/web/app/api/project-library/asset/handler.test.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it, vi } from "vitest"; +import { handleProjectLibraryAssetGet } from "./handler.js"; + +const entryId = "project_00000000000000000000000000000001"; + +describe("project library asset handler", () => { + it("resolves an opaque entry server-side and serves only the requested relative asset", async () => { + const resolveProjectPath = vi.fn(async () => "/private/managed/project/app-store-screenshots.json"); + const serveAsset = vi.fn(async () => new Response("png", { + headers: { "content-type": "image/png" }, + })); + const response = await handleProjectLibraryAssetGet(new Request( + `http://localhost/api/project-library/asset?entryId=${entryId}&path=assets%2Fhero.png`, + ), { resolveProjectPath } as never, serveAsset); + + expect(response.status).toBe(200); + expect(resolveProjectPath).toHaveBeenCalledWith(entryId); + expect(serveAsset).toHaveBeenCalledWith( + "/private/managed/project/app-store-screenshots.json", + "assets/hero.png", + ); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.headers.get("vary")).toContain("Cookie"); + }); + + it("rejects missing, malformed, and unavailable entries without exposing paths", async () => { + const service = { resolveProjectPath: vi.fn(async () => { throw new Error("/private/secret"); }) }; + const missing = await handleProjectLibraryAssetGet(new Request( + "http://localhost/api/project-library/asset?path=assets%2Fhero.png", + ), service as never, vi.fn()); + expect(missing.status).toBe(400); + + const unavailable = await handleProjectLibraryAssetGet(new Request( + `http://localhost/api/project-library/asset?entryId=${entryId}&path=assets%2Fhero.png`, + ), service as never, vi.fn()); + expect(unavailable.status).toBe(404); + expect(await unavailable.text()).not.toContain("/private/secret"); + }); +}); diff --git a/apps/web/app/api/project-library/asset/handler.ts b/apps/web/app/api/project-library/asset/handler.ts new file mode 100644 index 00000000..64fb3a09 --- /dev/null +++ b/apps/web/app/api/project-library/asset/handler.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + projectLibraryService, + type ProjectLibraryService, +} from "@web/lib/project-library.server.js"; +import { + serveProjectAssetPath, + withPrivateAssetPolicy, +} from "@web/lib/project-asset.server.js"; + +const ENTRY_ID_PATTERN = /^(?:project|recent)_[0-9a-f]{32}$/; + +export async function handleProjectLibraryAssetGet( + request: Request, + service: Pick = projectLibraryService, + serveAsset: typeof serveProjectAssetPath = serveProjectAssetPath, +): Promise { + const url = new URL(request.url); + const entryId = url.searchParams.get("entryId")?.trim(); + const relativePath = url.searchParams.get("path"); + const finish = (response: Response) => withPrivateAssetPolicy(response, true); + if (!entryId || !ENTRY_ID_PATTERN.test(entryId) || !relativePath) { + return finish(Response.json({ error: "entryId and path are required" }, { status: 400 })); + } + try { + const projectPath = await service.resolveProjectPath(entryId); + return finish(await serveAsset(projectPath, relativePath)); + } catch { + return finish(Response.json({ error: "project not found" }, { status: 404 })); + } +} diff --git a/apps/web/app/api/project-library/asset/route.ts b/apps/web/app/api/project-library/asset/route.ts new file mode 100644 index 00000000..d3acc0fc --- /dev/null +++ b/apps/web/app/api/project-library/asset/route.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleProjectLibraryAssetGet } from "./handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(request: Request): Promise { + return handleProjectLibraryAssetGet(request); +} diff --git a/apps/web/app/api/project-library/handler.test.ts b/apps/web/app/api/project-library/handler.test.ts new file mode 100644 index 00000000..547cecae --- /dev/null +++ b/apps/web/app/api/project-library/handler.test.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it, vi } from "vitest"; +import { + handleProjectLibraryGet, + handleProjectLibraryOpen, +} from "./handler.js"; +import type { ProjectSessionRegistry } from "@web/lib/project-session.server.js"; + +const managedId = "project_00000000000000000000000000000001"; + +describe("project library handler", () => { + it("returns the source-safe library with no-store policy", async () => { + const list = vi.fn(async () => ({ entries: [{ entryId: managedId }] })); + const response = await handleProjectLibraryGet({ list } as never); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + await expect(response.json()).resolves.toEqual({ entries: [{ entryId: managedId }] }); + }); + + it("accepts exactly one opaque entry id and activates it", async () => { + const activate = vi.fn(async () => ({ + projectId: managedId, + projectPath: "/private/not-returned/app-store-screenshots.json", + descriptor: { sessionId: "session-1" }, + })); + const request = new Request("http://localhost/api/project-library", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ entryId: managedId }), + }); + const registry = {} as ProjectSessionRegistry; + + const response = await handleProjectLibraryOpen(request, { activate } as never, registry); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(JSON.parse(body)).toEqual({ projectId: managedId }); + expect(activate).toHaveBeenCalledWith(managedId, registry); + expect(body).not.toContain("not-returned"); + }); + + it("rejects unknown fields and unavailable entries", async () => { + const activate = vi.fn(async () => { throw new Error("missing"); }); + const invalid = await handleProjectLibraryOpen(new Request("http://localhost/api/project-library", { + method: "POST", + body: JSON.stringify({ entryId: managedId, projectPath: "/private/leak" }), + }), { activate } as never, {} as ProjectSessionRegistry); + expect(invalid.status).toBe(400); + + const unavailable = await handleProjectLibraryOpen(new Request("http://localhost/api/project-library", { + method: "POST", + body: JSON.stringify({ entryId: managedId }), + }), { activate } as never, {} as ProjectSessionRegistry); + expect(unavailable.status).toBe(409); + await expect(unavailable.json()).resolves.toEqual({ + error: "This project is unavailable. Reopen or import its source to continue.", + }); + }); +}); diff --git a/apps/web/app/api/project-library/handler.ts b/apps/web/app/api/project-library/handler.ts new file mode 100644 index 00000000..6045a608 --- /dev/null +++ b/apps/web/app/api/project-library/handler.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + projectLibraryService, + type ProjectLibraryService, +} from "@web/lib/project-library.server.js"; +import { + projectSessionRegistry, + type ProjectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { projectSessionResponseHeaders } from "@web/app/api/project-session/handler.js"; + +const ENTRY_ID_PATTERN = /^(?:project|recent)_[0-9a-f]{32}$/; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function entryIdFromBody(request: Request): Promise { + const value = await request.json() as unknown; + if (!isRecord(value) || Object.keys(value).length !== 1 + || typeof value.entryId !== "string" || !ENTRY_ID_PATTERN.test(value.entryId)) { + throw new TypeError("Invalid project library request"); + } + return value.entryId; +} + +export async function handleProjectLibraryGet( + service: Pick = projectLibraryService, +): Promise { + try { + return Response.json(await service.list(), { + headers: { "cache-control": "no-store" }, + }); + } catch { + return Response.json({ + error: "Projects could not be loaded. Retry, or reopen a local project from its source.", + }, { status: 500, headers: { "cache-control": "no-store" } }); + } +} + +export async function handleProjectLibraryOpen( + request: Request, + service: Pick = projectLibraryService, + registry: ProjectSessionRegistry = projectSessionRegistry, +): Promise { + let entryId: string; + try { + entryId = await entryIdFromBody(request); + } catch { + return Response.json({ error: "A valid project entry id is required." }, { status: 400 }); + } + try { + const session = await service.activate(entryId, registry); + return Response.json({ projectId: session.projectId }, { + headers: projectSessionResponseHeaders(session, request), + }); + } catch { + return Response.json({ + error: "This project is unavailable. Reopen or import its source to continue.", + }, { status: 409, headers: { "cache-control": "no-store" } }); + } +} diff --git a/apps/web/app/api/project-library/route.ts b/apps/web/app/api/project-library/route.ts new file mode 100644 index 00000000..0e376f4d --- /dev/null +++ b/apps/web/app/api/project-library/route.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleProjectLibraryGet, handleProjectLibraryOpen } from "./handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(): Promise { + return handleProjectLibraryGet(); +} + +export async function POST(request: Request): Promise { + return handleProjectLibraryOpen(request); +} diff --git a/apps/web/app/api/project-session/handler.ts b/apps/web/app/api/project-session/handler.ts new file mode 100644 index 00000000..4345f1ee --- /dev/null +++ b/apps/web/app/api/project-session/handler.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + NoActiveProjectError, + MANAGED_PROJECT_RECOVERY_COOKIE, + PROJECT_SESSION_COOKIE, + hasManagedProjectRecoveryCookie, + projectSessionRegistry, + projectSessionErrorResponse, + resolveActiveProjectSession, + sessionIdFromRequest, + type ActiveProjectResolverDependencies, + type ProjectSession, +} from "@web/lib/project-session.server.js"; +import { + recentProjectsStore, + type RecentProjectsStore, +} from "@web/lib/recent-projects.server.js"; + +interface ProjectSessionHandlerDependencies extends ActiveProjectResolverDependencies { + recentProjects?: Pick; +} + +export function projectSessionCookieValue(sessionId: string, request: Request): string { + const secure = new URL(request.url).protocol === "https:" ? "; Secure" : ""; + return `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Lax${secure}`; +} + +function managedProjectRecoveryCookieValue(projectId: string, request: Request): string { + const secure = new URL(request.url).protocol === "https:" ? "; Secure" : ""; + return `${MANAGED_PROJECT_RECOVERY_COOKIE}=${encodeURIComponent(projectId)}; Path=/; HttpOnly; SameSite=Lax${secure}`; +} + +function expiredManagedProjectRecoveryCookie(request: Request): string { + const secure = new URL(request.url).protocol === "https:" ? "; Secure" : ""; + return `${MANAGED_PROJECT_RECOVERY_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`; +} + +function expiredProjectSessionCookie(request: Request): string { + const secure = new URL(request.url).protocol === "https:" ? "; Secure" : ""; + return `${PROJECT_SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`; +} + +export function expiredProjectSessionResponseHeaders(request: Request): Headers { + const headers = new Headers({ "cache-control": "no-store" }); + headers.append("set-cookie", expiredProjectSessionCookie(request)); + headers.append("set-cookie", expiredManagedProjectRecoveryCookie(request)); + return headers; +} + +export function projectSessionResponseHeaders( + session: ProjectSession, + request: Request, +): Headers { + const headers = new Headers({ "cache-control": "no-store" }); + headers.append( + "set-cookie", + projectSessionCookieValue(session.descriptor.sessionId, request), + ); + headers.append( + "set-cookie", + session.descriptor.sourceKind === "managed-project" + ? managedProjectRecoveryCookieValue(session.projectId, request) + : expiredManagedProjectRecoveryCookie(request), + ); + return headers; +} + +function descriptorResponse( + session: Awaited>, + request: Request, +): Response { + const headers = projectSessionResponseHeaders(session, request); + headers.set("x-project-id", session.projectId); + return Response.json(session.descriptor, { + headers, + }); +} + +export async function handleProjectSessionGet( + request: Request, + dependencies: ProjectSessionHandlerDependencies = {}, +): Promise { + try { + const registry = dependencies.registry ?? projectSessionRegistry; + const requestedSessionId = sessionIdFromRequest(request); + const existingSession = requestedSessionId ? registry.peek(requestedSessionId) : undefined; + const session = await resolveActiveProjectSession(request, dependencies); + if (session !== existingSession) { + await (dependencies.recentProjects ?? recentProjectsStore).record(session).catch(() => undefined); + } + return descriptorResponse(session, request); + } catch (error) { + if (error instanceof NoActiveProjectError) { + const headers = new Headers({ "cache-control": "no-store" }); + if (hasManagedProjectRecoveryCookie(request)) { + headers.append("set-cookie", expiredManagedProjectRecoveryCookie(request)); + } + return new Response(null, { + status: 204, + headers, + }); + } + return projectSessionErrorResponse(error); + } +} + +export async function handleProjectSessionDelete( + request: Request, + dependencies: ActiveProjectResolverDependencies = {}, +): Promise { + const expectedProjectId = request.headers.get("x-project-id")?.trim(); + if (!expectedProjectId) { + return Response.json( + { error: "X-Project-Id is required to close the active project" }, + { status: 428 }, + ); + } + + const registry = dependencies.registry ?? projectSessionRegistry; + const sessionId = sessionIdFromRequest(request); + const session = sessionId ? registry.get(sessionId) : undefined; + if (session && session.projectId !== expectedProjectId) { + return Response.json( + { error: "Active project changed; reload it before continuing" }, + { status: 409 }, + ); + } + if (sessionId && session) registry.close(sessionId, expectedProjectId); + const headers = expiredProjectSessionResponseHeaders(request); + return new Response(null, { + status: 204, + headers, + }); +} diff --git a/apps/web/app/api/project-session/route.test.ts b/apps/web/app/api/project-session/route.test.ts new file mode 100644 index 00000000..8cccc767 --- /dev/null +++ b/apps/web/app/api/project-session/route.test.ts @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: Apache-2.0 +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ManagedProjectNotFoundError } from "@web/lib/managed-project-repository.server.js"; +import { + ProjectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { + handleProjectSessionDelete, + handleProjectSessionGet, +} from "./handler.js"; +import * as projectSessionRoute from "./route.js"; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function createProject(name: string): Promise { + const root = await mkdtemp(join(tmpdir(), "project-session-route-")); + temporaryRoots.push(root); + const path = join(root, "app-store-screenshots.json"); + await writeFile(path, JSON.stringify({ + meta: { name, schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ + id: "primary", + name: "Primary", + mode: "isolated", + screens: [{ + id: "hero", + name: "Hero", + platform: "apple", + device: "iphone", + locale: "en", + artboard: { width: 660, height: 1434 }, + background: { type: "solid", color: "#000000" }, + elements: [], + }], + }], + })); + return path; +} + +function dependencies() { + return { + registry: new ProjectSessionRegistry({ createSessionId: () => "session-1" }), + configuredPath: () => null, + recentProjects: { record: async () => undefined }, + }; +} + +describe("project session route handlers", () => { + it("does not expose browser local-path activation", () => { + expect("POST" in projectSessionRoute).toBe(false); + expect("DELETE" in projectSessionRoute).toBe(true); + }); + + it("returns a cookie-neutral 204 when a fresh client has no active or configured project", async () => { + const response = await handleProjectSessionGet( + new Request("http://localhost/api/project-session"), + dependencies(), + ); + + expect(response.status).toBe(204); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + + it("expires a stale managed recovery hint that cannot be reopened", async () => { + const response = await handleProjectSessionGet(new Request( + "http://localhost/api/project-session", + { + headers: { + cookie: "screenshot_studio_managed_project=project_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + }, + ), { + registry: new ProjectSessionRegistry({ + createSessionId: () => "unused-session", + managedSource: { + kind: "managed-project", + open: async () => { throw new ManagedProjectNotFoundError(); }, + }, + }), + configuredPath: () => null, + recentProjects: { record: async () => undefined }, + }); + + expect(response.status).toBe(204); + expect(response.headers.get("set-cookie") ?? "").toContain( + "screenshot_studio_managed_project=;", + ); + expect(response.headers.get("set-cookie") ?? "").toContain("Max-Age=0"); + }); + + it("expires a malformed managed recovery hint", async () => { + const response = await handleProjectSessionGet(new Request( + "http://localhost/api/project-session", + { + headers: { + cookie: "screenshot_studio_managed_project=../../private", + }, + }, + ), dependencies()); + + expect(response.status).toBe(204); + expect(response.headers.get("set-cookie") ?? "").toContain( + "screenshot_studio_managed_project=;", + ); + expect(response.headers.get("set-cookie") ?? "").toContain("Max-Age=0"); + }); + + it("retains the recovery hint when reopening fails for a reason other than missing", async () => { + const response = await handleProjectSessionGet(new Request( + "http://localhost/api/project-session", + { + headers: { + cookie: "screenshot_studio_managed_project=project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }, + ), { + registry: new ProjectSessionRegistry({ + createSessionId: () => "unused-session", + managedSource: { + kind: "managed-project", + open: async () => { + throw new Error("EACCES: lstat '/private/managed-root/catalog.json'"); + }, + }, + }), + configuredPath: () => null, + recentProjects: { record: async () => undefined }, + }); + + expect(response.status).toBe(500); + expect(response.headers.get("set-cookie")).toBeNull(); + await expect(response.json()).resolves.toEqual({ + error: "Unable to reopen the managed project", + }); + }); + + it("leaves stale selection cookie-neutral and bootstraps an explicit configured path", async () => { + const projectPath = await createProject("Launcher"); + const registry = new ProjectSessionRegistry({ createSessionId: () => "session-1" }); + const stale = await handleProjectSessionGet(new Request( + "http://localhost/api/project-session", + { headers: { cookie: "screenshot_studio_project_session=missing" } }, + ), { registry, configuredPath: () => null, recentProjects: { record: async () => undefined } }); + + expect(stale.status).toBe(204); + expect(stale.headers.get("set-cookie")).toBeNull(); + + const configured = await handleProjectSessionGet( + new Request("http://localhost/api/project-session"), + { registry, configuredPath: () => projectPath, recentProjects: { record: async () => undefined } }, + ); + expect(configured.status).toBe(200); + expect(configured.headers.get("x-project-id")).toBeTruthy(); + expect(configured.headers.get("set-cookie")).toContain( + "screenshot_studio_project_session=session-1", + ); + expect(configured.headers.get("set-cookie")).toContain( + "screenshot_studio_managed_project=;", + ); + }); + + it("durably records a successfully resolved source without letting history failure block activation", async () => { + const projectPath = await createProject("Recorded local source"); + const registry = new ProjectSessionRegistry({ createSessionId: () => "session-recorded" }); + const record = vi.fn(async () => { throw new Error("recent history unavailable"); }); + + const response = await handleProjectSessionGet( + new Request("http://localhost/api/project-session"), + { registry, configuredPath: () => projectPath, recentProjects: { record } }, + ); + + expect(response.status).toBe(200); + expect(record).toHaveBeenCalledWith(expect.objectContaining({ + descriptor: expect.objectContaining({ sourceKind: "local-file" }), + })); + expect(response.headers.get("set-cookie")).toContain( + "screenshot_studio_project_session=session-recorded", + ); + }); + + it("does not re-add an active project to recents during an ordinary session lookup", async () => { + const projectPath = await createProject("Removed active recent"); + const registry = new ProjectSessionRegistry({ createSessionId: () => "session-existing" }); + await registry.openLocal(projectPath); + const record = vi.fn(async () => undefined); + + const response = await handleProjectSessionGet(new Request( + "http://localhost/api/project-session", + { headers: { cookie: "screenshot_studio_project_session=session-existing" } }, + ), { registry, configuredPath: () => null, recentProjects: { record } }); + + expect(response.status).toBe(200); + expect(record).not.toHaveBeenCalled(); + }); + + it("rehydrates a managed project and refreshes both activation cookies", async () => { + const projectId = "project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const registry = new ProjectSessionRegistry({ + createSessionId: () => "recovered-session", + managedSource: { + kind: "managed-project", + open: async (requestedId: string) => ({ + name: "Recovered", + projectId: requestedId, + projectPath: "/private/managed/app-store-screenshots.json", + capabilities: { + writeInPlace: true, + revealLocation: false, + watchExternalChanges: false, + nativePicker: false, + projectLocalResources: true, + exportProjectArchive: true, + deleteProjectPermanently: true, + }, + }), + }, + }); + + const response = await handleProjectSessionGet(new Request( + "https://localhost/api/project-session", + { headers: { cookie: `screenshot_studio_managed_project=${projectId}` } }, + ), { registry, configuredPath: () => null, recentProjects: { record: async () => undefined } }); + + expect(response.status).toBe(200); + expect(response.headers.get("x-project-id")).toBe(projectId); + expect(response.headers.get("set-cookie")).toContain( + "screenshot_studio_project_session=recovered-session", + ); + expect(response.headers.get("set-cookie")).toContain( + `screenshot_studio_managed_project=${projectId}`, + ); + expect(response.headers.get("set-cookie")).toContain("Secure"); + }); + + it("closes only the identity-fenced active session and expires its cookie", async () => { + const firstPath = await createProject("First"); + const secondPath = await createProject("Second"); + let nextSessionId = 0; + const registry = new ProjectSessionRegistry({ + createSessionId: () => `session-${++nextSessionId}`, + }); + const first = await registry.openLocal(firstPath); + const second = await registry.openLocal(secondPath); + + const response = await handleProjectSessionDelete(new Request( + "https://localhost/api/project-session", + { + method: "DELETE", + headers: { + cookie: `screenshot_studio_project_session=${first.descriptor.sessionId}`, + "x-project-id": first.projectId, + }, + }, + ), { registry, configuredPath: () => { throw new Error("must not bootstrap"); } }); + + expect(response.status).toBe(204); + expect(response.headers.get("set-cookie")).toContain("Max-Age=0"); + expect(response.headers.get("set-cookie")).toContain("Secure"); + expect(response.headers.get("set-cookie")).toContain( + "screenshot_studio_managed_project=;", + ); + expect(registry.get(first.descriptor.sessionId)).toBeUndefined(); + expect(registry.get(second.descriptor.sessionId)).toBe(second); + }); + + it("keeps the active session when the close project identity is missing or stale", async () => { + const path = await createProject("Identity fence"); + const registry = new ProjectSessionRegistry({ createSessionId: () => "session-1" }); + const session = await registry.openLocal(path); + const base = { + method: "DELETE", + headers: { cookie: "screenshot_studio_project_session=session-1" }, + }; + + const missing = await handleProjectSessionDelete( + new Request("http://localhost/api/project-session", base), + { registry, configuredPath: () => null }, + ); + const stale = await handleProjectSessionDelete(new Request( + "http://localhost/api/project-session", + { ...base, headers: { ...base.headers, "x-project-id": "another-project" } }, + ), { registry, configuredPath: () => null }); + + expect(missing.status).toBe(428); + expect(stale.status).toBe(409); + expect(registry.get(session.descriptor.sessionId)).toBe(session); + }); +}); diff --git a/apps/web/app/api/project-session/route.ts b/apps/web/app/api/project-session/route.ts new file mode 100644 index 00000000..75484fb9 --- /dev/null +++ b/apps/web/app/api/project-session/route.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleProjectSessionDelete, handleProjectSessionGet } from "./handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(request: Request): Promise { + return handleProjectSessionGet(request); +} + +export async function DELETE(request: Request): Promise { + return handleProjectSessionDelete(request); +} diff --git a/apps/web/app/api/project/route.test.ts b/apps/web/app/api/project/route.test.ts new file mode 100644 index 00000000..8e664563 --- /dev/null +++ b/apps/web/app/api/project/route.test.ts @@ -0,0 +1,452 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + linkSync, + lstatSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + PROJECT_SESSION_COOKIE, + projectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { GET, PUT } from "./route.js"; +import { captureImportIntentPath } from "@screenshot-studio/cli/capture-import-recovery"; + +const storageHooks = vi.hoisted(() => ({ + beforeDestinationRevalidation: undefined as undefined | ((projectPath: string) => Promise), +})); + +vi.mock("@screenshot-studio/core/storage", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeProjectConditionalAtomic: async ( + ...args: Parameters + ): ReturnType => { + const dependencies = args[3] ?? {}; + return actual.writeProjectConditionalAtomic(args[0], args[1], args[2], { + ...dependencies, + beforeDestinationRevalidation: async (projectPath) => { + await dependencies.beforeDestinationRevalidation?.(projectPath); + await storageHooks.beforeDestinationRevalidation?.(projectPath); + }, + }); + }, + }; +}); + +const validProject = { + meta: { name: "T", schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ + id: "primary", + name: "Primary", + mode: "isolated", + screens: [{ + id: "hero", name: "Hero", platform: "apple", device: "iphone", locale: "en", + artboard: { width: 660, height: 1434 }, + background: { type: "solid", color: "#000000" }, + elements: [], + }], + }], +}; + +let dir: string; +let file: string; +let currentProjectId: string | undefined; + +function quotedRevision(bytes: string | Buffer): string { + return `"${createHash("sha256").update(bytes).digest("hex")}"`; +} + +function cookieFor(sessionId: string): string { + return `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(sessionId)}`; +} + +function getRequest(cookie?: string): Request { + return new Request("http://x/api/project", { headers: cookie ? { cookie } : undefined }); +} + +async function currentRevision(): Promise { + const response = await GET(getRequest()); + expect(response.status).toBe(200); + const revision = response.headers.get("etag"); + currentProjectId = response.headers.get("x-project-id") ?? undefined; + expect(revision).toBeTruthy(); + expect(currentProjectId).toBeTruthy(); + return revision!; +} + +function putRequest(project: unknown, revision?: string, projectId = currentProjectId): Request { + const headers = new Headers({ "content-type": "application/json" }); + if (revision) headers.set("if-match", revision); + if (projectId) headers.set("x-project-id", projectId); + return new Request("http://x/api/project", { + method: "PUT", + headers, + body: JSON.stringify(project), + }); +} + +beforeEach(() => { + storageHooks.beforeDestinationRevalidation = undefined; + currentProjectId = undefined; + dir = mkdtempSync(join(tmpdir(), "proj-")); + file = join(dir, "app-store-screenshots.json"); + writeFileSync(file, JSON.stringify(validProject)); + process.env.PROJECT_PATH = file; +}); + +afterEach(() => { + storageHooks.beforeDestinationRevalidation = undefined; + delete process.env.PROJECT_PATH; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("GET /api/project", () => { + it("resolves each request through its active project session", async () => { + const first = await projectSessionRegistry.openLocal(file); + const secondDir = mkdtempSync(join(tmpdir(), "proj-second-")); + const secondFile = join(secondDir, "app-store-screenshots.json"); + const secondProject = structuredClone(validProject); + secondProject.meta.name = "Second"; + writeFileSync(secondFile, JSON.stringify(secondProject)); + + try { + const firstResponse = await GET(getRequest(cookieFor(first.descriptor.sessionId))); + const second = await projectSessionRegistry.openLocal(secondFile); + const secondResponse = await GET(getRequest(cookieFor(second.descriptor.sessionId))); + + expect(firstResponse.status).toBe(200); + expect((await firstResponse.json()).meta.name).toBe("T"); + expect(firstResponse.headers.get("x-project-id")).toBe(first.projectId); + expect(secondResponse.status).toBe(200); + expect((await secondResponse.json()).meta.name).toBe("Second"); + expect(secondResponse.headers.get("x-project-id")).toBe(second.projectId); + } finally { + rmSync(secondDir, { recursive: true, force: true }); + } + }); + + it("returns 409 without a selected or configured project", async () => { + delete process.env.PROJECT_PATH; + + const response = await GET(getRequest()); + + expect(response.status).toBe(409); + }); + + it("keeps an explicitly configured project as a compatibility seed", async () => { + const response = await GET(getRequest()); + + expect(response.status).toBe(200); + expect((await response.json()).meta.name).toBe("T"); + }); + + it("returns the parsed project and an ETag from the same exact bytes", async () => { + const bytes = `${JSON.stringify(validProject, null, 4)}\n`; + writeFileSync(file, bytes); + + const res = await GET(getRequest()); + + expect(res.status).toBe(200); + expect(res.headers.get("etag")).toBe(quotedRevision(bytes)); + expect(res.headers.get("x-project-id")).toMatch(/^[a-f0-9]{64}$/); + expect(res.headers.get("x-project-id")).not.toContain(dir); + const body = await res.json(); + expect(body.meta.name).toBe("T"); + expect(body).toMatchObject({ + preferredExportLayoutId: "primary", + layouts: [{ id: "primary", mode: "isolated" }], + meta: { schemaVersion: 1 }, + }); + }); + + it("returns the ETag for canonical target bytes through a symlink project path", async () => { + const alias = join(dir, "project-alias.json"); + const bytes = `${JSON.stringify(validProject, null, 4)}\n`; + writeFileSync(file, bytes); + const directResponse = await GET(getRequest()); + const directProjectId = directResponse.headers.get("x-project-id"); + symlinkSync(file, alias); + process.env.PROJECT_PATH = alias; + + const res = await GET(getRequest()); + + expect(res.status).toBe(200); + expect(res.headers.get("etag")).toBe(quotedRevision(bytes)); + expect(res.headers.get("x-project-id")).toBe(directProjectId); + expect(lstatSync(alias).isSymbolicLink()).toBe(true); + expect(realpathSync(alias)).toBe(realpathSync(file)); + }); +}); + +describe("PUT /api/project", () => { + it("blocks editor saves while capture-import recovery is malformed", async () => { + const revision = await currentRevision(); + const exact = readFileSync(file, "utf8"); + writeFileSync(captureImportIntentPath(file), "{}\n"); + const next = structuredClone(validProject); + next.meta.name = "Blocked"; + + const response = await PUT(putRequest(next, revision)); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringMatching(/invalid capture import recovery intent/i), + }); + expect(readFileSync(file, "utf8")).toBe(exact); + }); + + it("rejects a stale-tab identity before parsing and preserves matching-revision project bytes", async () => { + const first = await projectSessionRegistry.openLocal(file); + const secondDir = mkdtempSync(join(tmpdir(), "proj-second-")); + const secondFile = join(secondDir, "app-store-screenshots.json"); + writeFileSync(secondFile, JSON.stringify(validProject)); + const second = await projectSessionRegistry.openLocal(secondFile); + const firstResponse = await GET(getRequest(cookieFor(first.descriptor.sessionId))); + const secondBefore = readFileSync(secondFile); + expect(firstResponse.headers.get("etag")).toBe(quotedRevision(secondBefore)); + + try { + const staleProject = structuredClone(validProject); + staleProject.meta.name = "Stale A edit"; + const stale = await PUT(new Request("http://x/api/project", { + method: "PUT", + headers: { + "content-type": "application/json", + "if-match": firstResponse.headers.get("etag")!, + "x-project-id": first.projectId, + cookie: cookieFor(second.descriptor.sessionId), + }, + body: JSON.stringify(staleProject), + })); + + expect(stale.status).toBe(409); + await expect(stale.json()).resolves.toMatchObject({ error: expect.stringMatching(/project/i) }); + expect(readFileSync(secondFile)).toEqual(secondBefore); + + const malformed = await PUT(new Request("http://x/api/project", { + method: "PUT", + headers: { + "content-type": "application/json", + "if-match": firstResponse.headers.get("etag")!, + "x-project-id": first.projectId, + cookie: cookieFor(second.descriptor.sessionId), + }, + body: "not JSON", + })); + expect(malformed.status).toBe(409); + } finally { + rmSync(secondDir, { recursive: true, force: true }); + } + }); + + it("requires the project identity returned by GET before accepting a write", async () => { + const revision = await currentRevision(); + const before = readFileSync(file); + const next = structuredClone(validProject); + next.meta.name = "Missing identity"; + + const response = await PUT(putRequest(next, revision, "")); + + expect(response.status).toBe(428); + expect(readFileSync(file)).toEqual(before); + }); + + it("keeps an invalid selected-session write from changing the configured project", async () => { + const secondDir = mkdtempSync(join(tmpdir(), "proj-second-")); + const secondFile = join(secondDir, "app-store-screenshots.json"); + const secondProject = structuredClone(validProject); + secondProject.meta.name = "Second"; + writeFileSync(secondFile, JSON.stringify(secondProject)); + const second = await projectSessionRegistry.openLocal(secondFile); + const firstBefore = readFileSync(file); + const secondBefore = readFileSync(secondFile); + writeFileSync(secondFile, "not JSON"); + + try { + const response = await PUT(new Request("http://x/api/project", { + method: "PUT", + headers: { + "content-type": "application/json", + "if-match": quotedRevision(secondBefore), + "x-project-id": second.projectId, + cookie: cookieFor(second.descriptor.sessionId), + }, + body: JSON.stringify(secondProject), + })); + + expect(response.status).toBe(500); + expect(readFileSync(file)).toEqual(firstBefore); + expect(readFileSync(secondFile, "utf8")).toBe("not JSON"); + } finally { + rmSync(secondDir, { recursive: true, force: true }); + } + }); + + it("returns 428 without changing bytes when If-Match is missing", async () => { + const before = readFileSync(file); + const next = structuredClone(validProject); + next.meta.name = "Renamed"; + + const res = await PUT(putRequest(next)); + + expect(res.status).toBe(428); + await expect(res.json()).resolves.toMatchObject({ error: expect.any(String) }); + expect(readFileSync(file)).toEqual(before); + }); + + it("writes with the GET ETag and returns the validated project with its new ETag", async () => { + const revision = await currentRevision(); + const next = structuredClone(validProject); + next.meta.name = "Renamed"; + + const res = await PUT(putRequest(next, revision)); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + layouts: [{ id: "primary", mode: "isolated" }], + meta: { name: "Renamed", schemaVersion: 1 }, + }); + const persistedBytes = readFileSync(file); + expect(res.headers.get("etag")).toBe(quotedRevision(persistedBytes)); + expect(res.headers.get("etag")).not.toBe(revision); + expect(JSON.parse(readFileSync(file, "utf8"))).toMatchObject({ + layouts: [{ id: "primary", mode: "isolated" }], + meta: { name: "Renamed", schemaVersion: 1 }, + }); + }); + + it("writes through a symlink project path without replacing the symlink", async () => { + const alias = join(dir, "project-alias.json"); + symlinkSync(file, alias); + process.env.PROJECT_PATH = alias; + const revision = await currentRevision(); + const next = structuredClone(validProject); + next.meta.name = "Renamed through alias"; + + const res = await PUT(putRequest(next, revision)); + + expect(res.status).toBe(200); + expect(lstatSync(alias).isSymbolicLink()).toBe(true); + expect(realpathSync(alias)).toBe(realpathSync(file)); + expect(JSON.parse(readFileSync(file, "utf8"))).toMatchObject({ + meta: { name: "Renamed through alias" }, + }); + }); + + it("returns 412 and preserves a newer CLI snapshot byte-for-byte", async () => { + const editorRevision = await currentRevision(); + const cliProject = structuredClone(validProject); + cliProject.meta.name = "CLI change"; + const cliBytes = `${JSON.stringify(cliProject, null, 2)}\n`; + writeFileSync(file, cliBytes); + const editorProject = structuredClone(validProject); + editorProject.meta.name = "Stale editor change"; + + const res = await PUT(putRequest(editorProject, editorRevision)); + + const actualRevision = quotedRevision(cliBytes); + expect(res.status).toBe(412); + await expect(res.json()).resolves.toEqual({ + error: expect.any(String), + expectedRevision: editorRevision, + actualRevision, + }); + expect(readFileSync(file, "utf8")).toBe(cliBytes); + }); + + it("returns 412 when an atomic CLI-style write lands after If-Match comparison", async () => { + const editorRevision = await currentRevision(); + const cliProject = structuredClone(validProject); + cliProject.meta.name = "Post-compare CLI change"; + const cliBytes = `${JSON.stringify(cliProject, null, 2)}\n`; + const editorProject = structuredClone(validProject); + editorProject.meta.name = "Editor loser"; + storageHooks.beforeDestinationRevalidation = async (projectPath) => { + const cliStage = join(dir, ".cli-stage.tmp"); + writeFileSync(cliStage, cliBytes); + renameSync(cliStage, projectPath); + }; + + const res = await PUT(putRequest(editorProject, editorRevision)); + + expect(res.status).toBe(412); + await expect(res.json()).resolves.toEqual({ + error: expect.any(String), + expectedRevision: editorRevision, + actualRevision: quotedRevision(cliBytes), + }); + expect(readFileSync(file, "utf8")).toBe(cliBytes); + }); + + it("returns structured JSON for a hard-linked project path", async () => { + const hardLink = join(dir, "hard-link.json"); + linkSync(file, hardLink); + process.env.PROJECT_PATH = hardLink; + const before = readFileSync(file); + const next = structuredClone(validProject); + next.meta.name = "Rejected"; + + const res = await PUT(putRequest(next, quotedRevision(before))); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: expect.stringMatching(/hard links/i) }); + expect(readFileSync(file)).toEqual(before); + }); + + it("returns structured JSON for a directory project path", async () => { + process.env.PROJECT_PATH = dir; + const before = readFileSync(file); + const next = structuredClone(validProject); + next.meta.name = "Rejected"; + + const res = await PUT(putRequest(next, quotedRevision(before))); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: expect.stringMatching(/regular file/i) }); + expect(readFileSync(file)).toEqual(before); + }); + + it("returns structured JSON when the destination becomes a symlink before commit", async () => { + const editorRevision = await currentRevision(); + const displaced = join(dir, "displaced.json"); + const outside = join(dir, "outside.json"); + const outsideBytes = `${JSON.stringify(validProject, null, 2)}\n`; + writeFileSync(outside, outsideBytes); + storageHooks.beforeDestinationRevalidation = async (projectPath) => { + renameSync(projectPath, displaced); + symlinkSync(outside, projectPath); + }; + const next = structuredClone(validProject); + next.meta.name = "Rejected"; + + const res = await PUT(putRequest(next, editorRevision)); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ + error: expect.stringMatching(/symbolic link|no.follow/i), + }); + expect(lstatSync(file).isSymbolicLink()).toBe(true); + expect(readFileSync(outside, "utf8")).toBe(outsideBytes); + }); + + it("rejects an invalid project with 400 and does not write", async () => { + const revision = await currentRevision(); + const res = await PUT(putRequest({ meta: { name: "" } }, revision)); + expect(res.status).toBe(400); + // original file untouched + expect(JSON.parse(readFileSync(file, "utf8")).meta.name).toBe("T"); + }); +}); diff --git a/apps/web/app/api/project/route.ts b/apps/web/app/api/project/route.ts new file mode 100644 index 00000000..7cbbe95f --- /dev/null +++ b/apps/web/app/api/project/route.ts @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +import { parseProject, ProjectValidationError } from "@screenshot-studio/core/model"; +import { + ProjectPathError, + ProjectRevisionConflictError, + readIdentifiedProjectSnapshot, + readProjectSnapshot, + withProjectMutation, + writeProjectConditionalAtomic, +} from "@screenshot-studio/core/storage"; +import { reconcileCaptureImport } from "@screenshot-studio/cli/capture-import-recovery"; +import { isStrongQuotedETag } from "@web/lib/http-etag.js"; +import { + withManagedProjectMutationGuard, + withManagedProjectReadGuard, +} from "@web/lib/managed-project-mutation-guard.server.js"; +import { withActiveProjectSession } from "@web/lib/project-session.server.js"; + +/** + * The editor's only persistence channel: the browser reads/writes the same on-disk + * app-store-screenshots.json the CLI and /render use. PUT validates with the same strict core model, + * then compares and writes inside the shared mutation coordinator so stale editor state cannot win. + * Dev-only, local, self-hosted; arbitrary local file read is acceptable here (same as /render). + */ +export const dynamic = "force-dynamic"; + +function quotedRevision(revision: string): string { + return `"${revision}"`; +} + +function revisionConflictResponse(expectedRevision: string, actualRevision: string): Response { + return Response.json({ + error: "Project changed on disk; reload it before saving again", + expectedRevision, + actualRevision, + }, { + status: 412, + headers: { etag: actualRevision }, + }); +} + +function projectWriteErrorResponse(error: unknown): Response { + if (error instanceof ProjectRevisionConflictError) { + return revisionConflictResponse( + quotedRevision(error.expected), + quotedRevision(error.actual), + ); + } + const status = error instanceof ProjectPathError ? 400 : 500; + const message = error instanceof Error ? error.message : String(error); + return Response.json({ error: message }, { status }); +} + +export async function GET(req: Request): Promise { + return withActiveProjectSession(req, async (session) => { + return withManagedProjectReadGuard(session, async () => { + try { + const snapshot = await readProjectSnapshot(session.projectPath); + return Response.json(snapshot.project, { + headers: { + etag: quotedRevision(snapshot.revision), + "x-project-id": session.projectId, + }, + }); + } catch (err) { + const status = err instanceof ProjectValidationError ? 422 : 404; + return Response.json({ error: (err as Error).message }, { status }); + } + }); + }); +} + +export async function PUT(req: Request): Promise { + const expectedRevision = req.headers.get("if-match"); + if (expectedRevision === null) { + return Response.json({ error: "If-Match is required to save the project" }, { status: 428 }); + } + if (!isStrongQuotedETag(expectedRevision)) { + return Response.json({ error: "If-Match must be one strong quoted ETag" }, { status: 400 }); + } + + return withActiveProjectSession(req, async (session) => { + const { projectId, projectPath } = session; + const expectedProjectId = req.headers.get("x-project-id")?.trim(); + if (!expectedProjectId) { + return Response.json( + { error: "X-Project-Id is required to save the project" }, + { status: 428 }, + ); + } + if (expectedProjectId !== projectId) { + return Response.json( + { error: "Active project changed; reload it before saving again" }, + { status: 409 }, + ); + } + + let project; + try { + project = parseProject(await req.json()); + } catch (err) { + return Response.json({ error: (err as Error).message }, { status: 400 }); + } + + try { + return await withManagedProjectMutationGuard(session, () => withProjectMutation(projectPath, async () => { + await reconcileCaptureImport(projectPath); + const current = await readIdentifiedProjectSnapshot(projectPath); + const actualRevision = quotedRevision(current.revision); + if (expectedRevision !== actualRevision) { + return revisionConflictResponse(expectedRevision, actualRevision); + } + + const persisted = await writeProjectConditionalAtomic(projectPath, project, current); + return Response.json(persisted.project, { + headers: { etag: quotedRevision(persisted.revision) }, + }); + })); + } catch (error) { + return projectWriteErrorResponse(error); + } + }); +} diff --git a/apps/web/app/api/recent-projects/handler.test.ts b/apps/web/app/api/recent-projects/handler.test.ts new file mode 100644 index 00000000..28e0bbb8 --- /dev/null +++ b/apps/web/app/api/recent-projects/handler.test.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it, vi } from "vitest"; +import type { RecentProjectsStore } from "@web/lib/recent-projects.server.js"; +import type { ProjectSession } from "@web/lib/project-session.server.js"; +import { + handleRecentProjectOpen, + handleRecentProjectsDelete, + handleRecentProjectsGet, +} from "./handler.js"; + +const managedSession: ProjectSession = { + projectId: "project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + projectPath: "/private/managed/project_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/app-store-screenshots.json", + descriptor: { + sessionId: "recent-session", + name: "Launch", + sourceKind: "managed-project", + lifecycleRevision: 1, + capabilities: { + writeInPlace: true, + revealLocation: false, + watchExternalChanges: false, + nativePicker: false, + projectLocalResources: true, + exportProjectArchive: true, + deleteProjectPermanently: true, + }, + }, +}; + +function service(overrides: Partial = {}): RecentProjectsStore { + return { + list: vi.fn(async () => ({ entries: [] })), + remove: vi.fn(async () => false), + activate: vi.fn(async () => managedSession), + ...overrides, + } as unknown as RecentProjectsStore; +} + +describe("recent projects route handlers", () => { + it("returns only browser-safe recent descriptors with no cacheable source locator", async () => { + const recents = service({ + list: vi.fn(async () => ({ entries: [{ + recentId: "recent_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + sourceKind: "local-file" as const, + name: "Private launch", + lastOpenedAt: "2026-07-20T12:00:00.000Z", + availability: "unavailable" as const, + }] })), + }); + const response = await handleRecentProjectsGet(recents); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(JSON.stringify(await response.json())).not.toContain("/Users/"); + }); + + it("removes only the exact opaque history entry", async () => { + const remove = vi.fn(async () => true); + const recents = service({ remove }); + const response = await handleRecentProjectsDelete(new Request( + "http://localhost/api/recent-projects", + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ recentId: "recent_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }), + }, + ), recents); + + expect(response.status).toBe(204); + expect(remove).toHaveBeenCalledWith("recent_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + }); + + it("rejects malformed removal without invoking history mutation", async () => { + const remove = vi.fn(); + const response = await handleRecentProjectsDelete(new Request( + "http://localhost/api/recent-projects", + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ recentId: "../../private", extra: true }), + }, + ), service({ remove })); + + expect(response.status).toBe(400); + expect(remove).not.toHaveBeenCalled(); + }); + + it("activates a validated recent and issues the normal source-neutral session cookies", async () => { + const activate = vi.fn(async () => managedSession); + const response = await handleRecentProjectOpen(new Request( + "https://localhost/api/recent-projects/open", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ recentId: "recent_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }), + }, + ), service({ activate })); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ projectId: managedSession.projectId }); + expect(response.headers.get("set-cookie")).toContain("screenshot_studio_project_session=recent-session"); + expect(response.headers.get("set-cookie")).toContain( + `screenshot_studio_managed_project=${managedSession.projectId}`, + ); + expect(response.headers.get("set-cookie")).toContain("Secure"); + }); + + it("leaves activation cookies unchanged and redacts source errors when recovery fails", async () => { + const activate = vi.fn(async () => { + throw new Error("ENOENT: /Users/alex/Secret/app-store-screenshots.json"); + }); + const response = await handleRecentProjectOpen(new Request( + "http://localhost/api/recent-projects/open", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ recentId: "recent_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }), + }, + ), service({ activate })); + + expect(response.status).toBe(409); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(await response.json()).toEqual({ + error: "This recent project is unavailable. Reopen or import the source to add it again.", + }); + }); +}); diff --git a/apps/web/app/api/recent-projects/handler.ts b/apps/web/app/api/recent-projects/handler.ts new file mode 100644 index 00000000..9394f5fb --- /dev/null +++ b/apps/web/app/api/recent-projects/handler.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + recentProjectsStore, + type RecentProjectsStore, +} from "@web/lib/recent-projects.server.js"; +import { + projectSessionRegistry, + type ProjectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { projectSessionResponseHeaders } from "@web/app/api/project-session/handler.js"; + +const RECENT_ID_PATTERN = /^recent_[0-9a-f]{32}$/; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function recentIdFromBody(request: Request): Promise { + const value = await request.json() as unknown; + if (!isRecord(value) || Object.keys(value).length !== 1 + || typeof value.recentId !== "string" || !RECENT_ID_PATTERN.test(value.recentId)) { + throw new TypeError("Invalid recent project request"); + } + return value.recentId; +} + +function badRequest(): Response { + return Response.json({ error: "A valid recent project id is required." }, { status: 400 }); +} + +export async function handleRecentProjectsGet( + store: RecentProjectsStore = recentProjectsStore, +): Promise { + try { + return Response.json(await store.list(), { + headers: { "cache-control": "no-store" }, + }); + } catch { + return Response.json({ + error: "Recent projects could not be loaded. Retry, or reopen a project from its source.", + }, { status: 500, headers: { "cache-control": "no-store" } }); + } +} + +export async function handleRecentProjectsDelete( + request: Request, + store: RecentProjectsStore = recentProjectsStore, +): Promise { + let recentId: string; + try { + recentId = await recentIdFromBody(request); + } catch { + return badRequest(); + } + try { + if (!await store.remove(recentId)) { + return Response.json({ error: "Recent project does not exist." }, { status: 404 }); + } + return new Response(null, { status: 204, headers: { "cache-control": "no-store" } }); + } catch { + return Response.json({ error: "Recent project could not be removed. Retry." }, { status: 500 }); + } +} + +export async function handleRecentProjectOpen( + request: Request, + store: RecentProjectsStore = recentProjectsStore, + registry: ProjectSessionRegistry = projectSessionRegistry, +): Promise { + let recentId: string; + try { + recentId = await recentIdFromBody(request); + } catch { + return badRequest(); + } + try { + const session = await store.activate(recentId, registry); + return Response.json({ projectId: session.projectId }, { + headers: projectSessionResponseHeaders(session, request), + }); + } catch { + return Response.json({ + error: "This recent project is unavailable. Reopen or import the source to add it again.", + }, { status: 409, headers: { "cache-control": "no-store" } }); + } +} diff --git a/apps/web/app/api/recent-projects/open/route.ts b/apps/web/app/api/recent-projects/open/route.ts new file mode 100644 index 00000000..7c42240e --- /dev/null +++ b/apps/web/app/api/recent-projects/open/route.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleRecentProjectOpen } from "@web/app/api/recent-projects/handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(request: Request): Promise { + return handleRecentProjectOpen(request); +} diff --git a/apps/web/app/api/recent-projects/route.ts b/apps/web/app/api/recent-projects/route.ts new file mode 100644 index 00000000..26d726e2 --- /dev/null +++ b/apps/web/app/api/recent-projects/route.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleRecentProjectsDelete, handleRecentProjectsGet } from "./handler.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(): Promise { + return handleRecentProjectsGet(); +} + +export async function DELETE(request: Request): Promise { + return handleRecentProjectsDelete(request); +} diff --git a/apps/web/app/api/store-specs/handler.ts b/apps/web/app/api/store-specs/handler.ts new file mode 100644 index 00000000..fb300799 --- /dev/null +++ b/apps/web/app/api/store-specs/handler.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +import { readFile } from "node:fs/promises"; +import { parseStoreSpecs } from "@screenshot-studio/core/export"; +import { resolveStoreSpecsPath } from "@web/lib/store-specs-path.js"; + +export interface StoreSpecsRouteDependencies { + resolvePath?: () => string; + readTextFile?: (path: string) => Promise; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : undefined; +} + +export async function handleStoreSpecsGet( + dependencies: StoreSpecsRouteDependencies = {}, +): Promise { + const resolvePath = dependencies.resolvePath ?? resolveStoreSpecsPath; + const readTextFile = dependencies.readTextFile ?? ((path) => readFile(path, "utf8")); + let source: string; + try { + source = await readTextFile(resolvePath()); + } catch (error) { + if (errorCode(error) === "ENOENT") { + return Response.json({ error: "Store specs file was not found" }, { status: 404 }); + } + return Response.json({ error: errorText(error) }, { status: 500 }); + } + + try { + return Response.json(parseStoreSpecs(JSON.parse(source))); + } catch (error) { + return Response.json({ error: errorText(error) }, { status: 422 }); + } +} diff --git a/apps/web/app/api/store-specs/route.test.ts b/apps/web/app/api/store-specs/route.test.ts new file mode 100644 index 00000000..608a134b --- /dev/null +++ b/apps/web/app/api/store-specs/route.test.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it, vi } from "vitest"; +import { handleStoreSpecsGet } from "./handler.js"; +import { GET } from "./route.js"; + +describe("GET /api/store-specs", () => { + it("returns parsed store specs including the iphone lead size", async () => { + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.apple.iphone.some((s: { id: string }) => s.id === "6.9")).toBe(true); + }); + + it("returns a structured 404 when the configured file is missing", async () => { + const error = Object.assign(new Error("missing specs"), { code: "ENOENT" }); + const response = await handleStoreSpecsGet({ + resolvePath: () => "/missing/store-specs.json", + readTextFile: vi.fn(async () => { throw error; }), + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Store specs file was not found" }); + }); + + it("returns a structured non-2xx response when the file is unreadable", async () => { + const error = Object.assign(new Error("permission denied"), { code: "EACCES" }); + const response = await handleStoreSpecsGet({ + resolvePath: () => "/private/store-specs.json", + readTextFile: vi.fn(async () => { throw error; }), + }); + + expect(response.status).toBe(500); + expect((await response.json()).error).toMatch(/permission denied/); + }); + + it.each([ + ["malformed JSON", "{"], + ["schema-invalid JSON", JSON.stringify({ + apple: { iphone: [{ id: "lead", label: "Lead", width: 0, height: 200, orientation: "portrait" }] }, + })], + ])("returns 422 for %s", async (_label, source) => { + const response = await handleStoreSpecsGet({ + resolvePath: () => "/project/store-specs.json", + readTextFile: vi.fn(async () => source), + }); + + expect(response.status).toBe(422); + expect((await response.json()).error).toEqual(expect.any(String)); + }); +}); diff --git a/apps/web/app/api/store-specs/route.ts b/apps/web/app/api/store-specs/route.ts new file mode 100644 index 00000000..617ce82e --- /dev/null +++ b/apps/web/app/api/store-specs/route.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleStoreSpecsGet } from "./handler.js"; + +/** + * Serves parsed store-specs.json to the editor client so the Inspector can list target presets + * without duplicating the sizes into the client bundle. Same data the CLI/export path reads. + */ +export const dynamic = "force-dynamic"; + +export async function GET(): Promise { + return handleStoreSpecsGet(); +} diff --git a/apps/web/app/api/templates/handler.ts b/apps/web/app/api/templates/handler.ts new file mode 100644 index 00000000..2c801d44 --- /dev/null +++ b/apps/web/app/api/templates/handler.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +import { readFile } from "node:fs/promises"; +import { parseStoreSpecs } from "@screenshot-studio/core/export"; +import { + createTemplateScreen, + createBuiltInTemplateScreen, + listBuiltInTemplates, + listTemplates, + TemplateCreateError, + TemplateNotFoundError, + TemplateValidationError, +} from "@screenshot-studio/cli/template-service"; +import { projectRevisionConflictResponse } from "@web/lib/project-request-fence.server.js"; +import { resolveStoreSpecsPath } from "@web/lib/store-specs-path.js"; + +async function loadStoreSpecs() { + return parseStoreSpecs(JSON.parse(await readFile(resolveStoreSpecsPath(), "utf8"))); +} + +function errorResponse(error: unknown): Response { + const revisionConflict = projectRevisionConflictResponse(error); + if (revisionConflict) return revisionConflict; + const message = error instanceof Error ? error.message : String(error); + const status = error instanceof TemplateValidationError ? 400 + : error instanceof TemplateNotFoundError ? 404 + : error instanceof TemplateCreateError ? 422 + : 500; + return Response.json({ error: message }, { status }); +} + +async function jsonBody(req: Request): Promise> { + let body: unknown; + try { + body = await req.json(); + } catch (error) { + throw new TemplateValidationError("request body must be valid JSON", { cause: error }); + } + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw new TemplateValidationError("request body must be a JSON object"); + } + return body as Record; +} + +export async function handleTemplateGet(projectPath: string): Promise { + try { + const specs = await loadStoreSpecs(); + const [templates, builtIns] = await Promise.all([ + listTemplates(projectPath), + listBuiltInTemplates(projectPath, specs), + ]); + return Response.json({ templates, builtIns }); + } catch (error) { + return errorResponse(error); + } +} + +export async function handleTemplatePost( + projectPath: string, + req: Request, + expectedRevision?: string, +): Promise { + try { + const body = await jsonBody(req); + const layoutId = typeof body.layoutId === "string" ? body.layoutId : undefined; + const result = body.source === "built-in" + ? await createBuiltInTemplateScreen( + projectPath, + body.id, + body.locale, + await loadStoreSpecs(), + {}, + layoutId, + { expectedRevision }, + ) + : await createTemplateScreen( + projectPath, body.id, body.locale, {}, layoutId, { expectedRevision }, await loadStoreSpecs(), + ); + const { project, revision, screen } = result; + return Response.json( + { project, screenId: screen.id }, + { headers: { etag: `"${revision}"` } }, + ); + } catch (error) { + return errorResponse(error); + } +} diff --git a/apps/web/app/api/templates/route.test.ts b/apps/web/app/api/templates/route.test.ts new file mode 100644 index 00000000..c011eacd --- /dev/null +++ b/apps/web/app/api/templates/route.test.ts @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { projectRevision } from "@screenshot-studio/core/storage"; +import { + PROJECT_SESSION_COOKIE, + projectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { handleTemplateGet, handleTemplatePost } from "./handler.js"; +import { GET, POST } from "./route.js"; + +let dir: string; +let projectPath: string; +const originalInitCwd = process.env.INIT_CWD; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "template-route-")); + projectPath = join(dir, "app-store-screenshots.json"); + await mkdir(join(dir, "templates")); + await writeFile(projectPath, JSON.stringify({ + meta: { name: "Templates", schemaVersion: 1 }, fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ id: "primary", name: "Primary", mode: "isolated", screens: [{ id: "hero", name: "Hero", platform: "apple", device: "iphone", locale: "en", artboard: { width: 660, height: 1434 }, background: { type: "solid", color: "#000000" }, elements: [] }] }], + })); + await writeFile(join(dir, "templates/accent-card.json"), JSON.stringify({ + id: "accent-card", name: "Accent card", platform: "apple", device: "iphone", + artboard: { width: 660, height: 1434 }, background: { type: "solid", color: "#111111" }, + elements: [{ id: "accent", type: "shape", x: 20, y: 20, width: 620, height: 20, rotation: 0, opacity: 1, locked: false, visible: true, kind: "rect", fill: "#38BDF8", radius: 10 }], + })); +}); + +afterEach(async () => { + delete process.env.PROJECT_PATH; + if (originalInitCwd === undefined) delete process.env.INIT_CWD; + else process.env.INIT_CWD = originalInitCwd; + await rm(dir, { recursive: true, force: true }); +}); + +describe("/api/templates", () => { + it("lists definitions and creates a durable screen", async () => { + const listed = await handleTemplateGet(projectPath); + expect((await listed.json()).templates).toMatchObject([{ id: "accent-card" }]); + const response = await handleTemplatePost(projectPath, new Request("http://localhost/api/templates", { + method: "POST", body: JSON.stringify({ id: "accent-card", locale: "uk" }), + })); + expect(response.status).toBe(200); + expect((await response.json()).screenId).toBe("accent-card"); + const persisted = await readFile(projectPath); + const saved = JSON.parse(persisted.toString("utf8")); + expect(saved.layouts[0].screens.at(-1)).toMatchObject({ id: "accent-card", locale: "uk" }); + expect(response.headers.get("etag")).toBe(`"${projectRevision(persisted)}"`); + }); + + it("returns explicit errors for invalid input", async () => { + const nullBody = await handleTemplatePost(projectPath, new Request("http://localhost/api/templates", { + method: "POST", body: "null", + })); + expect(nullBody.status).toBe(400); + expect((await nullBody.json()).error).toMatch(/JSON object/i); + + const response = await handleTemplatePost(projectPath, new Request("http://localhost/api/templates", { + method: "POST", body: JSON.stringify({ id: "../bad", locale: "en" }), + })); + expect(response.status).toBe(400); + }); + + it("routes reads and mutations through the cookie-selected project session", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + process.env.PROJECT_PATH = join(dir, "configured-missing.json"); + const cookie = `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(session.descriptor.sessionId)}`; + const revision = projectRevision(await readFile(projectPath)); + + const listed = await GET(new Request("http://localhost/api/templates", { + headers: { cookie, "x-project-id": session.projectId }, + })); + expect((await listed.json()).templates).toMatchObject([{ id: "accent-card" }]); + + const created = await POST(new Request("http://localhost/api/templates", { + method: "POST", + headers: { + cookie, + "if-match": `"${revision}"`, + "x-project-id": session.projectId, + }, + body: JSON.stringify({ id: "accent-card", locale: "uk" }), + })); + expect(created.status).toBe(200); + expect((await created.json()).screenId).toBe("accent-card"); + }); + + it("rejects stale project identity and revision before creating a screen", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + const cookie = `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(session.descriptor.sessionId)}`; + const before = await readFile(projectPath); + + const staleProject = await POST(new Request("http://localhost/api/templates", { + method: "POST", + headers: { + cookie, + "if-match": `"${projectRevision(before)}"`, + "x-project-id": "stale-project", + }, + body: JSON.stringify({ id: "accent-card", locale: "uk" }), + })); + expect(staleProject.status).toBe(409); + expect(await readFile(projectPath)).toEqual(before); + + const staleRevision = await POST(new Request("http://localhost/api/templates", { + method: "POST", + headers: { + cookie, + "if-match": '"stale-revision"', + "x-project-id": session.projectId, + }, + body: JSON.stringify({ id: "accent-card", locale: "uk" }), + })); + expect(staleRevision.status).toBe(412); + expect(await readFile(projectPath)).toEqual(before); + }); + + it("returns 409 without a session before changing templates or the project revision", async () => { + delete process.env.PROJECT_PATH; + process.env.INIT_CWD = dir; + const projectBefore = await readFile(projectPath); + const templatePath = join(dir, "templates/accent-card.json"); + const templateBefore = await readFile(templatePath); + + expect((await GET(new Request("http://localhost/api/templates"))).status).toBe(409); + expect((await POST(new Request("http://localhost/api/templates", { + method: "POST", + body: JSON.stringify({ id: "accent-card", locale: "uk" }), + }))).status).toBe(409); + + expect(projectRevision(await readFile(projectPath))).toBe(projectRevision(projectBefore)); + expect(await readFile(templatePath)).toEqual(templateBefore); + }); +}); diff --git a/apps/web/app/api/templates/route.ts b/apps/web/app/api/templates/route.ts new file mode 100644 index 00000000..706ee9a8 --- /dev/null +++ b/apps/web/app/api/templates/route.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleTemplateGet, handleTemplatePost } from "./handler.js"; +import { + withProjectMutationRequest, + withProjectReadRequest, +} from "@web/lib/project-request-fence.server.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(req: Request): Promise { + return withProjectReadRequest( + req, + ({ projectPath }) => handleTemplateGet(projectPath), + ); +} + +export async function POST(req: Request): Promise { + return withProjectMutationRequest( + req, + ({ projectPath }, expectedRevision) => ( + handleTemplatePost(projectPath, req, expectedRevision) + ), + ); +} diff --git a/apps/web/app/api/themes/handler.ts b/apps/web/app/api/themes/handler.ts new file mode 100644 index 00000000..69e6306b --- /dev/null +++ b/apps/web/app/api/themes/handler.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + applyBuiltInDesignPackFile, + applyThemeFile, + listThemes, + ThemeApplyError, + ThemeNotFoundError, + ThemeValidationError, +} from "@screenshot-studio/cli/theme-service"; +import { listBuiltInDesignPacks } from "@screenshot-studio/core/themes"; +import { projectRevisionConflictResponse } from "@web/lib/project-request-fence.server.js"; + +function errorResponse(error: unknown): Response { + const revisionConflict = projectRevisionConflictResponse(error); + if (revisionConflict) return revisionConflict; + const message = error instanceof Error ? error.message : String(error); + const status = error instanceof ThemeValidationError ? 400 + : error instanceof ThemeNotFoundError ? 404 + : error instanceof ThemeApplyError ? 422 + : 500; + return Response.json({ error: message }, { status }); +} + +async function jsonBody(req: Request): Promise> { + let body: unknown; + try { + body = await req.json(); + } catch (error) { + throw new ThemeValidationError("request body must be valid JSON", { cause: error }); + } + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw new ThemeValidationError("request body must be a JSON object"); + } + return body as Record; +} + +export async function handleThemeGet(projectPath: string): Promise { + try { + return Response.json({ + builtIns: listBuiltInDesignPacks(), + themes: await listThemes(projectPath), + }); + } catch (error) { + return errorResponse(error); + } +} + +export async function handleThemePost( + projectPath: string, + req: Request, + expectedRevision?: string, +): Promise { + try { + const body = await jsonBody(req); + const operation = body.source === "built-in" ? applyBuiltInDesignPackFile : applyThemeFile; + const { project, revision } = await operation( + projectPath, body.id, {}, typeof body.layoutId === "string" ? body.layoutId : undefined, + { expectedRevision }, + ); + return Response.json(project, { headers: { etag: `"${revision}"` } }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/apps/web/app/api/themes/route.test.ts b/apps/web/app/api/themes/route.test.ts new file mode 100644 index 00000000..01cc45af --- /dev/null +++ b/apps/web/app/api/themes/route.test.ts @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { projectRevision } from "@screenshot-studio/core/storage"; +import { + PROJECT_SESSION_COOKIE, + projectSessionRegistry, +} from "@web/lib/project-session.server.js"; +import { handleThemeGet, handleThemePost } from "./handler.js"; +import { GET, POST } from "./route.js"; + +let dir: string; +let projectPath: string; +const originalInitCwd = process.env.INIT_CWD; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "theme-route-")); + projectPath = join(dir, "app-store-screenshots.json"); + await mkdir(join(dir, "themes")); + await writeFile(projectPath, JSON.stringify({ + meta: { name: "Themes", schemaVersion: 1 }, fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ id: "primary", name: "Primary", mode: "isolated", screens: [{ id: "hero", name: "Hero", platform: "apple", device: "iphone", locale: "en", artboard: { width: 660, height: 1434 }, background: { type: "solid", color: "#111111" }, elements: [{ id: "accent", type: "shape", role: "accent", x: 0, y: 0, width: 100, height: 10, rotation: 0, opacity: 1, locked: false, visible: true, kind: "rect", fill: "#FFFFFF", radius: 0 }] }] }], + })); + await writeFile(join(dir, "themes/midnight.json"), JSON.stringify({ + id: "midnight", name: "Midnight", + colors: { heading: "#F8FAFC", body: "#CBD5E1", accent: "#38BDF8", surface: "#0F172A" }, + fonts: {}, + })); +}); + +afterEach(async () => { + delete process.env.PROJECT_PATH; + if (originalInitCwd === undefined) delete process.env.INIT_CWD; + else process.env.INIT_CWD = originalInitCwd; + await rm(dir, { recursive: true, force: true }); +}); + +describe("/api/themes", () => { + it("lists definitions and applies one durably", async () => { + const listed = await handleThemeGet(projectPath); + expect(await listed.json()).toMatchObject({ + builtIns: [{ id: "neutral-default", name: "Neutral default" }], + themes: [{ id: "midnight", name: "Midnight" }], + }); + + const response = await handleThemePost(projectPath, new Request("http://localhost/api/themes", { + method: "POST", + body: JSON.stringify({ id: "midnight" }), + })); + expect(response.status).toBe(200); + expect((await response.json()).layouts[0].screens[0].elements[0].fill).toBe("#38BDF8"); + const persisted = await readFile(projectPath); + expect(JSON.parse(persisted.toString("utf8")).layouts[0].screens[0].elements[0].fill).toBe("#38BDF8"); + expect(response.headers.get("etag")).toBe(`"${projectRevision(persisted)}"`); + }); + + it("applies a global design pack as literals without copying resource files", async () => { + const beforeEntries = await readdir(dir); + const response = await handleThemePost(projectPath, new Request("http://localhost/api/themes", { + method: "POST", + body: JSON.stringify({ id: "neutral-default", source: "built-in" }), + })); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.layouts[0].screens[0].elements[0]).toMatchObject({ fill: "#2563EB", radius: 0 }); + const persisted = JSON.parse((await readFile(projectPath)).toString("utf8")); + expect(JSON.stringify(persisted)).not.toContain("neutral-default"); + expect(await readdir(dir)).toEqual(beforeEntries); + }); + + it("returns explicit errors for missing and invalid ids", async () => { + const nullBody = await handleThemePost(projectPath, new Request("http://localhost/api/themes", { + method: "POST", body: "null", + })); + expect(nullBody.status).toBe(400); + expect((await nullBody.json()).error).toMatch(/JSON object/i); + + const missing = await handleThemePost(projectPath, new Request("http://localhost/api/themes", { + method: "POST", body: JSON.stringify({ id: "missing" }), + })); + expect(missing.status).toBe(404); + const invalid = await handleThemePost(projectPath, new Request("http://localhost/api/themes", { + method: "POST", body: JSON.stringify({ id: "../bad" }), + })); + expect(invalid.status).toBe(400); + }); + + it("routes reads and mutations through the cookie-selected project session", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + process.env.PROJECT_PATH = join(dir, "configured-missing.json"); + const cookie = `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(session.descriptor.sessionId)}`; + const revision = projectRevision(await readFile(projectPath)); + + const listed = await GET(new Request("http://localhost/api/themes", { + headers: { cookie, "x-project-id": session.projectId }, + })); + expect((await listed.json()).themes).toMatchObject([{ id: "midnight" }]); + + const applied = await POST(new Request("http://localhost/api/themes", { + method: "POST", + headers: { + cookie, + "if-match": `"${revision}"`, + "x-project-id": session.projectId, + }, + body: JSON.stringify({ id: "midnight" }), + })); + expect(applied.status).toBe(200); + expect((await applied.json()).layouts[0].screens[0].elements[0].fill).toBe("#38BDF8"); + }); + + it("rejects a stale project identity before applying a theme", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + const cookie = `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(session.descriptor.sessionId)}`; + const before = await readFile(projectPath); + + const response = await POST(new Request("http://localhost/api/themes", { + method: "POST", + headers: { + cookie, + "if-match": `"${projectRevision(before)}"`, + "x-project-id": "stale-project", + }, + body: JSON.stringify({ id: "midnight" }), + })); + + expect(response.status).toBe(409); + expect(await readFile(projectPath)).toEqual(before); + }); + + it("rejects a stale project revision before applying a theme", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + const cookie = `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(session.descriptor.sessionId)}`; + const before = await readFile(projectPath); + + const response = await POST(new Request("http://localhost/api/themes", { + method: "POST", + headers: { + cookie, + "if-match": '"stale-revision"', + "x-project-id": session.projectId, + }, + body: JSON.stringify({ id: "midnight" }), + })); + + expect(response.status).toBe(412); + expect(await readFile(projectPath)).toEqual(before); + }); + + it("requires project identity and one strong conditional ETag", async () => { + const session = await projectSessionRegistry.openLocal(projectPath); + const cookie = `${PROJECT_SESSION_COOKIE}=${encodeURIComponent(session.descriptor.sessionId)}`; + + expect((await GET(new Request("http://localhost/api/themes", { + headers: { cookie }, + }))).status).toBe(428); + + expect((await POST(new Request("http://localhost/api/themes", { + method: "POST", + headers: { cookie, "x-project-id": session.projectId }, + body: JSON.stringify({ id: "midnight" }), + }))).status).toBe(428); + + expect((await POST(new Request("http://localhost/api/themes", { + method: "POST", + headers: { + cookie, + "if-match": 'W/"weak-revision"', + "x-project-id": session.projectId, + }, + body: JSON.stringify({ id: "midnight" }), + }))).status).toBe(400); + }); + + it("returns 409 without a session before changing themes or the project revision", async () => { + delete process.env.PROJECT_PATH; + process.env.INIT_CWD = dir; + const projectBefore = await readFile(projectPath); + const themePath = join(dir, "themes/midnight.json"); + const themeBefore = await readFile(themePath); + + expect((await GET(new Request("http://localhost/api/themes"))).status).toBe(409); + expect((await POST(new Request("http://localhost/api/themes", { + method: "POST", + body: JSON.stringify({ id: "midnight" }), + }))).status).toBe(409); + + expect(projectRevision(await readFile(projectPath))).toBe(projectRevision(projectBefore)); + expect(await readFile(themePath)).toEqual(themeBefore); + }); +}); diff --git a/apps/web/app/api/themes/route.ts b/apps/web/app/api/themes/route.ts new file mode 100644 index 00000000..77026a3c --- /dev/null +++ b/apps/web/app/api/themes/route.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +import { handleThemeGet, handleThemePost } from "./handler.js"; +import { + withProjectMutationRequest, + withProjectReadRequest, +} from "@web/lib/project-request-fence.server.js"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(req: Request): Promise { + return withProjectReadRequest( + req, + ({ projectPath }) => handleThemeGet(projectPath), + ); +} + +export async function POST(req: Request): Promise { + return withProjectMutationRequest( + req, + ({ projectPath }, expectedRevision) => handleThemePost(projectPath, req, expectedRevision), + ); +} diff --git a/apps/web/app/built-in-font-assets.test.ts b/apps/web/app/built-in-font-assets.test.ts new file mode 100644 index 00000000..23e62284 --- /dev/null +++ b/apps/web/app/built-in-font-assets.test.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { BUILT_IN_FONT_IDENTITIES } from "@screenshot-studio/core/model"; + +const appRoot = dirname(fileURLToPath(import.meta.url)); +const fontRoot = resolve(appRoot, "../public/fonts"); +const fontFilename = "screenshot-studio-sans-regular-v1.ttf"; + +describe("built-in renderer font assets", () => { + it("pins the renderer-owned default to exact checked-in bytes", async () => { + const bytes = await readFile(resolve(fontRoot, fontFilename)); + + expect(createHash("sha256").update(bytes).digest("hex")) + .toBe("d2a8188db7fdd567bbd94017cec0622373d47206d45281b7c501f0775cdee83a"); + }); + + it("keeps the core catalog aligned with the browser font face", async () => { + const [css, globals, license] = await Promise.all([ + readFile(resolve(appRoot, "built-in-fonts.css"), "utf8"), + readFile(resolve(appRoot, "globals.css"), "utf8"), + readFile(resolve(fontRoot, "OFL.txt"), "utf8"), + ]); + const [font] = BUILT_IN_FONT_IDENTITIES; + + expect(BUILT_IN_FONT_IDENTITIES).toHaveLength(1); + expect(css).toContain(`font-family: "${font.family}"`); + expect(css).toContain(`font-weight: ${font.weight}`); + expect(css).toContain(`url("/fonts/${fontFilename}")`); + expect(css).not.toContain("node_modules"); + expect(globals).toContain('@import "./built-in-fonts.css";'); + expect(license).toContain("Copyright 2015-2021 Google LLC. All Rights Reserved."); + expect(license).toContain("SIL OPEN FONT LICENSE Version 1.1"); + }); +}); diff --git a/apps/web/app/built-in-fonts.css b/apps/web/app/built-in-fonts.css new file mode 100644 index 00000000..84ad1836 --- /dev/null +++ b/apps/web/app/built-in-fonts.css @@ -0,0 +1,8 @@ +/* Exact checked-in bytes keep editor and CLI text metrics independent of the host system. */ +@font-face { + font-family: "Screenshot Studio Sans"; + src: url("/fonts/screenshot-studio-sans-regular-v1.ttf") format("truetype"); + font-style: normal; + font-weight: 400; + font-display: block; +} diff --git a/apps/web/app/editor/editor-keyboard.test.ts b/apps/web/app/editor/editor-keyboard.test.ts new file mode 100644 index 00000000..0288b208 --- /dev/null +++ b/apps/web/app/editor/editor-keyboard.test.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { resolveEditorKeyboardCommand } from "./editor-keyboard"; + +const base = { + key: "", + repeat: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + interactive: false, + modalOpen: false, + canUndo: true, + canRedo: true, + hasSelection: true, +}; + +describe("editor keyboard routing", () => { + it("maps history shortcuts without stealing unavailable commands", () => { + expect(resolveEditorKeyboardCommand({ ...base, key: "z", metaKey: true })).toBe("undo"); + expect(resolveEditorKeyboardCommand({ ...base, key: "z", metaKey: true, shiftKey: true })).toBe("redo"); + expect(resolveEditorKeyboardCommand({ ...base, key: "y", ctrlKey: true })).toBe("redo"); + expect(resolveEditorKeyboardCommand({ ...base, key: "z", metaKey: true, canUndo: false })).toBeNull(); + }); + + it("maps authoring shortcuts with their existing modifier rules", () => { + expect(resolveEditorKeyboardCommand({ ...base, key: "g", ctrlKey: true })).toBe("group"); + expect(resolveEditorKeyboardCommand({ ...base, key: "G", ctrlKey: true, shiftKey: true })).toBe("ungroup"); + expect(resolveEditorKeyboardCommand({ ...base, key: "d", metaKey: true })).toBe("duplicate"); + expect(resolveEditorKeyboardCommand({ ...base, key: "/", metaKey: true })).toBe("templates"); + expect(resolveEditorKeyboardCommand({ ...base, key: "Backspace" })).toBe("delete"); + expect(resolveEditorKeyboardCommand({ ...base, key: "t" })).toBe("add-text"); + expect(resolveEditorKeyboardCommand({ ...base, key: "r" })).toBe("add-shape"); + }); + + it("ignores repeated, interactive, modal, and modified plain-key events", () => { + expect(resolveEditorKeyboardCommand({ ...base, key: "t", repeat: true })).toBeNull(); + expect(resolveEditorKeyboardCommand({ ...base, key: "t", interactive: true })).toBeNull(); + expect(resolveEditorKeyboardCommand({ ...base, key: "t", modalOpen: true })).toBeNull(); + expect(resolveEditorKeyboardCommand({ ...base, key: "t", altKey: true })).toBeNull(); + }); +}); diff --git a/apps/web/app/editor/editor-keyboard.ts b/apps/web/app/editor/editor-keyboard.ts new file mode 100644 index 00000000..d1421293 --- /dev/null +++ b/apps/web/app/editor/editor-keyboard.ts @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useEffect } from "react"; +import { createShapeElement, createTextElement, preferredAuthoringFont } from "@screenshot-studio/core/model"; +import type { ResourceDialog, WorkspacePanel } from "@web/lib/editor-workspace"; +import { editorStore } from "@web/lib/editor-store"; +import { mutateAndSave } from "@web/lib/mutate-and-save"; + +export type EditorKeyboardCommand = + | "undo" + | "redo" + | "group" + | "ungroup" + | "duplicate" + | "templates" + | "delete" + | "add-text" + | "add-shape"; + +export interface EditorKeyboardInput { + key: string; + repeat: boolean; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + interactive: boolean; + modalOpen: boolean; + canUndo: boolean; + canRedo: boolean; + hasSelection: boolean; +} + +export function resolveEditorKeyboardCommand(input: EditorKeyboardInput): EditorKeyboardCommand | null { + if (input.repeat || input.interactive || input.modalOpen) return null; + const key = input.key.toLowerCase(); + const modifier = input.metaKey || input.ctrlKey; + if (modifier && key === "z") { + if (input.shiftKey) return input.canRedo ? "redo" : null; + return input.canUndo ? "undo" : null; + } + if (input.ctrlKey && key === "y") return input.canRedo ? "redo" : null; + if (modifier && key === "g") return input.shiftKey ? "ungroup" : "group"; + if (modifier && key === "d") return input.hasSelection ? "duplicate" : null; + if (modifier && input.key === "/") return "templates"; + if (input.metaKey || input.ctrlKey || input.altKey) return null; + if (input.key === "Delete" || input.key === "Backspace") return input.hasSelection ? "delete" : null; + if (key === "t") return "add-text"; + if (key === "r") return "add-shape"; + return null; +} + +function isInteractiveTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return Boolean(target.closest("input, textarea, select, button, a, [role='dialog'], [contenteditable='true']")); +} + +export function useEditorKeyboardRouting(onOpenDialog: (dialog: ResourceDialog) => void) { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const state = editorStore.getState(); + const command = resolveEditorKeyboardCommand({ + key: event.key, + repeat: event.repeat, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + interactive: isInteractiveTarget(event.target), + modalOpen: Boolean(document.querySelector("[role='dialog'][aria-modal='true']")), + canUndo: state.historyPast.length > 0, + canRedo: state.historyFuture.length > 0, + hasSelection: state.selectedItems.length > 0, + }); + if (!command) return; + event.preventDefault(); + switch (command) { + case "undo": + mutateAndSave(() => editorStore.getState().undo()); + return; + case "redo": + mutateAndSave(() => editorStore.getState().redo()); + return; + case "group": + mutateAndSave(() => editorStore.getState().groupSelected()); + return; + case "ungroup": + mutateAndSave(() => editorStore.getState().ungroupSelected()); + return; + case "duplicate": + mutateAndSave(() => editorStore.getState().duplicateSelected()); + return; + case "templates": + onOpenDialog("templates"); + return; + case "delete": + mutateAndSave(() => editorStore.getState().removeSelected()); + return; + case "add-text": { + const current = editorStore.getState(); + const font = current.project?.fonts[0]; + const direction = current.project?.layouts + .find((layout) => layout.id === current.activeLayoutId)?.screens + .find((screen) => screen.id === current.activeScreenId)?.direction; + mutateAndSave(() => editorStore.getState().addElement(createTextElement({ + font: preferredAuthoringFont(font ? [font] : []), + direction, + }))); + return; + } + case "add-shape": + mutateAndSave(() => editorStore.getState().addElement(createShapeElement())); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onOpenDialog]); +} + +export function useEditorEscapeRouting({ + dialog, + localeDialogOpen, + panel, + overlay, + inspectorOpen, + setPanel, + closeInspector, +}: { + dialog: ResourceDialog | null; + localeDialogOpen: boolean; + panel: WorkspacePanel | null; + overlay: boolean; + inspectorOpen: boolean; + setPanel: (panel: WorkspacePanel | null) => void; + closeInspector: () => void; +}) { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.key !== "Escape" || dialog || localeDialogOpen) return; + if (panel === "add") setPanel("structure"); + else if (panel) setPanel(null); + else if (overlay && inspectorOpen) closeInspector(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [closeInspector, dialog, inspectorOpen, localeDialogOpen, overlay, panel, setPanel]); +} diff --git a/apps/web/app/editor/editor-page-navigation.test.ts b/apps/web/app/editor/editor-page-navigation.test.ts new file mode 100644 index 00000000..33675ec7 --- /dev/null +++ b/apps/web/app/editor/editor-page-navigation.test.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("./page.tsx", import.meta.url), "utf8"); +const projectNavigation = readFileSync( + new URL("./use-editor-project-navigation.ts", import.meta.url), + "utf8", +); +const saveGuards = readFileSync( + new URL("./use-editor-save-guards.ts", import.meta.url), + "utf8", +); + +describe("Workspace round-trip navigation", () => { + it("restores project-keyed view state and applies existing-surface action intents", () => { + expect(source).toContain("readWorkspaceNavigationState"); + expect(source).toContain("writeWorkspaceNavigationState"); + expect(source).toContain('intent === "locales"'); + expect(source).toContain('intent === "export"'); + expect(source).toContain('intent === "add-screen"'); + expect(source).toContain("setTimeout(() => {"); + expect(source).toContain("writeWorkspaceNavigationState(activeProjectId, workspaceNavigationState)"); + }); + + it("opens Projects from the bottom rail and blocks navigation on a save failure", () => { + expect(projectNavigation).toContain("navigateToProjects"); + expect(projectNavigation).toContain("setError"); + expect(source).toContain('role="alert"'); + expect(source).toContain("onOpenProjects={projectNavigation.openProjects}"); + expect(projectNavigation).toContain('router.push("/")'); + expect(source).not.toContain("nextjs-portal"); + }); + + it("keeps a beforeunload safeguard while writes are pending or failed", () => { + expect(source).toContain("useEditorSaveGuards(saveStatus)"); + expect(saveGuards).toContain('window.addEventListener("beforeunload"'); + expect(saveGuards).toContain("hasPendingProjectSave()"); + expect(saveGuards).toContain('saveStatus !== "saving" && saveStatus !== "error"'); + }); +}); diff --git a/apps/web/app/editor/editor-resource-dialogs.tsx b/apps/web/app/editor/editor-resource-dialogs.tsx new file mode 100644 index 00000000..612fa50b --- /dev/null +++ b/apps/web/app/editor/editor-resource-dialogs.tsx @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import type { ResourceDialog } from "@web/lib/editor-workspace"; +import { AssetPickerDialog } from "@web/components/AssetPickerDialog"; +import { FontManagerDialog } from "@web/components/FontManagerDialog"; +import { LocaleManagerDialog } from "@web/components/LocaleManagerDialog"; +import { TemplateLibraryDialog } from "@web/components/TemplateLibraryDialog"; +import { ThemeManagerDialog } from "@web/components/ThemeManagerDialog"; +import type { CachedAssetDimensions } from "@web/components/asset-picker-model"; + +interface EditorResourceDialogsProps { + dialog: ResourceDialog | null; + localeDialogOpen: boolean; + deviceFrameAssetTarget: { elementId: string; screenId: string } | null; + cacheImage(assetUrl: string): Promise; + closeDialog(): void; + closeLocaleDialog(): void; + clearDeviceFrameAssetTarget(): void; +} + +export function EditorResourceDialogs({ + dialog, + localeDialogOpen, + deviceFrameAssetTarget, + cacheImage, + closeDialog, + closeLocaleDialog, + clearDeviceFrameAssetTarget, +}: EditorResourceDialogsProps) { + return ( + <> + + {dialog === "themes" ? : null} + {dialog === "templates" ? : null} + {dialog === "screenshot-assets" ? ( + + ) : null} + {dialog === "image-assets" ? ( + + ) : null} + {dialog === "background-assets" ? ( + + ) : null} + {dialog === "store-badge-assets" ? ( + + ) : null} + {dialog === "device-frame-assets" ? ( + { + closeDialog(); + clearDeviceFrameAssetTarget(); + }} + /> + ) : null} + {localeDialogOpen ? : null} + + ); +} diff --git a/apps/web/app/editor/editor-resources.test.ts b/apps/web/app/editor/editor-resources.test.ts new file mode 100644 index 00000000..ccffaa8d --- /dev/null +++ b/apps/web/app/editor/editor-resources.test.ts @@ -0,0 +1,377 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it, vi } from "vitest"; +import { ProjectFontRegistrationError } from "@screenshot-studio/core/fonts"; +import type { FontAsset, Project } from "@screenshot-studio/core/model"; +import { + applyEditorResourceResult, + cacheDecodedProjectImage, + loadEditorResources, + loadProjectFontWarnings, + missingImageUrls, + runEditorResourceReload, + selectEditorResourceSerializer, + type EditorResourceSerializer, +} from "./editor-resources"; +import { editorStore } from "@web/lib/editor-store"; +import { + clearProjectPersistenceIdentity, + setProjectPersistenceIdentity, +} from "@web/lib/save-project"; + +const project = { + meta: { name: "Resources", schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ + id: "primary", + name: "Primary", + mode: "isolated", + screens: [{ + id: "screen", + name: "Screen", + platform: "apple", + device: "iphone", + locale: "en", + direction: "ltr", + artboard: { width: 100, height: 200 }, + background: { type: "image", assetUrl: "assets/background.png", fit: "cover", color: "#ffffff" }, + elements: [{ + id: "image", + type: "image", + x: 0, + y: 0, + width: 10, + height: 10, + rotation: 0, + opacity: 1, + locked: false, + visible: true, + assetUrl: "assets/image.png", + fit: "contain", + cornerRadius: 0, + }], + }], + }], +} as Project; + +const specs = { + apple: { + iphone: [{ + id: "lead", + label: "Lead", + width: 100, + height: 200, + orientation: "portrait" as const, + }], + }, +}; + +const brokenFont: FontAsset = { + family: "Broken", + weight: 400, + source: "uploaded", + path: "assets/fonts/broken.ttf", +}; + +class ScopedTestFontFace { + constructor( + readonly family: string, + readonly source: ArrayBuffer, + readonly descriptors: FontFaceDescriptors, + ) {} + + async load() { + return this; + } +} + +function successfulDependencies(events: string[] = []) { + return { + fetchProject: vi.fn(async () => { + events.push("project"); + return Response.json(project, { + headers: { etag: '"revision-1"', "x-project-id": "project-1" }, + }); + }), + loadImages: vi.fn(async () => { + events.push("images"); + return new Map([["assets/image.png", {} as HTMLImageElement]]); + }), + registerFonts: vi.fn(async () => { events.push("fonts"); }), + fetchStoreSpecs: vi.fn(async () => { + events.push("specs"); + return Response.json(specs); + }), + }; +} + +describe("editor resources", () => { + it("preserves valid editor context only when reapplying the same project identity", () => { + editorStore.getState().load(project); + editorStore.getState().select("image", "screen"); + setProjectPersistenceIdentity("project-1", '"revision-0"'); + applyEditorResourceResult({ + project, + projectId: "project-1", + revision: '"revision-1"', + images: new Map(), + specs, + fontWarnings: [], + }, { readLayoutPreference: () => null }); + + expect(editorStore.getState().selectedItems).toEqual([{ screenId: "screen", elementId: "image" }]); + clearProjectPersistenceIdentity(); + }); + + it("uses a fresh load when the validated project identity changes", () => { + editorStore.getState().load(project); + editorStore.getState().select("image", "screen"); + setProjectPersistenceIdentity("project-old", '"revision-0"'); + applyEditorResourceResult({ + project, + projectId: "project-new", + revision: '"revision-1"', + images: new Map(), + specs, + fontWarnings: [], + }, { readLayoutPreference: () => "primary" }); + + expect(editorStore.getState().selectedItems).toEqual([]); + clearProjectPersistenceIdentity(); + }); + + it("returns only project image URLs absent from the current cache", () => { + const cache = new Map([["assets/background.png", {} as HTMLImageElement]]); + expect(missingImageUrls(project, cache)).toEqual(["assets/image.png"]); + }); + + it("updates the shared cache before publishing its reactive copy", () => { + const existing = {} as HTMLImageElement; + const imported = {} as HTMLImageElement; + const cache = new Map([["assets/existing.png", existing]]); + + const next = cacheDecodedProjectImage(cache, "assets/imported.png", imported); + + expect(cache.get("assets/imported.png")).toBe(imported); + expect(next.get("assets/imported.png")).toBe(imported); + expect(next).not.toBe(cache); + }); + + it("loads and validates required resources without mutating global revision state", async () => { + const events: string[] = []; + const dependencies = successfulDependencies(events); + + const result = await loadEditorResources(dependencies); + + expect(result).toMatchObject({ + project, + projectId: "project-1", + revision: '"revision-1"', + specs, + fontWarnings: [], + }); + expect(result.images.has("assets/image.png")).toBe(true); + expect(events).toEqual(["project", "images", "fonts", "specs"]); + const imageResolver = (dependencies.loadImages.mock.calls[0] as unknown as [ + Project, + (path: string) => string, + ])[1]; + const fontResolver = (dependencies.registerFonts.mock.calls[0] as unknown as [ + Project["fonts"], + (path: string) => string, + ])[1]; + expect(imageResolver("assets/image.png")).toContain( + "projectId=project-1&revision=%22revision-1%22&path=assets%2Fimage.png", + ); + expect(fontResolver("assets/fonts/example.ttf")).toContain( + "projectId=project-1&revision=%22revision-1%22&path=assets%2Ffonts%2Fexample.ttf", + ); + }); + + it("replaces same-metadata managed fonts when the project resource scope changes", async () => { + const sharedFont: FontAsset = { + family: "Shared Project Font", + weight: 400, + source: "uploaded", + path: "assets/fonts/shared.ttf", + }; + const fontProject = { ...structuredClone(project), fonts: [sharedFont] }; + const registered = new Set(); + const unrelatedFace = { family: sharedFont.family, weight: String(sharedFont.weight) }; + registered.add(unrelatedFace); + const add = vi.fn((face: ScopedTestFontFace) => { registered.add(face); }); + const remove = vi.fn((face: ScopedTestFontFace) => registered.delete(face)); + const fetchFont = vi.fn(async (url: string) => new Response( + Uint8Array.of(url.includes("project-b") ? 0x42 : 0x41), + )); + vi.stubGlobal("FontFace", ScopedTestFontFace); + vi.stubGlobal("fetch", fetchFont); + vi.stubGlobal("document", { + fonts: { + add, + delete: remove, + has: (face: ScopedTestFontFace) => registered.has(face), + ready: Promise.resolve(), + load: vi.fn(), + }, + }); + const dependenciesFor = (projectId: string) => ({ + fetchProject: vi.fn(async () => Response.json(fontProject, { + headers: { etag: '"shared-revision"', "x-project-id": projectId }, + })), + loadImages: vi.fn(async () => new Map()), + fetchStoreSpecs: vi.fn(async () => Response.json(specs)), + }); + + try { + await loadEditorResources(dependenciesFor("project-a")); + const firstFace = [...registered].find( + (face): face is ScopedTestFontFace => face instanceof ScopedTestFontFace, + )!; + await loadEditorResources(dependenciesFor("project-b")); + + expect(fetchFont).toHaveBeenCalledTimes(2); + expect(fetchFont.mock.calls.map(([url]) => url)).toEqual([ + expect.stringContaining("projectId=project-a"), + expect.stringContaining("projectId=project-b"), + ]); + expect(remove).toHaveBeenCalledWith(firstFace); + expect(remove).not.toHaveBeenCalledWith(unrelatedFace); + const projectFaces = [...registered].filter( + (face): face is ScopedTestFontFace => face instanceof ScopedTestFontFace, + ); + expect(projectFaces).toHaveLength(1); + expect(new Uint8Array(projectFaces[0]!.source)[0]).toBe(0x42); + } finally { + vi.unstubAllGlobals(); + } + }); + + it.each([ + ["missing", null], + ["weak", 'W/"revision-1"'], + ["malformed", "revision-1"], + ])("rejects a %s project ETag before loading resources", async (_label, etag) => { + const dependencies = successfulDependencies(); + dependencies.fetchProject.mockResolvedValue(Response.json(project, { + headers: etag === null ? undefined : { etag }, + })); + + await expect(loadEditorResources(dependencies)).rejects.toThrow(/strong ETag/); + expect(dependencies.loadImages).not.toHaveBeenCalled(); + }); + + it("rejects a non-2xx or invalid project before loading dependent resources", async () => { + const nonSuccess = successfulDependencies(); + nonSuccess.fetchProject.mockResolvedValue(Response.json({ error: "no project" }, { status: 503 })); + await expect(loadEditorResources(nonSuccess)).rejects.toThrow(/no project/); + expect(nonSuccess.loadImages).not.toHaveBeenCalled(); + + const invalid = successfulDependencies(); + invalid.fetchProject.mockResolvedValue(Response.json({ nope: true }, { headers: { etag: '"revision-1"' } })); + await expect(loadEditorResources(invalid)).rejects.toThrow(); + expect(invalid.loadImages).not.toHaveBeenCalled(); + }); + + it("requires an opaque project identifier before loading dependent resources", async () => { + const dependencies = successfulDependencies(); + dependencies.fetchProject.mockResolvedValue(Response.json(project, { + headers: { etag: '"revision-1"' }, + })); + + await expect(loadEditorResources(dependencies)).rejects.toThrow(/project identifier/i); + expect(dependencies.loadImages).not.toHaveBeenCalled(); + }); + + it("keeps ordered font registration failures as recoverable warnings", async () => { + const dependencies = successfulDependencies(); + const secondFont = { ...brokenFont, family: "Also Broken", path: "assets/fonts/also-broken.ttf" }; + const failures = [ + { font: brokenFont, error: new Error("bad bytes") }, + { font: secondFont, error: new Error("missing file") }, + ]; + dependencies.registerFonts.mockRejectedValue(new ProjectFontRegistrationError(failures)); + + const result = await loadEditorResources(dependencies); + + expect(result.fontWarnings).toEqual(failures); + expect(dependencies.fetchStoreSpecs).toHaveBeenCalledOnce(); + }); + + it("treats non-font failures and every required image/spec failure as fatal", async () => { + const imageFailure = successfulDependencies(); + imageFailure.loadImages.mockRejectedValue(new Error("image failed")); + await expect(loadEditorResources(imageFailure)).rejects.toThrow("image failed"); + expect(imageFailure.fetchStoreSpecs).not.toHaveBeenCalled(); + + const fontFailure = successfulDependencies(); + fontFailure.registerFonts.mockRejectedValue(new Error("unexpected font runtime failure")); + await expect(loadEditorResources(fontFailure)).rejects.toThrow("unexpected font runtime failure"); + expect(fontFailure.fetchStoreSpecs).not.toHaveBeenCalled(); + + const specsFailure = successfulDependencies(); + specsFailure.fetchStoreSpecs.mockResolvedValue(Response.json({ error: "specs unavailable" }, { status: 503 })); + await expect(loadEditorResources(specsFailure)).rejects.toThrow("specs unavailable"); + + const invalidSpecs = successfulDependencies(); + invalidSpecs.fetchStoreSpecs.mockResolvedValue(Response.json({ apple: { iphone: "not-an-array" } })); + await expect(loadEditorResources(invalidSpecs)).rejects.toThrow(); + }); + + it("returns only recoverable project-font failures for a font-only retry", async () => { + const failures = [{ font: brokenFont, error: new Error("still missing") }]; + const registerFonts = vi.fn() + .mockRejectedValueOnce(new ProjectFontRegistrationError(failures)) + .mockResolvedValueOnce(undefined); + + await expect(loadProjectFontWarnings([brokenFont], registerFonts)).resolves.toEqual(failures); + await expect(loadProjectFontWarnings([brokenFont], registerFonts)).resolves.toEqual([]); + }); + + it("flushes through the supplied serializer and ignores a delayed stale resource response", async () => { + const events: string[] = []; + let releaseSave!: () => void; + let releaseFetch!: () => void; + let current = true; + const saveGate = new Promise((resolve) => { releaseSave = resolve; }); + const fetchGate = new Promise((resolve) => { releaseFetch = resolve; }); + const apply = vi.fn(); + const serialize: EditorResourceSerializer = async (operation: () => Promise) => { + events.push("save:start"); + await saveGate; + events.push("save:end"); + return operation(); + }; + const load = vi.fn(async () => { + events.push("load:start"); + await fetchGate; + events.push("load:end"); + return { + project, + projectId: "project-1", + revision: '"revision-2"', + images: new Map(), + specs, + fontWarnings: [], + }; + }); + + const reload = runEditorResourceReload({ serialize, load, apply, isCurrent: () => current }); + await vi.waitFor(() => expect(events).toEqual(["save:start"])); + releaseSave(); + await vi.waitFor(() => expect(events).toEqual(["save:start", "save:end", "load:start"])); + current = false; + releaseFetch(); + + await expect(reload).resolves.toBe(false); + expect(apply).not.toHaveBeenCalled(); + }); + + it("serializes remount reloads whenever modular editor state already has a project", () => { + const direct: EditorResourceSerializer = (operation) => operation(); + const flushed: EditorResourceSerializer = (operation) => operation(); + + expect(selectEditorResourceSerializer(false, { direct, flushed })).toBe(direct); + expect(selectEditorResourceSerializer(true, { direct, flushed })).toBe(flushed); + }); +}); diff --git a/apps/web/app/editor/editor-resources.ts b/apps/web/app/editor/editor-resources.ts new file mode 100644 index 00000000..6b285874 --- /dev/null +++ b/apps/web/app/editor/editor-resources.ts @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { parseProject, type Project } from "@screenshot-studio/core/model"; +import { + registerProjectFonts, + type FontRegistrationFailure, +} from "@screenshot-studio/core/fonts"; +import type { ImageCache } from "@screenshot-studio/core/assets"; +import { parseStoreSpecs, type StoreSpecs } from "@screenshot-studio/core/export"; +import { editorStore } from "@web/lib/editor-store"; +import { + readActiveLayoutPreference, + writeActiveLayoutPreference, +} from "@web/lib/editor-layout-preference"; +import { + createProjectAssetResolver, + getProjectPersistenceIdentity, + requireProjectRevision, + setProjectPersistenceIdentity, + type ProjectAssetResolver, +} from "@web/lib/save-project"; +import { withFlushedProject } from "@web/lib/debounced-save"; +import { + loadAllProjectImages, + useEditorImageCache, +} from "./use-editor-image-cache"; +import { + loadProjectFontWarnings, + useEditorFontWarnings, +} from "./use-editor-font-warnings"; + +export { + cacheDecodedProjectImage, + missingImageUrls, +} from "./use-editor-image-cache"; +export { loadProjectFontWarnings } from "./use-editor-font-warnings"; + +export interface EditorResourceResult { + project: Project; + projectId: string; + revision: string; + images: ImageCache; + specs: StoreSpecs; + fontWarnings: FontRegistrationFailure[]; +} + +export interface EditorResourceDependencies { + fetchProject?: () => Promise; + loadImages?: (project: Project, resolveAsset: ProjectAssetResolver) => Promise; + registerFonts?: ( + fonts: Project["fonts"], + resolveAsset: ProjectAssetResolver, + ) => Promise; + fetchStoreSpecs?: () => Promise; +} + +export type EditorResourceSerializer = (operation: () => Promise) => Promise; + +export interface EditorResourceReloadOptions { + serialize: EditorResourceSerializer; + load: () => Promise; + apply: (resources: EditorResourceResult) => void; + isCurrent: () => boolean; +} + +export function applyEditorResourceResult( + resources: EditorResourceResult, + dependencies: { + readLayoutPreference?: (projectId: string) => string | null; + } = {}, +): void { + const sameProject = getProjectPersistenceIdentity()?.projectId === resources.projectId + && editorStore.getState().project !== null; + setProjectPersistenceIdentity(resources.projectId, resources.revision); + if (sameProject) { + editorStore.getState().reloadProject(resources.project); + return; + } + const activeLayoutId = (dependencies.readLayoutPreference ?? readActiveLayoutPreference)( + resources.projectId, + ); + editorStore.getState().load(resources.project, activeLayoutId ?? undefined); +} + +const runDirectly: EditorResourceSerializer = (operation) => operation(); + +export function selectEditorResourceSerializer( + projectLoaded: boolean, + serializers: { + direct?: EditorResourceSerializer; + flushed?: EditorResourceSerializer; + } = {}, +): EditorResourceSerializer { + return projectLoaded + ? serializers.flushed ?? withFlushedProject + : serializers.direct ?? runDirectly; +} + +export async function runEditorResourceReload( + options: EditorResourceReloadOptions, +): Promise { + return options.serialize(async () => { + const resources = await options.load(); + if (!options.isCurrent()) return false; + options.apply(resources); + return true; + }); +} + +async function responseError(response: Response, fallback: string): Promise { + const body = await response.json().catch(() => ({})) as { error?: unknown }; + return new Error(typeof body.error === "string" ? body.error : `${fallback} (${response.status})`); +} + +export async function loadEditorResources( + dependencies: EditorResourceDependencies = {}, +): Promise { + const fetchProject = dependencies.fetchProject ?? (() => fetch("/api/project")); + const loadImages = dependencies.loadImages + ?? loadAllProjectImages; + const registerFonts = dependencies.registerFonts + ?? registerProjectFonts; + const fetchStoreSpecs = dependencies.fetchStoreSpecs ?? (() => fetch("/api/store-specs")); + + const projectResponse = await fetchProject(); + if (!projectResponse.ok) throw await responseError(projectResponse, "Failed to load project"); + const revision = requireProjectRevision(projectResponse.headers.get("etag")); + const projectId = projectResponse.headers.get("x-project-id")?.trim(); + if (!projectId) throw new Error("Project response is missing its project identifier"); + const project = parseProject(await projectResponse.json()); + const resolveAsset = createProjectAssetResolver({ projectId, revision }); + const images = await loadImages(project, resolveAsset); + + const fontWarnings = await loadProjectFontWarnings( + project.fonts, + (fonts) => registerFonts(fonts, resolveAsset), + ); + + const specsResponse = await fetchStoreSpecs(); + if (!specsResponse.ok) throw await responseError(specsResponse, "Failed to load store specs"); + const specs = parseStoreSpecs(await specsResponse.json()); + + return { project, projectId, revision, images, specs, fontWarnings }; +} + +export function useEditorResources() { + const [error, setError] = useState(null); + const [specs, setSpecs] = useState(null); + const [reloading, setReloading] = useState(false); + const [activeProjectId, setActiveProjectId] = useState(null); + const resourceGeneration = useRef(0); + const projectId = useRef(null); + const { + images, + reset: resetImages, + replace: replaceImages, + cacheImage, + } = useEditorImageCache(resourceGeneration); + const { + fontWarnings, + fontRetrying, + reset: resetFontWarnings, + cancel: cancelFontLoads, + replace: replaceFontWarnings, + retryFonts, + } = useEditorFontWarnings(setError); + + const startReload = useCallback(async (serialize: EditorResourceSerializer) => { + const generation = ++resourceGeneration.current; + setError(null); + resetImages(); + setSpecs(null); + resetFontWarnings(); + setReloading(true); + try { + return await runEditorResourceReload({ + serialize, + load: loadEditorResources, + isCurrent: () => resourceGeneration.current === generation, + apply: (resources) => { + // A revision becomes globally usable only after every required resource validated and this + // generation won. Cancelled or delayed fetches cannot roll the save coordinator backwards. + applyEditorResourceResult(resources); + projectId.current = resources.projectId; + setActiveProjectId(resources.projectId); + replaceImages(resources.images); + setSpecs(resources.specs); + replaceFontWarnings(resources.fontWarnings); + }, + }); + } catch (error: unknown) { + if (resourceGeneration.current === generation) setError((error as Error).message); + return false; + } finally { + if (resourceGeneration.current === generation) setReloading(false); + } + }, [replaceFontWarnings, replaceImages, resetFontWarnings, resetImages]); + + useEffect(() => editorStore.subscribe((state, previous) => { + const currentProjectId = projectId.current; + if ( + currentProjectId + && state.activeLayoutId + && state.activeLayoutId !== previous.activeLayoutId + ) { + writeActiveLayoutPreference(currentProjectId, state.activeLayoutId); + } + }), []); + + const cancelLoads = useCallback(() => { + ++resourceGeneration.current; + cancelFontLoads(); + }, [cancelFontLoads]); + + useEffect(() => { + let active = true; + queueMicrotask(() => { + if (active) { + void startReload(selectEditorResourceSerializer(editorStore.getState().project !== null)); + } + }); + return () => { + active = false; + cancelLoads(); + }; + }, [cancelLoads, startReload]); + + const retry = useCallback(() => { + if (reloading) return; + const serialize = selectEditorResourceSerializer(editorStore.getState().project !== null); + void startReload(serialize); + }, [reloading, startReload]); + + const reload = useCallback(() => startReload( + selectEditorResourceSerializer(editorStore.getState().project !== null), + ), [startReload]); + + return { + error, + images, + specs, + fontWarnings, + retry, + reload, + cacheImage, + reloading, + retryFonts, + fontRetrying, + activeProjectId, + }; +} diff --git a/apps/web/app/editor/editor-shell-motion.test.ts b/apps/web/app/editor/editor-shell-motion.test.ts new file mode 100644 index 00000000..81df9357 --- /dev/null +++ b/apps/web/app/editor/editor-shell-motion.test.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const pageSource = readFileSync(new URL("./page.tsx", import.meta.url), "utf8"); +const leftPanelSource = readFileSync( + new URL("../../components/LeftPanel.tsx", import.meta.url), + "utf8", +); + +describe("editor side-panel motion integration", () => { + it("keeps both motion shells mounted and drives them from existing state", () => { + expect(pageSource).toContain( + 'import { EditorSidePanel } from "@web/components/editor-shell/EditorSidePanel";', + ); + expect(pageSource).toContain(''); + expect(pageSource).toContain(''); + expect(pageSource).not.toContain("{panel ? ("); + expect(pageSource).not.toContain("{inspectorOpen ? ("); + }); + + it("retains the last left-panel content while its shell closes", () => { + expect(pageSource).toContain( + 'const [lastPanel, setLastPanel] = useState("structure");', + ); + expect(pageSource).toContain("const setPanel = useCallback((next: WorkspacePanel | null) => {"); + expect(pageSource).toContain("setPanelState(next);"); + expect(pageSource).toContain("if (next) setLastPanel(next);"); + expect(pageSource).not.toContain("if (panel) setLastPanel(panel);"); + expect(pageSource).toContain("panel={panel ?? lastPanel}"); + }); + + it("lets the shared shell own width and positioning", () => { + expect(leftPanelSource).toContain( + '"flex h-full w-full shrink-0 flex-col overflow-hidden border-r', + ); + expect(leftPanelSource).not.toContain("absolute inset-y-0 left-14"); + expect(pageSource).toContain('className="!w-full"'); + }); + + it("preserves canvas inset state instead of coupling it to animation presence", () => { + expect(pageSource).toContain("leftInset={layout.overlay && panel ? SIDE_PANEL_WIDTH : 0}"); + expect(pageSource).toContain( + "rightInset={layout.overlay && inspectorOpen ? SIDE_PANEL_WIDTH : 0}", + ); + }); +}); diff --git a/apps/web/app/editor/page.tsx b/apps/web/app/editor/page.tsx new file mode 100644 index 00000000..27234735 --- /dev/null +++ b/apps/web/app/editor/page.tsx @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useStore } from "zustand"; +import type { + CanvasPattern, + ResourceDialog, + WorkspacePanel, + WorkspaceTool, + WorkspaceView, +} from "@web/lib/editor-workspace"; +import { editorStore } from "@web/lib/editor-store"; +import { LeftPanel } from "@web/components/LeftPanel"; +import { Inspector } from "@web/components/Inspector"; +import { Toolbar } from "@web/components/Toolbar"; +import { ToolRail } from "@web/components/ToolRail"; +import { CaptureImportDialog } from "@web/components/project-launcher/CaptureImportDialog"; +import { ConfirmationDialog } from "@web/components/editor-ui"; +import { EditorSidePanel } from "@web/components/editor-shell/EditorSidePanel"; +import { + readWorkspaceNavigationState, + writeWorkspaceNavigationState, +} from "@web/lib/editor-workspace-navigation"; +import { EditorResourceDialogs } from "./editor-resource-dialogs"; +import { useEditorEscapeRouting, useEditorKeyboardRouting } from "./editor-keyboard"; +import { useEditorCaptureImport } from "./use-editor-capture-import"; +import { useEditorProjectNavigation } from "./use-editor-project-navigation"; +import { useEditorResources } from "./editor-resources"; +import { useEditorSaveGuards } from "./use-editor-save-guards"; + +// react-konva needs the DOM; load the complete viewport client-only (no SSR). +const DynamicCanvasViewport = dynamic( + () => import("@web/components/CanvasViewport").then((m) => m.CanvasViewport), + { ssr: false }, +); + +const SIDE_PANEL_WIDTH = 288; + +export default function EditorPage() { + const router = useRouter(); + const { + error, + images, + specs, + fontWarnings, + retry, + reloading, + retryFonts, + fontRetrying, + activeProjectId, + reload: reloadResources, + cacheImage, + } = useEditorResources(); + const [zoom, setZoom] = useState(0.5); + const [view, setView] = useState("overview"); + const [tool, setTool] = useState("select"); + const [pattern, setPattern] = useState("dots"); + const [panel, setPanelState] = useState("structure"); + const [lastPanel, setLastPanel] = useState("structure"); + const [inspectorOpen, setInspectorOpen] = useState(true); + const [dialog, setDialog] = useState(null); + const [deviceFrameAssetTarget, setDeviceFrameAssetTarget] = useState<{ + elementId: string; + screenId: string; + } | null>(null); + const [localeDialogOpen, setLocaleDialogOpen] = useState(false); + const [layout, setLayout] = useState({ overlay: false, compact: false, phone: false }); + const [workspaceRestoredFor, setWorkspaceRestoredFor] = useState(null); + const [exportRequestKey, setExportRequestKey] = useState(0); + const saveStatus = useStore(editorStore, (state) => state.saveStatus); + const saveError = useStore(editorStore, (state) => state.saveError); + const project = useStore(editorStore, (state) => state.project); + const captureImport = useEditorCaptureImport(project, activeProjectId, reloadResources); + const workspaceNavigationState = useMemo(() => ({ + zoom, + view, + tool, + pattern, + panel, + lastPanel, + inspectorOpen, + }), [zoom, view, tool, pattern, panel, lastPanel, inspectorOpen]); + const setPanel = useCallback((next: WorkspacePanel | null) => { + setPanelState(next); + if (next) setLastPanel(next); + }, []); + const projectNavigation = useEditorProjectNavigation(activeProjectId, workspaceNavigationState); + useEditorSaveGuards(saveStatus); + + useEffect(() => { + const overlayQuery = window.matchMedia("(max-width: 1279px)"); + const compactQuery = window.matchMedia("(max-width: 959px)"); + const phoneQuery = window.matchMedia("(max-width: 639px)"); + const sync = () => { + setLayout({ + overlay: overlayQuery.matches, + compact: compactQuery.matches, + phone: phoneQuery.matches, + }); + if (compactQuery.matches) { + setPanel(null); + setInspectorOpen(false); + } + }; + sync(); + overlayQuery.addEventListener("change", sync); + compactQuery.addEventListener("change", sync); + phoneQuery.addEventListener("change", sync); + return () => { + overlayQuery.removeEventListener("change", sync); + compactQuery.removeEventListener("change", sync); + phoneQuery.removeEventListener("change", sync); + }; + }, [setPanel]); + + useEffect(() => { + if (!activeProjectId) return; + const frame = requestAnimationFrame(() => { + const restored = readWorkspaceNavigationState(activeProjectId); + if (restored) { + setZoom(restored.zoom); + setView(restored.view); + setTool(restored.tool); + setPattern(restored.pattern); + setPanel(restored.panel); + setLastPanel(restored.lastPanel); + setInspectorOpen(window.matchMedia("(max-width: 959px)").matches + ? false + : restored.inspectorOpen); + } + setWorkspaceRestoredFor(activeProjectId); + }); + return () => cancelAnimationFrame(frame); + }, [activeProjectId, setPanel]); + + useEffect(() => { + if (!activeProjectId || workspaceRestoredFor !== activeProjectId) return; + const timer = setTimeout(() => { + writeWorkspaceNavigationState(activeProjectId, workspaceNavigationState); + }, 120); + return () => clearTimeout(timer); + }, [activeProjectId, workspaceNavigationState, workspaceRestoredFor]); + + useEffect(() => { + if (!activeProjectId || workspaceRestoredFor !== activeProjectId) return; + const frame = requestAnimationFrame(() => { + const intent = new URLSearchParams(window.location.search).get("intent"); + if (intent === "locales") setLocaleDialogOpen(true); + if (intent === "export") setExportRequestKey((current) => current + 1); + if (intent === "add-screen" || intent === "layouts") setPanel("structure"); + if (intent) router.replace("/editor", { scroll: false }); + }); + return () => cancelAnimationFrame(frame); + }, [activeProjectId, router, setPanel, workspaceRestoredFor]); + + const openDialog = useCallback((next: ResourceDialog) => setDialog(next), []); + const closeInspector = useCallback(() => setInspectorOpen(false), []); + useEditorKeyboardRouting(openDialog); + useEditorEscapeRouting({ + dialog, + localeDialogOpen, + panel, + overlay: layout.overlay, + inspectorOpen, + setPanel, + closeInspector, + }); + + if (error) { + return ( +
+
+

{error}

+ + + Return to project launcher + +
+
+ ); + } + if (!images) return
Loading workspace…
; + return ( +
+ setLocaleDialogOpen(true)} + onToggleInspector={() => { + const opening = !inspectorOpen; + if (opening && layout.compact) setPanel(null); + setInspectorOpen(opening); + }} + /> +
+ { + const opening = panel !== next; + if (opening && layout.compact) setInspectorOpen(false); + setPanel(opening ? next : null); + }} + /> + + setPanel(panel === "add" ? "structure" : null)} + onOpenAdd={() => setPanel("add")} + onOpenDialog={setDialog} + onImportCaptures={captureImport.open} + /> + +
+ +
+ + setDialog("background-assets")} + onOpenDeviceFrameAsset={(target) => { + setDeviceFrameAssetTarget(target); + setDialog("device-frame-assets"); + }} + onClose={() => setInspectorOpen(false)} + className="!w-full" + /> + +
+ {saveStatus === "error" && saveError && !projectNavigation.error ? ( +
+ Save failed: {saveError} +
+ ) : null} + {projectNavigation.error ? ( + + ) : null} + {fontWarnings.length > 0 ? ( +
+

Some project fonts could not be loaded.

+
    + {fontWarnings.map(({ font, error: fontError }) => ( +
  • + {font.family} {font.weight} ({font.path}): {fontError.message} +
  • + ))} +
+

+ Preview may use a fallback. Export remains blocked until every project font resolves. +

+ +
+ ) : null} + setDialog(null)} + closeLocaleDialog={() => setLocaleDialogOpen(false)} + clearDeviceFrameAssetTarget={() => setDeviceFrameAssetTarget(null)} + /> + {captureImport.state.status === "closed" ? null : ( + + )} +
+ ); +} diff --git a/apps/web/app/editor/use-editor-capture-import.ts b/apps/web/app/editor/use-editor-capture-import.ts new file mode 100644 index 00000000..86e6e114 --- /dev/null +++ b/apps/web/app/editor/use-editor-capture-import.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useCallback, useMemo } from "react"; +import type { Project } from "@screenshot-studio/core/model"; +import { + assertCaptureImportHandoff, + useCaptureImportController, +} from "@web/components/project-launcher/useCaptureImportController"; +import { getProjectPersistenceIdentity } from "@web/lib/save-project"; + +export function useEditorCaptureImport( + project: Project | null, + activeProjectId: string | null, + reloadResources: () => Promise, +) { + const persistence = getProjectPersistenceIdentity(); + const currentTarget = useMemo(() => project && activeProjectId + && persistence?.projectId === activeProjectId + ? { + projectId: persistence.projectId, + revision: persistence.revision, + projectName: project.meta.name, + preferredLayoutId: project.preferredExportLayoutId, + layouts: project.layouts.map(({ id, name }) => ({ id, name })), + } + : null, [activeProjectId, persistence?.projectId, persistence?.revision, project]); + + const complete = useCallback(async ( + result: Parameters[0], + _destinationKind: "current-project" | "new-project", + signal: AbortSignal, + ) => { + const reloaded = await reloadResources(); + if (signal.aborted) throw new DOMException("Capture import handoff cancelled", "AbortError"); + if (!reloaded) throw new Error("Capture import completed, but the editor could not reload it"); + assertCaptureImportHandoff(result, getProjectPersistenceIdentity()?.projectId); + }, [reloadResources]); + + const recover = useCallback(async () => { + const reloaded = await reloadResources(); + if (!reloaded) throw new Error("The current project could not be reloaded"); + }, [reloadResources]); + + return useCaptureImportController({ + currentTarget, + onCompleted: complete, + onRecoverStale: recover, + }); +} diff --git a/apps/web/app/editor/use-editor-font-warnings.ts b/apps/web/app/editor/use-editor-font-warnings.ts new file mode 100644 index 00000000..c5681ddd --- /dev/null +++ b/apps/web/app/editor/use-editor-font-warnings.ts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useCallback, useRef, useState } from "react"; +import type { Project } from "@screenshot-studio/core/model"; +import { + ProjectFontRegistrationError, + registerProjectFonts, + type FontRegistrationFailure, +} from "@screenshot-studio/core/fonts"; +import { editorStore } from "@web/lib/editor-store"; +import { resolveActiveProjectAssetUrl } from "@web/lib/save-project"; + +export async function loadProjectFontWarnings( + fonts: Project["fonts"], + registerFonts: (fonts: Project["fonts"]) => Promise = ( + projectFonts, + ) => registerProjectFonts(projectFonts, resolveActiveProjectAssetUrl), +): Promise { + try { + await registerFonts(fonts); + return []; + } catch (error) { + if (!(error instanceof ProjectFontRegistrationError)) throw error; + return error.failures; + } +} + +export function useEditorFontWarnings(onError: (message: string) => void) { + const [fontWarnings, setFontWarnings] = useState([]); + const [fontRetrying, setFontRetrying] = useState(false); + const generation = useRef(0); + + const reset = useCallback(() => { + ++generation.current; + setFontWarnings([]); + setFontRetrying(false); + }, []); + + const cancel = useCallback(() => { + ++generation.current; + }, []); + + const replace = useCallback((warnings: FontRegistrationFailure[]) => { + setFontWarnings(warnings); + }, []); + + const retryFonts = useCallback(() => { + if (fontRetrying) return; + const project = editorStore.getState().project; + if (!project) return; + const signature = JSON.stringify(project.fonts); + const currentGeneration = ++generation.current; + setFontRetrying(true); + void loadProjectFontWarnings(project.fonts).then((warnings) => { + if (generation.current !== currentGeneration) return; + const current = editorStore.getState().project; + if (!current || JSON.stringify(current.fonts) !== signature) return; + setFontWarnings(warnings); + }).catch((error: unknown) => { + if (generation.current === currentGeneration) onError((error as Error).message); + }).finally(() => { + if (generation.current === currentGeneration) setFontRetrying(false); + }); + }, [fontRetrying, onError]); + + return { fontWarnings, fontRetrying, reset, cancel, replace, retryFonts }; +} diff --git a/apps/web/app/editor/use-editor-image-cache.ts b/apps/web/app/editor/use-editor-image-cache.ts new file mode 100644 index 00000000..a26d1e52 --- /dev/null +++ b/apps/web/app/editor/use-editor-image-cache.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useStore } from "zustand"; +import type { Project } from "@screenshot-studio/core/model"; +import { + collectImageUrls, + loadProjectImage, + loadProjectImages, + type ImageCache, +} from "@screenshot-studio/core/assets"; +import { editorStore } from "@web/lib/editor-store"; +import { + resolveActiveProjectAssetUrl, + type ProjectAssetResolver, +} from "@web/lib/save-project"; +import type { CachedAssetDimensions } from "@web/components/asset-picker-model"; + +export async function loadAllProjectImages( + project: Project, + resolveAsset: ProjectAssetResolver, +): Promise { + const caches = await Promise.all( + project.layouts.map((layout) => loadProjectImages(project, layout.id, resolveAsset)), + ); + return new Map(caches.flatMap((cache) => [...cache])); +} + +export function missingImageUrls(project: Project, images: ImageCache): string[] { + return collectImageUrls(project).filter((assetUrl) => !images.has(assetUrl)); +} + +export function cacheDecodedProjectImage( + images: ImageCache, + assetUrl: string, + image: HTMLImageElement, +): ImageCache { + // Update the shared cache before publishing a new Map so a synchronous project mutation cannot + // render one frame with a missing-image placeholder. + images.set(assetUrl, image); + return new Map(images); +} + +export function useEditorImageCache(resourceGeneration: { readonly current: number }) { + const [images, setImages] = useState(null); + const imagesRef = useRef(null); + const assetSignature = useStore(editorStore, (state) => + state.project ? collectImageUrls(state.project).sort().join("\n") : "", + ); + + const reset = useCallback(() => { + imagesRef.current = null; + setImages(null); + }, []); + + const replace = useCallback((next: ImageCache) => { + imagesRef.current = next; + setImages(next); + }, []); + + useEffect(() => { + if (!images || !assetSignature) return; + const project = editorStore.getState().project; + if (!project) return; + const missing = missingImageUrls(project, images); + if (missing.length === 0) return; + let cancelled = false; + void loadAllProjectImages(project, resolveActiveProjectAssetUrl).then((loaded) => { + if (cancelled) return; + setImages((current) => { + if (!current) { + imagesRef.current = loaded; + return loaded; + } + const next = new Map(current); + let changed = false; + for (const assetUrl of missing) { + const image = loaded.get(assetUrl); + if (image) { + next.set(assetUrl, image); + changed = true; + } + } + const result = changed ? next : current; + imagesRef.current = result; + return result; + }); + }); + return () => { cancelled = true; }; + }, [assetSignature, images]); + + const cacheImage = useCallback(async (assetUrl: string): Promise => { + const generation = resourceGeneration.current; + const current = imagesRef.current; + if (!current) return null; + const cached = current.get(assetUrl); + if (cached) return { width: cached.naturalWidth, height: cached.naturalHeight }; + const image = await loadProjectImage(assetUrl, resolveActiveProjectAssetUrl); + if (!image + || resourceGeneration.current !== generation + || imagesRef.current !== current) return null; + replace(cacheDecodedProjectImage(current, assetUrl, image)); + return { width: image.naturalWidth, height: image.naturalHeight }; + }, [replace, resourceGeneration]); + + return { images, reset, replace, cacheImage }; +} diff --git a/apps/web/app/editor/use-editor-project-navigation.ts b/apps/web/app/editor/use-editor-project-navigation.ts new file mode 100644 index 00000000..cd46eb76 --- /dev/null +++ b/apps/web/app/editor/use-editor-project-navigation.ts @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { abandonProjectSession } from "@web/lib/project-close"; +import { + navigateToProjects, + writeWorkspaceNavigationState, + type WorkspaceNavigationState, +} from "@web/lib/editor-workspace-navigation"; + +export function useEditorProjectNavigation( + activeProjectId: string | null, + workspaceNavigationState: WorkspaceNavigationState, +) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [abandoning, setAbandoning] = useState(false); + + const openProjects = useCallback(() => { + if (busy) return; + if (activeProjectId) { + writeWorkspaceNavigationState(activeProjectId, workspaceNavigationState); + } + setBusy(true); + setError(null); + void navigateToProjects({ navigate: () => router.push("/") }) + .catch((caught: unknown) => { + setError(caught instanceof Error ? caught.message : String(caught)); + setBusy(false); + }); + }, [activeProjectId, busy, router, workspaceNavigationState]); + + const closeWithoutSaving = useCallback(() => { + if (!activeProjectId || abandoning) return; + setAbandoning(true); + void abandonProjectSession(activeProjectId, { + navigate: () => router.push("/?switch=1"), + }).catch((caught: unknown) => { + setError(caught instanceof Error ? caught.message : String(caught)); + setAbandoning(false); + }); + }, [abandoning, activeProjectId, router]); + + return { + busy, + error, + abandoning, + clearError: () => setError(null), + openProjects, + closeWithoutSaving, + }; +} diff --git a/apps/web/app/editor/use-editor-save-guards.ts b/apps/web/app/editor/use-editor-save-guards.ts new file mode 100644 index 00000000..d3ccf40b --- /dev/null +++ b/apps/web/app/editor/use-editor-save-guards.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useEffect } from "react"; +import type { EditorState } from "@web/lib/editor-store"; +import { + flushScheduledSave, + hasPendingProjectSave, +} from "@web/lib/debounced-save"; + +export function useEditorSaveGuards(saveStatus: EditorState["saveStatus"]): void { + useEffect(() => { + const onPageHide = () => { void flushScheduledSave().catch(() => undefined); }; + window.addEventListener("pagehide", onPageHide); + return () => window.removeEventListener("pagehide", onPageHide); + }, []); + + useEffect(() => { + const onBeforeUnload = (event: BeforeUnloadEvent) => { + if (!hasPendingProjectSave() && saveStatus !== "saving" && saveStatus !== "error") return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", onBeforeUnload); + return () => window.removeEventListener("beforeunload", onBeforeUnload); + }, [saveStatus]); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 00000000..04139752 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,192 @@ +@import "tailwindcss"; +@import "../components/editor-ui/tokens.css"; +@import "./built-in-fonts.css"; + +:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #0f172a; + background: #f8fafc; +} + +* { + box-sizing: border-box; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: default; +} + +.workspace-canvas { + background-color: #f8fafc; +} + +.workspace-canvas[data-pattern="dots"] { + background-image: radial-gradient(circle, rgb(148 163 184 / 32%) 1px, transparent 1px); + background-size: 16px 16px; +} + +.workspace-canvas[data-pattern="grid"] { + background-image: + linear-gradient(to right, rgb(148 163 184 / 12%) 1px, transparent 1px), + linear-gradient(to bottom, rgb(148 163 184 / 12%) 1px, transparent 1px); + background-size: 32px 32px; +} + +.workspace-canvas[data-pattern="none"] { + background-image: none; +} + +@keyframes launcher-backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes launcher-dialog-in { + from { + opacity: 0; + transform: translateY(6px) scale(0.99); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes launcher-backdrop-out { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes launcher-dialog-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + + to { + opacity: 0; + transform: translateY(4px) scale(0.99); + } +} + +@keyframes launcher-dialog-state-in { + from { + opacity: 0; + transform: translateY(3px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes project-card-action-status-in { + from { + opacity: 0; + transform: translateY(3px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes project-card-action-status-out { + from { opacity: 1; } + to { opacity: 0; } +} + +.launcher-dialog-backdrop { + animation: launcher-backdrop-in 220ms ease-out; +} + +.launcher-dialog-panel { + animation: launcher-dialog-in 240ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.launcher-dialog-state { + animation: launcher-dialog-state-in 150ms ease-out; +} + +.project-card-action-status { + animation: project-card-action-status-in 180ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.project-card-action-status[data-motion="closing"] { + animation: project-card-action-status-out 180ms ease-in forwards; +} + +.launcher-dialog-backdrop[data-motion="closing"] { + animation: launcher-backdrop-out 180ms ease-in forwards; + pointer-events: none; +} + +.launcher-dialog-backdrop[data-motion="closing"] .launcher-dialog-panel { + animation: launcher-dialog-out 180ms ease-in forwards; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } + + .launcher-dialog-backdrop, + .launcher-dialog-panel, + .launcher-dialog-state { + animation: none !important; + } + + .project-card-action-status { + animation: none !important; + } + + .project-card-action-status[data-motion="closing"] { + opacity: 0; + } +} +@keyframes menu-select-in { + from { + opacity: 0; + transform: translateY(-4px) scale(0.985); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.menu-select-surface { + animation: menu-select-in 150ms cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: top right; +} + +.menu-select-surface[data-placement="top"] { + transform-origin: bottom right; +} + +@media (prefers-reduced-motion: reduce) { + .menu-select-surface { + animation: none; + } +} diff --git a/apps/web/app/home/page.tsx b/apps/web/app/home/page.tsx new file mode 100644 index 00000000..ce804afd --- /dev/null +++ b/apps/web/app/home/page.tsx @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +import { ProjectHome } from "@web/components/project-launcher/ProjectHome"; + +export default async function ProjectHomePage({ + searchParams, +}: { + searchParams: Promise<{ project?: string }>; +}) { + return ; +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 00000000..685ed92e --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata = { + title: "Screenshot Studio", + description: "App store screenshot editor and shared render host", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 00000000..1a39e372 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +import { ProjectLauncher } from "@web/components/project-launcher/ProjectLauncher"; +import type { ProjectLibraryState } from "@web/components/project-launcher/ProjectGallery"; +import { listStarterProjectTemplates } from "@screenshot-studio/core/scaffold"; +import { projectLibraryService } from "@web/lib/project-library.server"; + +async function loadInitialProjects(): Promise { + try { + return { + status: "ready", + entries: (await projectLibraryService.list()).entries, + }; + } catch { + return { + status: "error", + message: "Projects could not be loaded. Retry, or reopen a local project from its source.", + }; + } +} + +export default async function AppHomePage() { + const initialProjects = await loadInitialProjects(); + + return ( + + ); +} diff --git a/apps/web/app/render/RenderStage.tsx b/apps/web/app/render/RenderStage.tsx new file mode 100644 index 00000000..c33f809b --- /dev/null +++ b/apps/web/app/render/RenderStage.tsx @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { Project } from "@screenshot-studio/core/model"; + +/** + * Client stage: registers embedded fonts, preloads screenshot images, renders one screen via + * `core/engine.renderScreen`, then exposes an export hook and a readiness flag on `window`. Konva + * is imported dynamically inside the effect so it never loads during SSR (it needs a real DOM). + * + * Readiness contract: `window.__stageReady` is set to `true` ONLY after the embedded + * fonts AND the screenshot images have resolved and the stage has drawn — never before — so a + * screenshot can never capture a frame that fell back to a system font or a half-loaded image. + */ + +declare global { + interface Window { + __stageReady?: boolean; + __renderError?: string; + /** Returns a PNG data URL of the current stage at the given pixelRatio. */ + __exportPng?: (pixelRatio: number) => string; + } +} + +export function RenderStage({ + project, + layoutId, + screenId, + projectPath, +}: { + project: Project; + layoutId: string; + screenId: string; + projectPath: string; +}) { + const containerRef = useRef(null); + const [error, setError] = useState(null); + + useEffect(() => { + let stage: import("konva").default.Stage | undefined; + let cancelled = false; + + (async () => { + const container = containerRef.current; + if (!container) return; + try { + const [{ renderScreen }, { registerProjectFonts }, { loadProjectImages }] = await Promise.all([ + import("@screenshot-studio/core/engine"), + import("@screenshot-studio/core/fonts"), + import("@screenshot-studio/core/assets"), + ]); + + // Fonts AND images must resolve before the first paint: a screenshot must never + // be captured half-loaded, just like the font must never fall back. + const resolveAsset = (assetUrl: string) => + `/api/asset?project=${encodeURIComponent(projectPath)}&path=${encodeURIComponent(assetUrl)}`; + const [, images] = await Promise.all([ + registerProjectFonts(project.fonts, resolveAsset), + loadProjectImages(project, layoutId, resolveAsset), + ]); + if (cancelled) return; + + stage = renderScreen(container, project, layoutId, screenId, images); + + window.__exportPng = (pixelRatio: number) => + stage!.toDataURL({ pixelRatio, mimeType: "image/png" }); + window.__stageReady = true; + } catch (err) { + const message = (err as Error).message; + window.__renderError = message; + setError(message); + } + })(); + + return () => { + cancelled = true; + stage?.destroy(); + delete window.__stageReady; + delete window.__exportPng; + }; + }, [project, layoutId, screenId, projectPath]); + + if (error) return
{error}
; + return
; +} diff --git a/apps/web/app/render/page.test.ts b/apps/web/app/render/page.test.ts new file mode 100644 index 00000000..cd9bccee --- /dev/null +++ b/apps/web/app/render/page.test.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as React from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { projectRevision } from "@screenshot-studio/core/storage"; +import RenderPage from "./page"; + +Object.assign(globalThis, { React }); + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function projectFile(): Promise<{ path: string; revision: string }> { + const root = await mkdtemp(join(tmpdir(), "render-page-revision-")); + temporaryRoots.push(root); + const path = join(root, "app-store-screenshots.json"); + const bytes = `${JSON.stringify({ + meta: { name: "Render", schemaVersion: 1 }, + fonts: [], + preferredExportLayoutId: "primary", + layouts: [{ + id: "primary", + name: "Primary", + mode: "isolated", + screens: [{ + id: "hero", + name: "Hero", + platform: "apple", + device: "iphone", + locale: "en", + direction: "ltr", + artboard: { width: 660, height: 1434 }, + background: { type: "solid", color: "#111111" }, + elements: [], + }], + }], + })}\n`; + await writeFile(path, bytes, "utf8"); + return { path, revision: projectRevision(bytes) }; +} + +describe("RenderPage project revision pin", () => { + it("renders the exact requested project revision", async () => { + const project = await projectFile(); + + const result = await RenderPage({ + searchParams: Promise.resolve({ + project: project.path, + screen: "hero", + revision: project.revision, + }), + }); + + expect(result.props.project.meta.name).toBe("Render"); + expect(result.props.layoutId).toBe("primary"); + expect(result.props.screenId).toBe("hero"); + expect(result.props.projectPath).toBe(await realpath(project.path)); + }); + + it("fails fast instead of rendering a different on-disk revision", async () => { + const project = await projectFile(); + + const result = await RenderPage({ + searchParams: Promise.resolve({ + project: project.path, + screen: "hero", + revision: "0".repeat(64), + }), + }); + + expect(result.props["data-render-error"]).toBe("revision-mismatch"); + expect(result.props.children).toMatch(/revision/i); + }); +}); diff --git a/apps/web/app/render/page.tsx b/apps/web/app/render/page.tsx new file mode 100644 index 00000000..bfbae507 --- /dev/null +++ b/apps/web/app/render/page.tsx @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +import { resolve } from "node:path"; +import { getLayout } from "@screenshot-studio/core/model"; +import { + ProjectPathError, + readIdentifiedProjectSnapshot, + resolveExistingProjectPath, +} from "@screenshot-studio/core/storage"; +import { RenderStage } from "./RenderStage"; + +/** + * Headless render host (docs/editor.md). One dev-only route, no editor chrome. + * This route's RenderStage is the static host used by CLI export and the parity harness. The + * interactive editor uses EditorStage; both consume the shared core background/element node-config + * contract so render/export parity remains centralized (docs/architecture.md). + * + * The project is read from disk by path (`?project=`) and validated with the same `core` loader + * the CLI uses. Arbitrary local file read is acceptable here: the product is a local, self-hosted + * tool and this route is dev-only. + */ +export const dynamic = "force-dynamic"; + +export default async function RenderPage({ + searchParams, +}: { + searchParams: Promise<{ project?: string; layout?: string; screen?: string; revision?: string }>; +}) { + const { project: projectPath, layout: requestedLayoutId, screen: screenId, revision } = await searchParams; + if (!projectPath) { + return
Missing ?project= path
; + } + + let canonicalProjectPath: string; + try { + canonicalProjectPath = await resolveExistingProjectPath(resolve(projectPath)); + } catch (err) { + return
Cannot read project: {(err as Error).message}
; + } + + let snapshot; + try { + snapshot = await readIdentifiedProjectSnapshot(canonicalProjectPath); + } catch (err) { + const kind = err instanceof ProjectPathError ? "read-failed" : "invalid-project"; + return
{(err as Error).message}
; + } + + if (revision !== undefined && snapshot.revision !== revision) { + return ( +
+ Project revision changed before render +
+ ); + } + + const project = snapshot.project; + const layoutId = requestedLayoutId ?? project.preferredExportLayoutId; + let layout; + try { + layout = getLayout(project, layoutId); + } catch (error) { + return
{(error as Error).message}
; + } + const targetScreen = screenId ?? layout.screens[0]!.id; + return ( + + ); +} diff --git a/apps/web/components/ArtboardTargets.test.ts b/apps/web/components/ArtboardTargets.test.ts new file mode 100644 index 00000000..315d940c --- /dev/null +++ b/apps/web/components/ArtboardTargets.test.ts @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: Apache-2.0 +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import type { DesignLayout, Screen } from "@screenshot-studio/core/model"; +import type { StoreSpecs } from "@screenshot-studio/core/export"; +import { ArtboardTargets } from "./ArtboardTargets.js"; +import { editorUi } from "./editor-ui/styles.js"; + +// Vitest compiles the Next.js-preserved JSX with the classic runtime for this Node-only render. +Object.assign(globalThis, { React }); + +const screen = { + id: "web-desktop", + name: "Hero", + platform: "web", + device: "desktop", + locale: "en", + direction: "ltr", + artboard: { width: 1920, height: 1080 }, + background: { type: "solid", color: "#ffffff" }, + elements: [], +} as Screen; +const layout = { + id: "primary", + name: "Primary", + mode: "isolated", + screens: [screen], +} as DesignLayout; +const specs = { + web: { + desktop: [ + { + id: "desktop-1920", + label: "Web desktop 1920×1080 (lead)", + width: 1920, + height: 1080, + orientation: "landscape", + kind: "responsive", + }, + { + id: "desktop-1440", + label: "Web desktop 1440×900", + width: 1440, + height: 900, + orientation: "landscape", + kind: "responsive", + }, + ], + mobile: [{ + id: "mobile-portrait", + label: "Web mobile portrait (lead)", + width: 390, + height: 844, + orientation: "portrait", + kind: "responsive", + }], + }, +} as StoreSpecs; + +describe("ArtboardTargets", () => { + it("keeps the lead marker out of the target name and orders it before master", () => { + const markup = renderToStaticMarkup(React.createElement(ArtboardTargets, { + layout, + screen, + specs, + onOpen: vi.fn(), + onCreateOutput: vi.fn(), + onCreate: vi.fn(), + onRemove: vi.fn(), + onScreenSetModeChange: vi.fn(), + })); + + const name = markup.match(/data-testid="store-target-name"[^>]*>(.*?)<\/span>/)?.[1]; + const roles = markup.match(/data-testid="store-target-roles"[^>]*>(.*?)<\/span><\/span>/)?.[1] ?? ""; + expect(name).toBe("Web desktop 1920×1080"); + expect(roles.indexOf("lead")).toBeGreaterThanOrEqual(0); + expect(roles.indexOf("lead")).toBeLessThan(roles.indexOf("master")); + }); + + it("uses accessible actions and lets selection styling replace redundant Current controls", () => { + const variant = { + ...screen, + id: "web-desktop-1440", + variantOf: screen.id, + storeTargetId: "desktop-1440", + artboard: { width: 1440, height: 900 }, + } as Screen; + const mobile = { + ...screen, + id: "web-mobile", + device: "mobile", + artboard: { width: 390, height: 844 }, + } as Screen; + const markup = renderToStaticMarkup(React.createElement(ArtboardTargets, { + layout: { ...layout, screens: [screen, variant, mobile] }, + screen, + specs, + onOpen: vi.fn(), + onCreateOutput: vi.fn(), + onCreate: vi.fn(), + onRemove: vi.fn(), + onScreenSetModeChange: vi.fn(), + })); + + expect(markup).toContain('aria-label="Remove Web desktop 1440×900 variant"'); + expect(markup).toContain('aria-label="Open Web desktop 1440×900"'); + expect(markup).toMatch(/aria-label="Open [^"]+ output"/); + expect(markup).toContain('aria-current="true"'); + expect(markup).not.toContain("Current"); + expect(markup).toContain(editorUi.rowIconButtonDanger); + expect(markup).not.toContain(">Remove<"); + expect(markup).not.toContain(">Open<"); + }); + + it("keeps family size targets visible when a sibling master is selected", () => { + const siblingMaster = { + ...screen, + id: "web-desktop-secondary", + name: "Secondary", + } as Screen; + const firstRootVariant = { + ...screen, + id: "web-desktop-1440", + variantOf: screen.id, + storeTargetId: "desktop-1440", + artboard: { width: 1440, height: 900 }, + } as Screen; + const markup = renderToStaticMarkup(React.createElement(ArtboardTargets, { + layout: { ...layout, screens: [screen, siblingMaster, firstRootVariant] }, + screen: siblingMaster, + specs, + onOpen: vi.fn(), + onCreateOutput: vi.fn(), + onCreate: vi.fn(), + onRemove: vi.fn(), + onScreenSetModeChange: vi.fn(), + })); + + expect(markup).toContain('aria-label="Web desktop 1440×900 store target"'); + expect(markup).toContain('aria-label="Open Web desktop 1440×900"'); + expect(markup).toContain('aria-label="Remove Web desktop 1440×900 variant"'); + }); + + it("does not leak a size target from another locale into the active family", () => { + const frenchRoot = { + ...screen, + id: "web-desktop-fr", + locale: "fr", + } as Screen; + const frenchVariant = { + ...frenchRoot, + id: "web-desktop-1440-fr", + variantOf: frenchRoot.id, + storeTargetId: "desktop-1440", + artboard: { width: 1440, height: 900 }, + } as Screen; + const markup = renderToStaticMarkup(React.createElement(ArtboardTargets, { + layout: { ...layout, screens: [screen, frenchRoot, frenchVariant] }, + screen, + specs, + onOpen: vi.fn(), + onCreateOutput: vi.fn(), + onCreate: vi.fn(), + onRemove: vi.fn(), + onScreenSetModeChange: vi.fn(), + })); + + expect(markup).not.toContain('aria-label="Web desktop 1440×900 store target"'); + }); + + it("explains and exposes the persisted screen-slot behavior without conflating layers", () => { + const markup = renderToStaticMarkup(React.createElement(ArtboardTargets, { + layout: { + ...layout, + screenSetPolicies: [{ + platform: "web", + device: "desktop", + locale: "en", + mode: "synchronized", + }], + }, + screen, + specs, + onOpen: vi.fn(), + onCreateOutput: vi.fn(), + onCreate: vi.fn(), + onRemove: vi.fn(), + onScreenSetModeChange: vi.fn(), + })); + + expect(markup).toContain("Screen slots"); + expect(markup).toContain("Keep 1:1"); + expect(markup).toContain('aria-pressed="true"'); + expect(markup).toContain("Layer edits stay independent."); + expect(markup).toContain(editorUi.segmentedControl); + expect(markup.indexOf("Screen slots")).toBeLessThan( + markup.indexOf('aria-label="Screen slot behavior"'), + ); + }); + + it("offers promotion outputs as targets without treating them as device families", () => { + const androidScreen = { + ...screen, + id: "android-phone", + platform: "android", + device: "phone", + artboard: { width: 1080, height: 1920 }, + } as Screen; + const featureGraphic = { + ...androidScreen, + id: "android-phone-feature-graphic", + variantOf: androidScreen.id, + storeTargetId: "play-feature-graphic", + artboard: { width: 1024, height: 500 }, + } as Screen; + const androidSpecs = { + android: { + phone: [{ + id: "phone-portrait", + label: "Android phone portrait (lead)", + width: 1080, + height: 1920, + orientation: "portrait", + kind: "device", + }], + "$outputs": [{ + id: "play-feature-graphic", + label: "Google Play Feature Graphic", + width: 1024, + height: 500, + orientation: "landscape", + kind: "promotion", + family: "phone", + }], + }, + } as StoreSpecs; + const markup = renderToStaticMarkup(React.createElement(ArtboardTargets, { + layout: { ...layout, screens: [androidScreen, featureGraphic] }, + screen: featureGraphic, + specs: androidSpecs, + onOpen: vi.fn(), + onCreateOutput: vi.fn(), + onCreate: vi.fn(), + onRemove: vi.fn(), + onScreenSetModeChange: vi.fn(), + })); + + expect(markup).toContain("Google Play Feature Graphic"); + expect(markup).not.toContain("$outputs"); + expect(markup).not.toContain("promotion output"); + }); + + it("shows a standalone exact promotion target without inventing a primary device output", () => { + const featureGraphic = { + ...screen, + id: "feature-graphic", + platform: "android", + device: "phone", + storeTargetId: "play-feature-graphic", + artboard: { width: 1024, height: 500 }, + } as Screen; + const androidSpecs = { + android: { + phone: [{ + id: "phone-portrait", + label: "Android phone portrait (lead)", + width: 1080, + height: 1920, + orientation: "portrait", + kind: "device", + }], + "$outputs": [{ + id: "play-feature-graphic", + label: "Google Play Feature Graphic", + width: 1024, + height: 500, + orientation: "landscape", + kind: "promotion", + family: "phone", + }], + }, + } as StoreSpecs; + const markup = renderToStaticMarkup(React.createElement(ArtboardTargets, { + layout: { ...layout, screens: [featureGraphic] }, + screen: featureGraphic, + specs: androidSpecs, + onOpen: vi.fn(), + onCreateOutput: vi.fn(), + onCreate: vi.fn(), + onRemove: vi.fn(), + onScreenSetModeChange: vi.fn(), + })); + + expect(markup).toContain("Google Play Feature Graphic"); + expect(markup).not.toContain("This exact size has no primary output"); + expect(markup).not.toContain("Screen slots"); + expect(markup).not.toContain("Android phone portrait"); + expect(markup).not.toContain("Remove Google Play Feature Graphic variant"); + }); +}); diff --git a/apps/web/components/ArtboardTargets.tsx b/apps/web/components/ArtboardTargets.tsx new file mode 100644 index 00000000..d4818e56 --- /dev/null +++ b/apps/web/components/ArtboardTargets.tsx @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useState } from "react"; +import { ArrowRight, Trash2 } from "lucide-react"; +import { + isPrimaryScreen, + sameOutputFamily, + screenSetModeFor, + type DesignLayout, + type Screen, + type ScreenSetMode, +} from "@screenshot-studio/core/model"; +import { + leadStoreSizeId, + listStoreTargets, + STORE_OUTPUT_TARGETS_KEY, + type StoreSize, + type StoreSpecs, +} from "@screenshot-studio/core/export"; +import { storeOutputLabel } from "@web/lib/editor-workspace"; +import { AddMenu, ConfirmationDialog, editorUi, SegmentedControl } from "./editor-ui"; + +/** + * Store-target navigation never mutates the active artboard. The lead row opens the root/master; + * non-lead rows create or open exact-target variants with fresh persisted identities. Screen-slot + * synchronization is an authoring policy and never changes target identity or rendered pixels. + */ +export function ArtboardTargets({ + layout, + screen, + specs, + onOpen, + onCreateOutput, + onCreate, + onRemove, + onScreenSetModeChange, +}: { + layout: DesignLayout; + screen: Screen; + specs: StoreSpecs; + onOpen: (screenId: string) => void; + onCreateOutput: (platform: string, device: string, leadTarget: StoreSize) => void; + onCreate: (target: StoreSize) => void; + onRemove: (rootScreenId: string, targetId: string) => void; + onScreenSetModeChange: (mode: ScreenSetMode) => void; +}) { + const [pendingRemoval, setPendingRemoval] = useState<{ + rootScreenId: string; + targetId: string; + label: string; + } | null>(null); + const targets: StoreSize[] = (() => { + try { + return listStoreTargets(specs, screen.platform, screen.device); + } catch { + return []; + } + })(); + const families = Object.entries(specs).flatMap(([platform, devices]) => + Object.entries(devices) + .filter(([device]) => device !== STORE_OUTPUT_TARGETS_KEY) + .map(([device, sizes]) => ({ platform, device, sizes })), + ); + const root = screen.variantOf + ? layout.screens.find((candidate) => candidate.id === screen.variantOf) + : isPrimaryScreen(screen) + ? screen + : layout.screens.find((candidate) => sameOutputFamily(candidate, screen) && isPrimaryScreen(candidate)); + const rootId = root?.id; + const screenSetMode = root ? screenSetModeFor(layout, root) : "independent"; + let leadId: string | undefined; + try { + leadId = leadStoreSizeId(screen.platform, screen.device); + } catch { + leadId = undefined; + } + + if (targets.length === 0) { + return

No store targets for {screen.platform}/{screen.device}.

; + } + + const familyRows = families.map(({ platform, device, sizes }) => { + const existingRoot = layout.screens.find((candidate) => + isPrimaryScreen(candidate) + && candidate.platform === platform + && candidate.device === device + && candidate.locale === screen.locale, + ); + let familyLeadId: string | undefined; + try { + familyLeadId = leadStoreSizeId(platform, device); + } catch { + familyLeadId = undefined; + } + return { + key: `${platform}/${device}`, + platform, + device, + label: storeOutputLabel(platform, device), + existingRoot, + leadTarget: sizes.find((size) => size.id === familyLeadId) ?? sizes[0], + }; + }); + const createdFamilies = familyRows.filter((family) => family.existingRoot); + const availableFamilies = root + ? familyRows.filter((family) => !family.existingRoot && family.leadTarget) + : []; + const familyVariantsByTargetId = new Map(); + const rootVariantsByTargetId = new Map(); + for (const candidate of layout.screens) { + if ( + !candidate.storeTargetId + || candidate.platform !== screen.platform + || candidate.device !== screen.device + || candidate.locale !== screen.locale + ) continue; + familyVariantsByTargetId.set(candidate.storeTargetId, ( + familyVariantsByTargetId.get(candidate.storeTargetId) ?? candidate + )); + if (rootId && candidate.variantOf === rootId) { + rootVariantsByTargetId.set(candidate.storeTargetId, candidate); + } + } + const createdTargets = targets.filter((target) => ( + (root !== undefined && target.id === leadId) || familyVariantsByTargetId.has(target.id) + )); + const availableTargets = root + ? targets.filter((target) => target.id !== leadId && !familyVariantsByTargetId.has(target.id)) + : []; + + return ( +
+
+
+

Store outputs

+ ({ + value: family.key, + label: family.label, + description: "Create from the current output", + }))} + onSelect={(key) => { + const family = availableFamilies.find((candidate) => candidate.key === key); + if (family?.leadTarget) onCreateOutput(family.platform, family.device, family.leadTarget); + }} + /> +
+ {root ?
+ Screen slots + +

+ {screenSetMode === "synchronized" + ? "Add, delete, and order mirror across sizes. Layer edits stay independent." + : "Each size keeps its own screen count and order."} +

+
: null} +
+ +
+ {createdFamilies.map(({ key, platform, device, label, existingRoot }) => { + const isCurrentFamily = platform === screen.platform && device === screen.device; + return ( +
+ + {label} + created + + {!isCurrentFamily && existingRoot ? ( + + ) : null} +
+ ); + })} +
+ +
+
+

+ {storeOutputLabel(screen.platform, screen.device)} sizes +

+ ({ + value: target.id, + label: target.label, + description: `${target.width} × ${target.height} px`, + }))} + onSelect={(targetId) => { + const target = availableTargets.find((candidate) => candidate.id === targetId); + if (target) onCreate(target); + }} + /> +
+ {createdTargets.map((target) => { + const isLead = root !== undefined && target.id === leadId; + const rootVariant = rootVariantsByTargetId.get(target.id); + const targetScreen = isLead + ? root + : rootVariant ?? familyVariantsByTargetId.get(target.id); + const isCurrent = target.id === leadId + ? isPrimaryScreen(screen) + : screen.storeTargetId === target.id; + return ( +
+ + + {isLead ? target.label.replace(/\s+\(lead\)$/i, "") : target.label} + + + {isLead && ( + lead + )} + + {isLead ? "master" : "variant"} + + + + + {!isLead && root && targetScreen && ( + + )} + {!isCurrent && targetScreen ? ( + + ) : null} + +
+ ); + })} +
+ {pendingRemoval ? ( + setPendingRemoval(null)} + onConfirm={() => { + const removal = pendingRemoval; + setPendingRemoval(null); + onRemove(removal.rootScreenId, removal.targetId); + }} + /> + ) : null} +
+ ); +} diff --git a/apps/web/components/AssetPickerDialog.tsx b/apps/web/components/AssetPickerDialog.tsx new file mode 100644 index 00000000..fd89b40a --- /dev/null +++ b/apps/web/components/AssetPickerDialog.tsx @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import NextImage from "next/image"; +import { FolderOpen, Image as ImageIcon, Search, Smartphone, Upload } from "lucide-react"; +import { + createDeviceFrameElement, + createImageElement, + createScreenshotElement, +} from "@screenshot-studio/core/model"; +import { + filterProjectImageAssets, + type ProjectImageAsset, +} from "@web/lib/project-assets"; +import { editorStore } from "@web/lib/editor-store"; +import { mutateAndSave } from "@web/lib/mutate-and-save"; +import { projectJsonRequest } from "@web/lib/project-api-client"; +import { resolveActiveProjectAssetUrl } from "@web/lib/save-project"; +import { + initialMediaElementSize, + insertAssetAfterCaching, + type CachedAssetDimensions, +} from "./asset-picker-model"; +import { DialogShell, EmptyResourceState, editorUi, StatusMessage } from "./editor-ui"; +import { DIALOG_EXIT_DURATION_MS } from "./editor-ui/DialogShell"; + +type AssetElementType = "screenshot" | "image" | "background" | "store-badge" | "device-frame"; + +function actionableAssetError(message: string): string { + if (/unable to reopen the managed project/i.test(message)) { + return "This project session is no longer connected to its managed files. Reopen the project from Projects, then choose or import an image."; + } + if (/active project changed|project identity is unavailable/i.test(message)) { + return "The active project changed. Reopen this project before choosing or importing an image."; + } + return message; +} + +export function AssetPickerDialog({ + elementType, + targetDeviceFrame, + cacheAsset, + onClose, +}: { + elementType: AssetElementType; + targetDeviceFrame?: { elementId: string; screenId: string }; + cacheAsset: (path: string) => Promise; + onClose: () => void; +}) { + const [assets, setAssets] = useState(null); + const [query, setQuery] = useState(""); + const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); + const [dialogMotion, setDialogMotion] = useState<"open" | "closing">("open"); + const fileInputRef = useRef(null); + const activeRef = useRef(true); + const closingRef = useRef(false); + const closeTimerRef = useRef(null); + const onCloseRef = useRef(onClose); + useEffect(() => { onCloseRef.current = onClose; }, [onClose]); + + useEffect(() => { + activeRef.current = true; + return () => { + activeRef.current = false; + if (closeTimerRef.current !== null) window.clearTimeout(closeTimerRef.current); + }; + }, []); + + useEffect(() => { + let cancelled = false; + void projectJsonRequest<{ assets?: unknown }>("/api/assets", { + errorFallback: "Asset listing failed", + }) + .then(({ body }) => { + if (!Array.isArray(body.assets)) throw new Error("Asset listing returned an invalid response"); + if (!cancelled) setAssets(body.assets as ProjectImageAsset[]); + }) + .catch((loadError) => { + if (!cancelled) setError(actionableAssetError((loadError as Error).message)); + }); + return () => { cancelled = true; }; + }, []); + + const visibleAssets = useMemo( + () => filterProjectImageAssets(assets ?? [], query), + [assets, query], + ); + const isFrame = elementType === "device-frame"; + const isImage = elementType === "image"; + const isBackground = elementType === "background"; + const isStoreBadge = elementType === "store-badge"; + const isClosing = dialogMotion === "closing"; + const isCurrent = () => activeRef.current && !closingRef.current; + const requestClose = () => { + if (closingRef.current) return; + closingRef.current = true; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + onCloseRef.current(); + return; + } + setDialogMotion("closing"); + closeTimerRef.current = window.setTimeout(() => { + closeTimerRef.current = null; + onCloseRef.current(); + }, DIALOG_EXIT_DURATION_MS); + }; + const insert = (path: string, dimensions: CachedAssetDimensions) => { + if (!isCurrent()) return; + if (isBackground) { + mutateAndSave(() => editorStore.getState().setBackground({ + type: "image", + assetUrl: path, + fit: "cover", + color: "#000000", + })); + requestClose(); + return; + } + if (isFrame && targetDeviceFrame) { + mutateAndSave(() => editorStore.getState().patchElement({ + id: targetDeviceFrame.elementId, + screenId: targetDeviceFrame.screenId, + type: "device-frame", + patch: { assetUrl: path }, + })); + requestClose(); + return; + } + const element = isFrame + ? createDeviceFrameElement({ assetUrl: path }) + : isStoreBadge + ? { ...createImageElement({ assetUrl: path, width: 420, height: 140 }), name: "Store badge" } + : isImage + ? createImageElement({ assetUrl: path, ...initialMediaElementSize(dimensions) }) + : createScreenshotElement({ assetUrl: path, ...initialMediaElementSize(dimensions) }); + mutateAndSave(() => editorStore.getState().addElement(element)); + requestClose(); + }; + + const cacheAndInsert = async (path: string) => { + if (!isCurrent()) return; + setUploading(true); + setError(null); + try { + await insertAssetAfterCaching(path, cacheAsset, insert, isCurrent); + } catch (loadError) { + if (!isCurrent()) return; + setError(actionableAssetError((loadError as Error).message)); + setUploading(false); + } + }; + + const importAndInsert = async (file: File) => { + if (!isCurrent()) return; + setUploading(true); + setError(null); + try { + const form = new FormData(); + form.set("file", file); + const { body } = await projectJsonRequest<{ + asset?: ProjectImageAsset; + error?: string; + }>("/api/assets", { + method: "POST", + conditional: true, + advanceRevision: true, + body: form, + errorFallback: "Image import failed", + }); + if (!body.asset?.path || !body.asset.name || body.asset.source !== "assets") { + throw new Error("Image import returned an invalid response"); + } + if (!isCurrent()) return; + setAssets((current) => [...(current ?? []), body.asset!].sort((first, second) => ( + first.path < second.path ? -1 : first.path > second.path ? 1 : 0 + ))); + await insertAssetAfterCaching( + body.asset.path, + cacheAsset, + insert, + isCurrent, + ); + } catch (uploadError) { + if (!isCurrent()) return; + setError(actionableAssetError((uploadError as Error).message)); + setUploading(false); + } finally { + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + return ( + +
+ + { + const file = event.currentTarget.files?.[0]; + if (file) void importAndInsert(file); + }} + /> + +
+ + {error ? ( + {error} + ) : assets === null ? ( +

Scanning project images…

+ ) : visibleAssets.length === 0 ? ( + + ) : ( +
+ {visibleAssets.map((asset) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/components/CanvasViewport.tsx b/apps/web/components/CanvasViewport.tsx new file mode 100644 index 00000000..09542432 --- /dev/null +++ b/apps/web/components/CanvasViewport.tsx @@ -0,0 +1,509 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useStore } from "zustand"; +import { Grid3X3, Grip, Maximize2, Minus, Plus, SquareDashed } from "lucide-react"; +import type { ImageCache } from "@screenshot-studio/core/assets"; +import { findStoreTarget, leadStoreSizeId, type StoreSpecs } from "@screenshot-studio/core/export"; +import type { Screen } from "@screenshot-studio/core/model"; +import { + CANVAS_PADDING, + compactStoreSizeLabel, + OVERVIEW_GAP, + storeOutputLabel, + workspaceEditingScope, + workspaceOverviewRows, + type CanvasPattern, + type WorkspaceOutputFamily, + type WorkspaceTool, + type WorkspaceView, +} from "@web/lib/editor-workspace"; +import { editorStore, selectEditorLayout } from "@web/lib/editor-store"; +import { EditorStage } from "./EditorStage"; +import { CanvasSurface } from "./canvas-viewport/CanvasSurface"; +import { ConnectedOutputRow } from "./canvas-viewport/ConnectedOutputRow"; +import { canvasTransitionKind, type CanvasTransitionState } from "./canvas-viewport/layout-transition"; +import { OutputFamilySection } from "./canvas-viewport/OutputFamilySection"; +import { ScreenPreviewMetadata, ScreenPreviewTitle } from "./canvas-viewport/ScreenPreviewLabels"; +import { SizeRowHeader } from "./canvas-viewport/SizeRowHeader"; +import { useCanvasPanZoom } from "./canvas-viewport/use-canvas-pan-zoom"; +import { useCanvasViewportMetrics } from "./canvas-viewport/use-canvas-viewport-metrics"; +import { cx, editorUi, MenuSelect, type MenuSelectOption } from "./editor-ui"; + +const canvasPatternOptions: MenuSelectOption[] = [ + { value: "dots", label: "Dots", icon: Grip }, + { value: "grid", label: "Grid", icon: Grid3X3 }, + { value: "none", label: "None", icon: SquareDashed }, +]; + +interface IsolatedOverviewFamily { + key: string; + label: string; + rows: WorkspaceOutputFamily[]; + editing: boolean; +} + +function groupIsolatedOverviewRows(rows: readonly WorkspaceOutputFamily[]): IsolatedOverviewFamily[] { + const families = new Map(); + for (const row of rows) { + const screen = row.screens[0]; + if (!screen) continue; + const key = `${screen.platform}/${screen.device}`; + const family = families.get(key) ?? { + key, + label: storeOutputLabel(screen.platform, screen.device), + rows: [], + editing: false, + }; + family.rows.push(row); + family.editing ||= row.editing; + families.set(key, family); + } + return Array.from(families.values()); +} + +function outputSizeLabel(row: WorkspaceOutputFamily, specs: StoreSpecs | null): string { + const screen = row.screens[0]; + if (!screen) return "Size"; + let targetId = screen.storeTargetId; + if (targetId === undefined) { + try { + targetId = leadStoreSizeId(screen.platform, screen.device); + } catch { + return "Master"; + } + } + let label = targetId; + if (specs) { + try { + label = findStoreTarget(specs, screen.platform, screen.device, targetId).label; + } catch { + // Keep the persisted id visible when store specs cannot resolve it. + } + } + return compactStoreSizeLabel(label, screen.device); +} + +export function CanvasViewport({ + images, + specs, + zoom, + view, + tool, + pattern, + onZoomChange, + onPatternChange, + leftInset = 0, + rightInset = 0, + controlsHidden = false, +}: { + images: ImageCache; + specs: StoreSpecs | null; + zoom: number; + view: WorkspaceView; + tool: WorkspaceTool; + pattern: CanvasPattern; + onZoomChange: (zoom: number) => void; + onPatternChange: (pattern: CanvasPattern) => void; + leftInset?: number; + rightInset?: number; + controlsHidden?: boolean; +}) { + const project = useStore(editorStore, (state) => state.project); + const layout = useStore(editorStore, selectEditorLayout); + const activeScreenId = useStore(editorStore, (state) => state.activeScreenId); + const viewportRef = useRef(null); + const contentRef = useRef(null); + const lastFitLayoutRef = useRef(null); + const previousCanvasStateRef = useRef({ + layoutId: layout?.id ?? null, + layoutMode: layout?.mode ?? null, + view, + }); + const initialRevealStartedRef = useRef(false); + const initialRevealRef = useRef(null); + const initialRevealFrameRef = useRef(null); + const transitionRef = useRef(null); + const transitionFrameRef = useRef(null); + const zoomRef = useRef(zoom); + const [fittedView, setFittedView] = useState(view); + const [initialReveal, setInitialReveal] = useState<"pending" | "running" | "idle">("pending"); + const [transitioning, setTransitioning] = useState(false); + + const activeScreen = layout?.screens.find((screen) => screen.id === activeScreenId) ?? layout?.screens[0]; + const editingScreens = useMemo(() => { + if (!layout || !activeScreen) return []; + return workspaceEditingScope(layout, activeScreen); + }, [activeScreen, layout]); + const outputRows = useMemo(() => { + if (!layout || !activeScreen || view !== "overview") return []; + return workspaceOverviewRows(layout, activeScreen); + }, [activeScreen, layout, view]); + const isolatedOutputFamilies = useMemo( + () => groupIsolatedOverviewRows(outputRows), + [outputRows], + ); + const screens = view === "focus" && activeScreen ? [activeScreen] : editingScreens; + const { fitLayoutKey, fit } = useCanvasViewportMetrics({ + viewportRef, + zoomRef, + screens, + outputRows, + view, + connected: layout?.mode === "connected", + leftInset, + rightInset, + onZoomChange, + }); + const { spacePressed, panning, zoomAt, pointerHandlers } = useCanvasPanZoom({ + viewportRef, + zoomRef, + zoom, + tool, + leftInset, + rightInset, + onZoomChange, + }); + + useLayoutEffect(() => { + if (initialRevealStartedRef.current) return; + initialRevealStartedRef.current = true; + + // Keep the default zoom invisible, install the first fit, then reveal the settled canvas. + fit(); + setFittedView(view); + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + initialRevealFrameRef.current = requestAnimationFrame(() => { + initialRevealFrameRef.current = null; + setInitialReveal("idle"); + }); + return; + } + + initialRevealFrameRef.current = requestAnimationFrame(() => { + initialRevealFrameRef.current = null; + setInitialReveal("running"); + const content = contentRef.current; + if (!content) { + setInitialReveal("idle"); + return; + } + const animation = content.animate( + [{ opacity: 0 }, { opacity: 1 }], + { duration: 180, easing: "ease-out", fill: "forwards" }, + ); + initialRevealRef.current = animation; + animation.finished.then(() => { + if (initialRevealRef.current !== animation) return; + initialRevealRef.current = null; + animation.commitStyles(); + animation.cancel(); + setInitialReveal("idle"); + }).catch(() => undefined); + }); + }, [fit, view]); + + useLayoutEffect(() => { + const nextState: CanvasTransitionState = { + layoutId: layout?.id ?? null, + layoutMode: layout?.mode ?? null, + view, + }; + const transitionKind = canvasTransitionKind(previousCanvasStateRef.current, nextState); + previousCanvasStateRef.current = nextState; + if (transitionKind === "none") return; + + transitionRef.current?.cancel(); + transitionRef.current = null; + if (transitionFrameRef.current !== null) cancelAnimationFrame(transitionFrameRef.current); + transitionFrameRef.current = null; + + // Install the destination fit during layout so the stale source zoom is never painted. + fit(); + setFittedView(view); + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + return; + } + + transitionFrameRef.current = requestAnimationFrame(() => { + transitionFrameRef.current = null; + setTransitioning(true); + const content = contentRef.current; + if (!content) { + setTransitioning(false); + return; + } + + const activeArtboard = content.querySelector(`[data-artboard-screen-id="${CSS.escape(activeScreenId ?? "")}"]`); + if (activeArtboard) { + const contentRect = content.getBoundingClientRect(); + const artboardRect = activeArtboard.getBoundingClientRect(); + content.style.transformOrigin = `${artboardRect.left - contentRect.left + artboardRect.width / 2}px ${artboardRect.top - contentRect.top + artboardRect.height / 2}px`; + } else { + content.style.transformOrigin = "center center"; + } + + const animation = content.animate( + [ + transitionKind === "layout" + ? { opacity: 0.82, transform: "scale(0.985)" } + : { opacity: 0.88, transform: view === "overview" ? "scale(1.025)" : "scale(0.975)" }, + { opacity: 1, transform: "scale(1)" }, + ], + { + duration: transitionKind === "layout" ? 220 : 320, + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + }, + ); + transitionRef.current = animation; + animation.finished.then(() => { + if (transitionRef.current !== animation) return; + transitionRef.current = null; + content.style.removeProperty("transform-origin"); + setTransitioning(false); + }).catch(() => undefined); + }); + }, [activeScreenId, fit, layout?.id, layout?.mode, view]); + + useEffect(() => () => { + initialRevealRef.current?.cancel(); + transitionRef.current?.cancel(); + if (initialRevealFrameRef.current !== null) cancelAnimationFrame(initialRevealFrameRef.current); + if (transitionFrameRef.current !== null) cancelAnimationFrame(transitionFrameRef.current); + }, []); + + useEffect(() => { + if (lastFitLayoutRef.current === fitLayoutKey) return; + lastFitLayoutRef.current = fitLayoutKey; + const id = requestAnimationFrame(fit); + return () => cancelAnimationFrame(id); + }, [fit, fitLayoutKey]); + + useEffect(() => { + if (view !== "overview" || !activeScreenId) return; + const id = requestAnimationFrame(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const target = Array.from( + viewport.querySelectorAll("[data-artboard-screen-id]"), + ).find((element) => element.dataset.artboardScreenId === activeScreenId); + if (!target) return; + + const viewportRect = viewport.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const visibleLeft = viewportRect.left + leftInset; + const visibleRight = viewportRect.right - rightInset; + const fullyVisible = targetRect.left >= visibleLeft + && targetRect.right <= visibleRight + && targetRect.top >= viewportRect.top + && targetRect.bottom <= viewportRect.bottom; + if (fullyVisible) return; + + const visibleWidth = Math.max(1, viewport.clientWidth - leftInset - rightInset); + const targetCenterX = viewport.scrollLeft + targetRect.left - viewportRect.left + targetRect.width / 2; + const targetCenterY = viewport.scrollTop + targetRect.top - viewportRect.top + targetRect.height / 2; + viewport.scrollTo({ + left: targetCenterX - leftInset - visibleWidth / 2, + top: targetCenterY - viewport.clientHeight / 2, + behavior: "smooth", + }); + }); + return () => cancelAnimationFrame(id); + }, [activeScreenId, leftInset, rightInset, view]); + + if (!project || !layout || screens.length === 0) { + return
; + } + + const renderIsolatedScreen = (screen: Screen) => { + const active = screen.id === activeScreenId; + const previewWidth = screen.artboard.width * zoom; + return ( +
+ +
{ + if (!active && view === "overview") editorStore.getState().selectScreen(screen.id); + }} + onKeyDown={(event) => { + if (!active && view === "overview" && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + editorStore.getState().selectScreen(screen.id); + } + }} + > + +
+ +
+ ); + }; + + return ( +
+ editorStore.getState().setSelection([]) : undefined} + > +
+
+ {view === "overview" ? ( +
+ {layout.mode === "connected" ? outputRows.map((row) => ( + editorStore.getState().selectScreen(screenId)} + /> + )) : isolatedOutputFamilies.map((family) => { + const masterRow = family.rows.find((row) => row.screens[0]?.storeTargetId === undefined); + const variantRows = family.rows.filter((row) => row !== masterRow); + const artworkWidth = Math.max(...family.rows.map((row) => + row.screens.reduce((width, screen) => width + screen.artboard.width * zoom, 0) + + OVERVIEW_GAP * Math.max(0, row.screens.length - 1) + )); + return ( + +
+ {masterRow ? ( +
+
+ {masterRow.screens.map(renderIsolatedScreen)} +
+
+ ) : null} + {variantRows.map((row) => ( +
+ +
+ {row.screens.map(renderIsolatedScreen)} +
+
+ ))} +
+
+ ); + })} +
+ ) : ( +
+ {screens.map(renderIsolatedScreen)} +
+ )} +
+
+
+ + {!controlsHidden ? ( +
+ option.value === pattern)?.label ?? "Dots"}`} + menuLabel="Canvas background" + value={pattern} + options={canvasPatternOptions} + onChange={onPatternChange} + className="min-w-28" + triggerClassName="h-9 min-w-28 rounded-xl border-slate-200/80 px-2 font-medium text-slate-600" + menuClassName="w-40" + showSelectedIcon + /> +
+ ) : null} + + {!controlsHidden ?
+ + + +
: null} +
+ ); +} diff --git a/apps/web/components/EditorStage.tsx b/apps/web/components/EditorStage.tsx new file mode 100644 index 00000000..029c5335 --- /dev/null +++ b/apps/web/components/EditorStage.tsx @@ -0,0 +1,428 @@ +// SPDX-License-Identifier: Apache-2.0 +"use client"; + +import { useLayoutEffect, useRef, type Key } from "react"; +import Konva from "konva"; +import { Stage, Layer, Text, Rect, Ellipse, Image as KonvaImage, Group, Line, Path, Transformer } from "react-konva"; +import { useStore } from "zustand"; +import { + assertNever, + type Element, +} from "@screenshot-studio/core/model"; +import { applyNodeFilter, backgroundToNodeConfig, elementToNodeConfig, type NodeConfig } from "@screenshot-studio/core/engine"; +import type { ImageCache } from "@screenshot-studio/core/assets"; +import { editorStore, selectEditorLayout, type CommonElementPatch } from "@web/lib/editor-store"; +import { isSelected } from "@web/lib/editor-selection"; +import { mutateAndSave } from "@web/lib/mutate-and-save"; +import { deriveStageModel } from "./editor-stage/stage-model"; +import { useElementTransform } from "./editor-stage/use-element-transform"; +import { useInlineTextEditing } from "./editor-stage/use-inline-text-editing"; +import { useMarqueeSelection } from "./editor-stage/use-marquee-selection"; +import { transformerAppearance } from "./editor-stage/transformer-appearance"; +import { nodeSelectionKey, useTransformerSelection } from "./editor-stage/use-transformer-selection"; + +/** + * Interactive canvas over the same elementToNodeConfig map used by export. Isolated mode renders + * one screen. Connected Overview renders one gapless group stage; owner offset Groups translate + * screen-local element coordinates without changing persisted ownership or geometry. Connected + * Focus uses the same projection with the active artboard as a non-interactive exact crop preview. + */ +function renderNodeConfigChild(child: NodeConfig, key: Key, listening: boolean, name?: string) { + switch (child.konva) { + case "Image": + return ; + case "Ellipse": + return ; + case "Path": + return ; + case "Rect": + return ; + case "Text": + return ; + case "Group": + return ( + + {child.children.map((nested, index) => renderNodeConfigChild(nested, index, listening, name))} + + ); + default: + return assertNever(child); + } +} + +function FilteredKonvaImage({ + nodeConfig, + listening, + name, +}: { + nodeConfig: Extract; + listening: boolean; + name?: string; +}) { + const imageRef = useRef(null); + useLayoutEffect(() => { + const node = imageRef.current; + if (!node) return; + applyNodeFilter(node, nodeConfig.filter); + node.getLayer()?.batchDraw(); + return () => { + node.clearCache(); + }; + }, [nodeConfig.filter]); + + return ; +} + +export function EditorStage({ + images, + zoom = 1, + screenId, + interactive = true, + connectedOverview = false, +}: { + images: ImageCache; + zoom?: number; + screenId?: string; + interactive?: boolean; + connectedOverview?: boolean; +}) { + const layout = useStore(editorStore, selectEditorLayout); + const activeScreenId = useStore(editorStore, (state) => state.activeScreenId); + const selectedItems = useStore(editorStore, (state) => state.selectedItems); + const requestedScreenId = screenId ?? activeScreenId; + + const trRef = useRef(null); + const nodeRefs = useRef(new Map()); + const wrapperRef = useRef(null); + + const { + requestedScreen, + projections, + baseX, + stageWidth, + stageHeight, + canEdit, + selectedElement, + } = deriveStageModel({ + layout, + requestedScreenId, + activeScreenId, + selectedItems, + interactive, + connectedOverview, + }); + + const commit = (ownerScreenId: string, element: Element, patch: CommonElementPatch) => { + mutateAndSave(() => editorStore.getState().patchElement({ + id: element.id, + type: element.type, + patch, + screenId: ownerScreenId, + })); + }; + + const liveElement = (ownerScreenId: string, id: string): Element | undefined => { + const state = editorStore.getState(); + return state.project?.layouts.find((candidate) => candidate.id === state.activeLayoutId)?.screens + .find((candidate) => candidate.id === ownerScreenId) + ?.elements.find((element) => element.id === id); + }; + + const { + verticalGuideRef, + horizontalGuideRef, + onDragMove, + onDragEnd, + onTransformEnd, + } = useElementTransform({ + projections, + selectedItems, + zoom, + stageSize: { width: stageWidth, height: stageHeight }, + nodeRefs, + transformerRef: trRef, + wrapperRef, + getLiveElement: liveElement, + patchElement: commit, + moveSelected: (delta) => mutateAndSave(() => editorStore.getState().moveSelected(delta)), + }); + + const { + marquee, + movedRef: marqueeMovedRef, + selectionModifier, + hasMoved: marqueeHasMoved, + bounds: currentMarqueeBounds, + onMouseDown: beginMarquee, + onMouseMove: updateMarquee, + onMouseUp: finishMarquee, + } = useMarqueeSelection({ + canEdit, + zoom, + projections, + baseX, + selectedItems, + setSelection: (items) => editorStore.getState().setSelection(items), + }); + + const { + editing, + editingProjection, + editingElement, + style: editingStyle, + beginTextEditing, + finishEditing, + setValue: setEditingValue, + } = useInlineTextEditing({ + projections, + baseX, + zoom, + canEdit, + getLiveElement: liveElement, + selectElement: (screen, element) => editorStore.getState().select(element, screen), + commitText: (screen, element, value) => mutateAndSave(() => editorStore.getState().patchElement({ + id: element, + type: "text", + patch: { content: value }, + screenId: screen, + })), + }); + + useTransformerSelection({ + transformerRef: trRef, + nodeRefs, + canEdit, + editingText: Boolean(editingElement), + layout, + selectedItems, + }); + + if (!layout || !requestedScreen || projections.length === 0) return null; + + const selectElement = (event: Konva.KonvaEventObject, screenId: string, elementId: string) => { + const candidate = { screenId, elementId }; + if (!selectionModifier(event) && selectedItems.length > 1 && isSelected(selectedItems, candidate)) { + return; + } + editorStore.getState().select( + elementId, + screenId, + selectionModifier(event) ? "toggle" : "replace", + ); + }; + + const finishElementClick = ( + event: Konva.KonvaEventObject, + screenId: string, + elementId: string, + ) => { + if (selectionModifier(event)) return; + const state = editorStore.getState(); + const candidate = { screenId, elementId }; + if (state.selectedItems.length > 1 && isSelected(state.selectedItems, candidate)) { + state.select(elementId, screenId, "replace"); + } + }; + + return ( +
projection.screen.id).join(" ")} + data-selection-count={selectedItems.length} + style={{ position: "relative", width: stageWidth * zoom, height: stageHeight * zoom }} + > + + + {projections.map(({ screen, x, y }) => ( + { + if (marqueeMovedRef.current || selectionModifier(event)) return; + editorStore.getState().selectScreen(screen.id); + }} + onTap={() => editorStore.getState().selectScreen(screen.id)} + > + {renderNodeConfigChild( + backgroundToNodeConfig(screen.background, { + x: x + baseX, + y, + width: screen.artboard.width, + height: screen.artboard.height, + }, images), + screen.id, + canEdit, + "artboard-background", + )} + + ))} + + {projections.map(({ screen, x, y }) => ( + + {screen.elements.map((element) => { + if (!element.visible) return null; + const nc = elementToNodeConfig(element, { images, direction: screen.direction }); + const key = nodeSelectionKey(screen.id, element.id); + const interaction = canEdit ? { + draggable: !element.locked, + ref: (node: Konva.Node | null) => { + if (node) nodeRefs.current.set(key, node); + else nodeRefs.current.delete(key); + }, + onMouseDown: (event: Konva.KonvaEventObject) => { + selectElement(event, screen.id, element.id); + }, + onClick: (event: Konva.KonvaEventObject) => { + finishElementClick(event, screen.id, element.id); + }, + onTap: () => editorStore.getState().select(element.id, screen.id), + onDragMove: onDragMove(screen.id, element.id), + onDragEnd: onDragEnd(screen.id, element.id), + onTransformEnd: onTransformEnd(screen.id, element.id), + } : { listening: false }; + switch (nc.konva) { + case "Text": + if (element.type !== "text") return null; + return ( + { + finishElementClick(event, screen.id, element.id); + if (event.evt.detail >= 2) { + beginTextEditing(screen.id, element, nc.config.text ?? ""); + } + }} + onDblClick={() => beginTextEditing(screen.id, element, nc.config.text ?? "")} + /> + ); + case "Image": + return ; + case "Ellipse": + return ; + case "Path": + return ; + case "Group": + return ( + + {nc.children.map((child, index) => renderNodeConfigChild(child, index, canEdit))} + + ); + case "Rect": + return ; + default: + return assertNever(nc); + } + })} + + ))} + + {layout.mode === "connected" && connectedOverview + ? projections.map(({ screen, x, y }) => ( + + )) + : null} + + {canEdit ? ( + <> + + + + ) : null} + + {canEdit ? ( + + ) : null} + + {canEdit && marquee && marqueeHasMoved(marquee) && currentMarqueeBounds ? (() => { + const bounds = currentMarqueeBounds; + return ( + + ); + })() : null} + + + + {canEdit && editing && editingElement?.type === "text" && editingProjection ? ( +