diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fbb78e..337efd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,20 @@ jobs: cache: npm - run: npm ci - run: npm run check + - run: npm run format:check + - run: npm run worker:check + env: + WRANGLER_LOG_PATH: /tmp/wallpect-wrangler-ci.log + + browser: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium webkit firefox + - run: npm run test:e2e -- --project=chromium --project=webkit-mobile --project=firefox diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c4ad2..55da474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to Wallpect are recorded in this file. The format follows [K ## [Unreleased] +## [0.2.3] - 2026-08-02 + +### Added + +- Added a bilingual architecture document covering the browser-only boundary, data lifecycle, renderer invariants, offline model, and verification gates. +- Added production-mode browser coverage for exact PNG/JPEG/WebP dimensions, zero post-upload HTTP(S) egress, Object URL cleanup, offline navigation, keyboard editing, dark OS preference stability, and every mobile editor panel. +- Added raw build-size budgets and Chromium, WebKit, and Firefox jobs to GitHub CI; Microsoft Edge remains part of the complete local browser matrix. + +### Changed + +- Migrated recent-device preferences from `wallpect:recent-devices` to the sanitized, versioned `wallpect:recent-devices:v1` key. +- Replaced simulated listbox options with native device-selection buttons and labelled every information dialog through its visible heading and introduction. +- Fixed the accepted light-rail/dark-stage interface to a light browser color scheme so operating-system dark preference does not recolor native controls. +- Updated service-worker installation and activation lifecycles, bypassed HTTP cache during update checks, and limited offline shell fallback to navigation requests. +- Expanded the release process with explicit public-data review, fixed-SHA merge, deployed-asset byte comparison, and evidence requirements before tagging. + +### Security + +- Updated `minimatch` and `brace-expansion` in the development dependency lock to a patched release for CVE-2026-14257. +- Confirmed zero known vulnerabilities in the complete and production npm dependency graphs at release preparation time. + ## [0.2.2] - 2026-07-25 ### Added @@ -73,7 +94,8 @@ All notable changes to Wallpect are recorded in this file. The format follows [K - Uploaded images remain in browser memory and are not sent to an upload API or analytics service. - Local secrets, credentials, build output, browser reports, and editor/OS metadata are excluded from version control. -[Unreleased]: https://github.com/kyeunga25/wallpect/compare/v0.2.2...HEAD +[Unreleased]: https://github.com/kyeunga25/wallpect/compare/v0.2.3...HEAD +[0.2.3]: https://github.com/kyeunga25/wallpect/compare/v0.2.2...v0.2.3 [0.2.2]: https://github.com/kyeunga25/wallpect/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/kyeunga25/wallpect/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/kyeunga25/wallpect/releases/tag/v0.2.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 041c584..8c34959 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,7 @@ - 執行 `npm ci` 安裝鎖定版本的依賴。 - 不要加入圖片上傳 API、圖片分析、帳戶追蹤或其他把用戶圖片傳離瀏覽器的功能。 - 預覽與匯出必須共用相同的變換及渲染計算。 +- 持久偏好必須使用版本化 key 及明確遷移;目前只允許 `wallpect:locale:v1` 與 `wallpect:recent-devices:v1`。 - 裝置資料必須位於 `src/data/devices`,不要寫入 UI 元件。 - 推算或估算的設定檔資料不得標示為 `verified`。 @@ -20,7 +21,7 @@ 1. 建立範圍清晰的分支及變更。 2. 為行為修正或新邏輯加入相應測試。 3. 修改裝置設定檔時,提供來源、檢查日期、證據類型及準確度等級。 -4. 不要提交私人桌布、Apple 專有素材、憑證、環境檔或建置產物。 +4. 不要提交私人桌布、Apple 專有素材、憑證、環境檔或建置產物。提交前逐一審閱 status、diff、staged diff 及 untracked files,只明確暫存本次檔案。 5. 執行以下檢查: ```bash @@ -44,6 +45,7 @@ npm run test:e2e - UI 變更附上不含私人圖片的前後畫面。 - 裝置資料變更連結到可靠來源,並依照[準確度政策](docs/ACCURACY_POLICY.md)標示資料。 - 確認上傳圖片仍只留在瀏覽器中。 +- 涉及圖片、storage、快取或 service worker 時,加入零 HTTP(S) egress、Object URL 釋放、遷移或 production-mode 離線測試。 一般錯誤或功能建議可使用 GitHub Issues。涉及未公開漏洞或可能洩露資料的問題,請勿在公開 issue 中附上敏感細節。 @@ -57,6 +59,7 @@ Thank you for helping improve Wallpect. Keep each issue or pull request focused - Run `npm ci` to install the locked dependency versions. - Do not add image upload APIs, image analytics, account tracking, or any feature that sends a user's image out of the browser. - Preview and export must share the same transform and rendering calculations. +- Persistent preferences require a versioned key and explicit migration. The only current keys are `wallpect:locale:v1` and `wallpect:recent-devices:v1`. - Keep device data in `src/data/devices`, outside UI components. - Never label derived or estimated profile data as `verified`. @@ -65,7 +68,7 @@ Thank you for helping improve Wallpect. Keep each issue or pull request focused 1. Create a focused branch and change set. 2. Add tests for behavior fixes or new logic. 3. For device profiles, include sources, review dates, evidence types, and accuracy levels. -4. Do not commit private wallpapers, proprietary Apple assets, credentials, environment files, or generated build output. +4. Do not commit private wallpapers, proprietary Apple assets, credentials, environment files, or generated build output. Review status, diff, staged diff, and untracked files individually before committing; stage only explicit files in scope. 5. Run the core checks: ```bash @@ -89,5 +92,6 @@ For deployment or release changes, also follow the [release guide](docs/RELEASIN - Include before/after images for UI changes, without private imagery. - Link device-data changes to reliable sources and follow the [accuracy policy](docs/ACCURACY_POLICY.md). - Confirm that uploaded image content still stays in the browser. +- For image, storage, cache, or service-worker changes, add the relevant zero HTTP(S) egress, Object URL release, migration, or production-mode offline test. Use GitHub Issues for ordinary bugs and feature requests. Do not place sensitive details in a public issue when reporting an undisclosed vulnerability or possible data exposure. diff --git a/README.md b/README.md index 4e5be62..f9ce895 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Wallpect helps you compose an image for a selected Apple device, inspect likely 4. Adjust the composition and enable the guides you want to inspect. 5. Choose PNG, JPEG, or WebP and export at the displayed resolution. -Your image stays in browser memory and is released when it is replaced, the page is refreshed, or the page is closed. Only the selected locale and up to four recent device identifiers are saved in versioned `localStorage`. Normal requests for public site files still pass through Cloudflare, but they do not contain your selected image bytes. +Your image stays in browser memory and is released when it is replaced, the page is refreshed, or the page is closed. Only the selected locale (`wallpect:locale:v1`) and up to four recent device identifiers (`wallpect:recent-devices:v1`) are saved in versioned `localStorage`. Normal requests for public site files still pass through Cloudflare, but they do not contain your selected image bytes. The website provides directly accessible Traditional Chinese and English privacy, terms, disclaimer, and data-source notices. Maintenance and third-party source/API rules are documented in the [Terms, Privacy, and Data Policy](docs/LEGAL_PRIVACY_AND_DATA_POLICY.md). @@ -73,7 +73,7 @@ npm ci npm run dev ``` -Run the core quality checks: +Run the core quality checks. `npm run check` includes lint, unit/integration tests, a strict production build, and the raw build-size budget: ```bash npm run check @@ -81,7 +81,7 @@ npm run format:check npm run worker:check ``` -The Playwright suite additionally covers Chromium, WebKit, Microsoft Edge, and Firefox: +The Playwright suite builds the app and starts a production-mode local preview on port `4174`. It covers Chromium, WebKit, Microsoft Edge, and Firefox: ```bash npm run test:e2e @@ -108,9 +108,12 @@ See the [deployment guide](docs/DEPLOYMENT.md) for platform settings and the [re Preview and export both call `renderWallpaper`. Pan offsets use the target canvas's normalized coordinate space, so a scaled preview and full-resolution export preserve the same composition. +See the [architecture document](docs/ARCHITECTURE.md) for the runtime boundary, local-data lifecycle, storage migration, offline behavior, and verification invariants. + ## Documentation - [Accuracy policy](docs/ACCURACY_POLICY.md) +- [Architecture](docs/ARCHITECTURE.md) - [Device profile guide](docs/DEVICE_PROFILE_GUIDE.md) - [Deployment guide](docs/DEPLOYMENT.md) - [Release guide](docs/RELEASING.md) diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 63c3990..e40ef83 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -43,7 +43,7 @@ Wallpect 讓你按所選 Apple 裝置調整圖片構圖、檢查可能遮擋內 4. 調整構圖,並開啟需要檢查的輔助線。 5. 選擇 PNG、JPEG 或 WebP,再按顯示的解析度匯出。 -圖片只會保存在瀏覽器記憶體,當圖片被取代、頁面重新整理或關閉時便會釋放。只有介面語言及最多四個最近使用的裝置識別碼會儲存在版本化 `localStorage`;公開網站檔案的正常請求仍會經過 Cloudflare,但不包含你選擇的圖片 bytes。 +圖片只會保存在瀏覽器記憶體,當圖片被取代、頁面重新整理或關閉時便會釋放。只有介面語言(`wallpect:locale:v1`)及最多四個最近使用的裝置識別碼(`wallpect:recent-devices:v1`)會儲存在版本化 `localStorage`;公開網站檔案的正常請求仍會經過 Cloudflare,但不包含你選擇的圖片 bytes。 網站內可直接查閱繁中/英文的私隱、使用條款、免責及資料來源說明。維護與新增第三方來源/API 的規則記錄於 [條款、私隱與資料政策](docs/LEGAL_PRIVACY_AND_DATA_POLICY.md)。 @@ -73,7 +73,7 @@ npm ci npm run dev ``` -執行主要品質檢查: +執行主要品質檢查;`npm run check` 包括 lint、單元/整合測試、strict production build 及 raw build size budget: ```bash npm run check @@ -81,7 +81,7 @@ npm run format:check npm run worker:check ``` -Playwright 測試另外涵蓋 Chromium、WebKit、Microsoft Edge 及 Firefox: +Playwright 會先 build,再在 `4174` 啟動 production-mode 本機 preview,並涵蓋 Chromium、WebKit、Microsoft Edge 及 Firefox: ```bash npm run test:e2e @@ -108,9 +108,12 @@ Wallpect 以不含 Worker script 的 Cloudflare Workers Static Assets 模式部 預覽與匯出均會呼叫 `renderWallpaper`。平移值使用目標 Canvas 的標準化座標,因此縮小預覽與完整解析度匯出可保持相同構圖。 +完整 runtime 邊界、本機資料生命週期、偏好遷移、離線行為及驗證不變條件見[架構文件](docs/ARCHITECTURE.md)。 + ## 技術文件 - [準確度政策](docs/ACCURACY_POLICY.md) +- [架構文件](docs/ARCHITECTURE.md) - [裝置設定檔指南](docs/DEVICE_PROFILE_GUIDE.md) - [部署指南](docs/DEPLOYMENT.md) - [發布指南](docs/RELEASING.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..3d7334d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,111 @@ +# Wallpect 架構 / Architecture + +本文件描述 Wallpect 0.2.3 已實作的系統邊界、資料生命週期及必須維持的工程不變條件。 + +## 繁體中文 + +### 執行邊界 + +Wallpect 是由 Cloudflare Workers Static Assets 提供的單頁應用程式。`wrangler.toml` 沒有 Worker `main` script、API、資料庫或物件儲存 binding。Cloudflare 只傳送公開的 HTML、JavaScript、CSS、service worker 與示範圖片;使用者選擇的圖片只在瀏覽器內解碼、構圖及匯出。 + +```mermaid +flowchart LR + image["使用者選擇的本機圖片"] --> decoder["decodeImageFile"] + decoder --> memory["EditorProvider 瀏覽器記憶體"] + profiles["src/data/devices 設定檔"] --> memory + memory --> renderer["renderWallpaper"] + renderer --> preview["縮放預覽 Canvas"] + renderer --> export["完整解析度匯出 Canvas"] + locale["語言及最近裝置 ID"] --> storage["版本化 localStorage"] + public["公開 build 資源"] <--> cloudflare["Cloudflare Workers Static Assets"] +``` + +圖中刻意沒有由圖片、Canvas 或 editor state 指向 Cloudflare 的資料路徑。新增這類路徑屬架構與私隱邊界變更,必須先更新產品通知、法律/私隱審閱、測試及本文件。 + +### 模組職責 + +| 路徑 | 職責 | +| ------------------ | ---------------------------------------------------------- | +| `src/components` | UI、輸入控制、裝置預覽及對話框 | +| `src/state` | Editor session state、各裝置/方向的構圖及 UI announcement | +| `src/core` | 圖片驗證/解碼、偏好遷移、幾何、渲染及匯出 | +| `src/data/devices` | UI 以外、經 schema 驗證的裝置設定檔 | +| `src/i18n` | 繁體中文預設、簡體中文/英文翻譯及語言偏好 | +| `public` | 靜態 headers、service worker、示範圖片及公開資源 | +| `tests` | 單元、整合及 production-mode browser contract | +| `scripts` | 不會進入 runtime bundle 的 deterministic quality gate | + +### 必須維持的不變條件 + +1. 預覽與匯出均呼叫 `renderWallpaper`;構圖平移採用目標 Canvas 的標準化座標,因此相同比例的預覽與完整輸出保持一致。 +2. 匯出 Canvas 只在操作期間存在,完成後會縮回 `1 × 1`;下載與上傳建立的 Object URL 必須撤銷。 +3. 圖片 bytes、檔名、Canvas 像素及 transform 不得寫入 `localStorage`、Cache Storage、網絡請求或分析服務。 +4. 可持久保存的資料只有 `wallpect:locale:v1` 及最多四個有效裝置 ID 的 `wallpect:recent-devices:v1`。舊 `wallpect:recent-devices` 會在讀取時清理、遷移並移除。 +5. 裝置資料留在 `src/data/devices`;推算或估算的幾何不得標示為 `verified`。 +6. 缺少的離線導覽可回退到已快取的 `/`,但缺少的 script、style 或其他非導覽資源必須失敗,不得回傳 HTML shell。 + +### 資料生命週期 + +| 資料 | 建立 | 保留 | 清除/更新 | +| -------------- | ------------------------------------ | ------------------------------------------------ | -------------------------------------------- | +| 使用者圖片 | `decodeImageFile` 由本機 `File` 解碼 | React state 及瀏覽器圖片物件 | 更換、移除、重新整理或關閉頁面時釋放 | +| 構圖與匯出設定 | 使用者操作 | 目前頁面的 React state | 重新整理或關閉頁面 | +| 語言 | 使用者切換 | `wallpect:locale:v1` | 改寫或清除網站資料 | +| 最近裝置 | 使用者選擇裝置 | `wallpect:recent-devices:v1`,最多四個有效 ID | 改寫或清除網站資料 | +| 公開 app shell | 首次 production 載入 | `wallpect-v3` Cache Storage 及一般 browser cache | 新 cache 版本啟用時移除舊 `wallpect-*` cache | + +### 驗證門檻 + +- `npm run check`:strict TypeScript build、lint、單元/整合測試及 raw build budget。 +- `npm run format:check`:格式驗證。 +- `npm run worker:check`:Static Assets dry-run,不部署。 +- `npm run test:e2e`:以 production build/preview 驗證 Chromium、WebKit、Edge 及 Firefox 對應流程。 +- E2E 必須覆蓋 PNG/JPEG/WebP 精確尺寸、上傳後零 HTTP(S) egress、Object URL 釋放、離線導覽、鍵盤操作、流動版無水平溢出及法律/私隱入口。 + +## English + +### Runtime boundary + +Wallpect is a single-page application served by Cloudflare Workers Static Assets. `wrangler.toml` has no Worker `main` script, API, database, or object-storage binding. Cloudflare serves only public HTML, JavaScript, CSS, the service worker, and the bundled sample. A user-selected image is decoded, composed, and exported only in the browser. + +The diagram above intentionally has no data path from the selected image, Canvas, or editor state to Cloudflare. Adding one is an architecture and privacy-boundary change that requires updated product notice, legal/privacy review, tests, and this document before merge. + +### Module ownership + +| Path | Responsibility | +| ------------------ | ------------------------------------------------------------------------------------------- | +| `src/components` | UI, input controls, device previews, and dialogs | +| `src/state` | Editor session state, per-device/orientation composition, and announcements | +| `src/core` | Image validation/decoding, preference migration, geometry, rendering, and export | +| `src/data/devices` | Schema-validated device profiles outside UI components | +| `src/i18n` | Traditional Chinese default, Simplified Chinese/English translations, and locale preference | +| `public` | Static headers, service worker, sample image, and public assets | +| `tests` | Unit, integration, and production-mode browser contracts | +| `scripts` | Deterministic quality gates excluded from the runtime bundle | + +### Required invariants + +1. Preview and export both call `renderWallpaper`. Pan values use normalized target-Canvas coordinates, preserving a composition between an aspect-matched preview and full output. +2. The export Canvas exists only for the operation and is reduced to `1 × 1` afterward. Object URLs created for uploads and downloads must be revoked. +3. Image bytes, filenames, Canvas pixels, and transforms must not enter `localStorage`, Cache Storage, network requests, or analytics. +4. Persisted data is limited to `wallpect:locale:v1` and up to four valid device IDs in `wallpect:recent-devices:v1`. The legacy `wallpect:recent-devices` value is sanitized, migrated, and removed when read. +5. Device data stays in `src/data/devices`; derived or estimated geometry must not be labelled `verified`. +6. An unavailable offline navigation may fall back to cached `/`. A missing script, style, or other non-navigation asset must fail and must not receive the HTML shell. + +### Data lifecycle + +| Data | Created | Retained | Cleared or updated | +| --------------------------- | ------------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------- | +| Selected image | Decoded from a local `File` by `decodeImageFile` | React state and a browser image object | Replace, remove, refresh, or close | +| Composition/export settings | User interaction | Current-page React state | Refresh or close | +| Locale | User selection | `wallpect:locale:v1` | Overwrite or clear site data | +| Recent devices | Device selection | `wallpect:recent-devices:v1`, up to four valid IDs | Overwrite or clear site data | +| Public app shell | First production load | `wallpect-v3` Cache Storage and normal browser cache | Old `wallpect-*` caches removed when a new cache activates | + +### Verification gates + +- `npm run check`: strict TypeScript build, lint, unit/integration tests, and the raw build budget. +- `npm run format:check`: formatting validation. +- `npm run worker:check`: Static Assets dry-run without deployment. +- `npm run test:e2e`: production build/preview coverage across the mapped Chromium, WebKit, Edge, and Firefox flows. +- E2E must cover exact PNG/JPEG/WebP dimensions, zero HTTP(S) egress after upload, Object URL release, offline navigation, keyboard editing, mobile overflow, and legal/privacy entry points. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 272884d..6b5482d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -14,7 +14,7 @@ Node.js: 22 或以上版本 Wallpect 使用 Workers Static Assets 的純靜態模式。`wrangler.toml` 不包含 `main` 或 `ASSETS` binding,因此請求不會執行 Worker script;圖片仍然只會在瀏覽器內解碼、預覽及匯出。`assets.not_found_handling` 明確設定為 `single-page-application`,讓非檔案導覽路徑回傳 `index.html`。 -Repository 亦包含鎖定版本的 Wrangler、`public/_headers` 與正式版本的 service worker。Vite 會為 JS/CSS 資源加入 hash;`_headers` 會長期快取這些不可變資源,同時讓 `index.html` 保持重新驗證,並避免長期快取 `sw.js`。 +Repository 亦包含鎖定版本的 Wrangler、`public/_headers` 與正式版本的 service worker。Vite 會為 JS/CSS 資源加入 hash;`_headers` 會長期快取這些不可變資源,同時讓 `index.html` 保持重新驗證,並避免長期快取 `sw.js`。註冊 service worker 時使用 `updateViaCache: "none"`;網絡失敗時只讓導覽請求回退到快取的 `/`,缺少的 JS、CSS 或其他非導覽檔案不會收到 HTML shell。 本機驗證 Workers 靜態資源設定: @@ -30,6 +30,8 @@ npm run build npm run worker:dev ``` +`npm run test:e2e` 會自行建立 production build,並在 `127.0.0.1:4174` 啟動 Vite production preview,因此會實際註冊及檢查 service worker。不要以 Vite dev server 的結果代替離線/更新驗證。 + ### 網域 建議對應: @@ -81,13 +83,14 @@ PLAYWRIGHT_BASE_URL=https:// npm run test:e2e 1. 首次開啟預設顯示繁體中文,並可切換簡體中文與英文; 2. 內建示範圖片可正常渲染; -3. 上傳圖片時沒有包含圖片 bytes 的網絡請求; +3. 上傳圖片後沒有 HTTP(S) 請求,下載使用的 `blob:` URL 及上傳 Object URL 均會釋放; 4. iPhone、iPad 與 Mac 設定檔可正確切換; 5. PNG/JPEG/WebP 下載具有設定檔指定的精確尺寸; 6. 桌面與流動版版面沒有水平溢出; 7. 非檔案導覽路徑會回傳 SPA shell; 8. 安全與快取 header 已正確提供; 9. hashed JS/CSS 使用 immutable cache,而 `sw.js` 仍會重新驗證。 +10. 首次連線載入後,離線導覽可開啟 app shell;缺少的非導覽資源會失敗而不是回傳 `index.html`。 ## English @@ -103,7 +106,7 @@ Node.js: 22 or newer Wallpect uses the assets-only Workers Static Assets mode. `wrangler.toml` has no `main` entry or `ASSETS` binding, so requests do not invoke a Worker script; images remain decoded, previewed, and exported only in the browser. `assets.not_found_handling` is explicitly set to `single-page-application`, so navigation paths that do not match files receive `index.html`. -The repository also includes a pinned Wrangler version, `public/_headers`, and a production service worker. Vite hashes JS/CSS assets; `_headers` caches those immutable assets for the long term while leaving `index.html` revalidated and preventing long-lived caching of `sw.js`. +The repository also includes a pinned Wrangler version, `public/_headers`, and a production service worker. Vite hashes JS/CSS assets; `_headers` caches those immutable assets for the long term while leaving `index.html` revalidated and preventing long-lived caching of `sw.js`. Service-worker registration uses `updateViaCache: "none"`. On network failure, only navigation requests can fall back to cached `/`; a missing JS, CSS, or other non-navigation file does not receive the HTML shell. Validate the Workers static assets configuration locally: @@ -119,6 +122,8 @@ npm run build npm run worker:dev ``` +`npm run test:e2e` creates a production build and starts a Vite production preview at `127.0.0.1:4174`, so it exercises the real service-worker registration and offline behavior. Do not substitute Vite dev-server results for offline/update verification. + ### Domains Suggested mappings: @@ -170,10 +175,11 @@ Then verify on the deployed URL: 1. the first visit defaults to Traditional Chinese and can switch to Simplified Chinese or English; 2. the bundled demo renders; -3. an uploaded image produces no network request containing image bytes; +3. no HTTP(S) request occurs after an image upload, and upload/download Object URLs are released; 4. iPhone, iPad, and Mac profiles switch correctly; 5. PNG/JPEG/WebP downloads have exact profile dimensions; 6. desktop and mobile layouts have no horizontal overflow; 7. non-file navigation paths return the SPA shell; 8. security and cache headers are present; 9. hashed JS/CSS uses immutable caching while `sw.js` remains revalidated. +10. after an initial online load, offline navigation opens the app shell while a missing non-navigation asset fails instead of receiving `index.html`. diff --git a/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md b/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md index d2550a7..b0ae15e 100644 --- a/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md +++ b/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md @@ -1,7 +1,7 @@ # Wallpect 條款、私隱與資料政策 -生效日期:2026 年 7 月 25 日 -適用版本:0.2.2 +生效日期:2026 年 8 月 2 日 +適用版本:0.2.3 > 本文件記錄 Wallpect 測試版目前的產品行為及維護規則,只屬一般資料,並非法律意見。若加入收費、帳戶、廣告、分析、個人資料表格、使用者內容託管或新司法管轄區,必須先接受獨立法律審閱。 @@ -16,16 +16,18 @@ ## 2. 現行私隱資料清單 -| 資料/處理 | 位置 | 用途 | 保留 | 傳送對象 | -| ----------------------------------------------- | ------------------------------------ | ---------------------------- | ---------------------------------- | ------------------------------------------------------ | -| 使用者選擇的圖片 bytes、檔名、Canvas 像素及變換 | 瀏覽器記憶體 | 預覽與匯出 | 更換圖片、重新整理或關閉頁面時清除 | 不上傳;沒有圖片 API 或第三方圖片分析 | -| 語言偏好 | `localStorage`:`wallpect:locale:v1` | 還原介面語言 | 直至使用者改寫或清除網站資料 | 不由應用程式傳送 | -| 最多四個最近裝置 ID | `localStorage`:版本化 key | 提供最近使用裝置捷徑 | 直至改寫或清除網站資料 | 不由應用程式傳送 | -| 公開應用程式資源 | Browser Cache/Service Worker Cache | 提升載入可靠性 | 由版本化 cache 及瀏覽器政策管理 | 不包含使用者選擇的圖片 | -| 一般網絡請求 metadata | Cloudflare 網絡 | 傳送、保護及快取公開網站檔案 | 受 Cloudflare 服務及私隱政策規管 | Cloudflare;可能包括 IP、路由、系統設定及請求 metadata | +| 資料/處理 | 位置 | 用途 | 保留 | 傳送對象 | +| ----------------------------------------------- | -------------------------------------------- | ---------------------------- | ---------------------------------- | ------------------------------------------------------ | +| 使用者選擇的圖片 bytes、檔名、Canvas 像素及變換 | 瀏覽器記憶體 | 預覽與匯出 | 更換圖片、重新整理或關閉頁面時清除 | 不上傳;沒有圖片 API 或第三方圖片分析 | +| 語言偏好 | `localStorage`:`wallpect:locale:v1` | 還原介面語言 | 直至使用者改寫或清除網站資料 | 不由應用程式傳送 | +| 最多四個最近裝置 ID | `localStorage`:`wallpect:recent-devices:v1` | 提供最近使用裝置捷徑 | 直至改寫或清除網站資料 | 不由應用程式傳送 | +| 公開應用程式資源 | Browser Cache/Service Worker Cache | 提升載入可靠性 | 由版本化 cache 及瀏覽器政策管理 | 不包含使用者選擇的圖片 | +| 一般網絡請求 metadata | Cloudflare 網絡 | 傳送、保護及快取公開網站檔案 | 受 Cloudflare 服務及私隱政策規管 | Cloudflare;可能包括 IP、路由、系統設定及請求 metadata | Wallpect 前端不設定廣告 Cookie、不載入客戶端分析、不建立帳戶、不收集表格資料,也不把使用者選擇的圖片傳送到 Cloudflare。正常瀏覽公開網站仍會產生網絡請求;不應把「圖片不上傳」誤寫成「託管供應商完全不處理任何網絡資料」。 +0.2.3 會在首次讀取時把舊 `wallpect:recent-devices` 值限制為最多四個有效且不重複的裝置 ID,寫入 `wallpect:recent-devices:v1`,然後移除舊 key。遷移不會加入圖片、檔名、構圖或其他資料;若 storage 不可用,編輯器仍可運作而不保存偏好。 + 參考:[香港私隱公署《擬備收集個人資料聲明及私隱政策聲明指引》](https://www.pcpd.org.hk/tc_chi/resources_centre/publications/files/GN_picspps_c.pdf)、[Cloudflare Privacy Policy](https://www.cloudflare.com/policies/privacy/)。 ## 3. 裝置資料來源與準確度 @@ -83,8 +85,8 @@ Apple 的香港網站條款限制複製、重新發佈、鏡像及自動擷取 # Wallpect Terms, Privacy, and Data Policy -Effective date: 25 July 2026 -Applies to version: 0.2.2 +Effective date: 2 August 2026 +Applies to version: 0.2.3 > This document records the current behavior and maintenance rules of the Wallpect beta. It is general information, not legal advice. Independent legal review is required before adding payments, accounts, advertising, analytics, personal-data forms, hosted user content, or new jurisdictions. @@ -99,7 +101,9 @@ The user-facing version is available in the website's “Legal & data” dialog. ## 2. Current privacy inventory -Wallpect keeps selected image bytes, filenames, Canvas pixels, and transforms in browser memory and does not upload them. It stores only the selected locale and up to four recent device identifiers in versioned `localStorage`. Public app assets may be cached for reliability, but these caches do not contain user-selected images. +Wallpect keeps selected image bytes, filenames, Canvas pixels, and transforms in browser memory and does not upload them. It stores only the selected locale in `wallpect:locale:v1` and up to four recent device identifiers in `wallpect:recent-devices:v1`. Public app assets may be cached for reliability, but these caches do not contain user-selected images. + +On first read, version 0.2.3 sanitizes a legacy `wallpect:recent-devices` value to at most four valid, unique device IDs, writes the versioned key, and removes the legacy key. The migration adds no image, filename, composition, or other data. If storage is unavailable, the editor continues without persisting the preference. The site is hosted on Cloudflare. Requests for public files pass through Cloudflare, which may process IP addresses, routing data, system configuration, and request metadata under its privacy policy. The app itself does not load advertising cookies, client-side analytics, account systems, or personal-data forms. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index a18de9e..2f0a0e4 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -16,7 +16,21 @@ Wallpect 使用 Semantic Versioning,版本資料以 `package.json`、`package- 5. 更新受影響的 README、部署、準確度、裝置資料或產品文件。 6. 審核所有已追蹤及新增檔案,排除私人資料、憑證、供應商識別碼、內部規劃及非公開商業資料;公開設定只保留部署所必需的產品名稱與網址。 -### 2. 本機驗證 +### 2. 公開資料外流門檻 + +在每次 commit、push、pull request、merge、deploy、tag 或 GitHub Release 前,重新核對實際範圍: + +```bash +git status --short --branch +git diff --name-status +git diff --check +git diff --cached --name-status +git ls-files --others --exclude-standard +``` + +逐一檢查 diff、暫存內容及新增檔案,不要用批量加入取代審閱。確認沒有私人圖片、個人資料、對話內容、憑證、API key、Cloudflare 帳戶/資源識別碼、非公開規劃或商業資料。只明確暫存本次已審閱的檔案;範圍不清楚時停止發布。 + +### 3. 本機驗證 ```bash npm ci @@ -24,33 +38,38 @@ npm run check npm run format:check npm run worker:check npm run test:e2e +npm audit +npm audit --omit=dev ``` -`npm run check` 會依次執行 lint、單元/整合測試及 production build。`worker:check` 會以鎖定版本的 Wrangler 檢查 Static Assets 部署包,但不會部署。 +`npm run check` 會依次執行 lint、單元/整合測試、strict production build 及 raw build budget。沒有設定 `PLAYWRIGHT_BASE_URL` 時,Playwright 會自行 build 並在 `127.0.0.1:4174` 啟動 production-mode preview。`worker:check` 會以鎖定版本的 Wrangler 檢查 Static Assets 部署包,但不會部署。 -### 3. Pull request 預覽 +### 4. Pull request 預覽 1. 推送非 production branch 並建立 pull request。 -2. 確認 GitHub CI 通過。 -3. 確認 Workers Builds 的 non-production branch build 成功,並取得 preview URL。 -4. 對 preview 執行: +2. 記錄 pull request 的 head SHA,並只審閱及批准該 SHA。 +3. 確認 GitHub CI 的 quality 與 browser jobs 均通過。 +4. 確認 Workers Builds 的 non-production branch build 成功,並取得 preview URL。 +5. 對 preview 執行: ```bash PLAYWRIGHT_BASE_URL=https:// npm run test:e2e ``` -5. 手動檢查私隱邊界、精確匯出、桌面/流動版版面、SPA 導覽及安全/快取 headers。 +6. 手動檢查私隱邊界、精確匯出、桌面/流動版版面、SPA 導覽及安全/快取 headers。 +7. 合併前重新讀取 head SHA;如有變更,舊審閱無效,必須重新檢查。使用 `gh pr merge --merge --match-head-commit ` 或等效的固定 SHA 操作。 -### 4. 正式部署與 GitHub Release +### 5. 正式部署與 GitHub Release -1. 審閱並合併 pull request 至 `main`。 -2. 等待 Workers Builds 完成正式 build 及 deploy。 -3. 對 `https://wallpect.k-y.cc` 執行相同的 E2E 及部署檢查。 -4. 確認目前 Workers deployment 對應已合併的 `main` 提交。 -5. 建立 `v` tag 及 GitHub Release,並將該版本標示為 latest。 -6. Release notes 應概述用戶可見變更、部署變更、私隱邊界及已知限制,不應包含憑證、內部原因或私人資料。 +1. 以已審閱的固定 SHA 合併 pull request 至 `main`。 +2. `git fetch origin` 後將本機 `main` 以 `git merge --ff-only origin/main` 對齊,並確認本機 `HEAD`、`origin/main` 與 GitHub merged commit 一致。 +3. 等待該 SHA 的 GitHub CI 及 Workers Builds 正式 build/deploy 完成。 +4. 對 `https://wallpect.k-y.cc` 執行相同的 E2E、headers、SPA route 與 no-cache HTTP 檢查;對 hashed JS/CSS 比較本機 `dist` 與正式回應的 bytes/hash。 +5. 確認目前 Workers deployment 對應已合併的 `main` 提交及正式流量。若 token 或 Dashboard 權限不足以取得 deployment percentage,必須明確記錄證據缺口,不得聲稱已驗證 100% 流量。 +6. 只在 production 驗證通過後,從已驗證的 `main` SHA 建立並推送 `v` tag,再建立 latest GitHub Release。 +7. Release notes 應概述用戶可見變更、部署變更、私隱邊界及已知限制,不應包含憑證、內部原因或私人資料。 -### 5. 回退 +### 6. 回退 若新版本有問題,優先以 Cloudflare Dashboard 的 Worker → Deployments 選擇上一個穩定版本,或使用 `wrangler rollback `。回退會建立一個新的 deployment,並把所選版本設為 100% 正式流量。 @@ -72,7 +91,21 @@ Wallpect follows Semantic Versioning. `package.json`, `package-lock.json`, `CHAN 5. Update affected README, deployment, accuracy, device-data, or product documentation. 6. Review every tracked and new file for personal data, credentials, provider identifiers, internal planning, and non-public business information. Keep only product names and URLs required for public deployment. -### 2. Validate locally +### 2. Public egress gate + +Before every commit, push, pull request, merge, deployment, tag, or GitHub Release, re-check the exact scope: + +```bash +git status --short --branch +git diff --name-status +git diff --check +git diff --cached --name-status +git ls-files --others --exclude-standard +``` + +Review each diff, staged change, and new file; do not substitute bulk staging for review. Confirm that the scope contains no private imagery, personal data, conversation content, credentials, API keys, Cloudflare account/resource identifiers, non-public planning, or business data. Stage only explicit reviewed files and stop the release if scope is unclear. + +### 3. Validate locally ```bash npm ci @@ -80,33 +113,38 @@ npm run check npm run format:check npm run worker:check npm run test:e2e +npm audit +npm audit --omit=dev ``` -`npm run check` runs lint, unit/integration tests, and the production build. `worker:check` uses the pinned Wrangler version to validate the Static Assets package without deploying it. +`npm run check` runs lint, unit/integration tests, the strict production build, and the raw build budget. Without `PLAYWRIGHT_BASE_URL`, Playwright builds the app and starts a production-mode preview at `127.0.0.1:4174`. `worker:check` uses the pinned Wrangler version to validate the Static Assets package without deploying it. -### 3. Validate the pull request preview +### 4. Validate the pull request preview 1. Push a non-production branch and open a pull request. -2. Confirm GitHub CI passes. -3. Confirm the Workers Builds non-production branch build succeeds and obtain its preview URL. -4. Run: +2. Record the pull request head SHA and review/approve only that SHA. +3. Confirm both GitHub CI quality and browser jobs pass. +4. Confirm the Workers Builds non-production branch build succeeds and obtain its preview URL. +5. Run: ```bash PLAYWRIGHT_BASE_URL=https:// npm run test:e2e ``` -5. Manually check the privacy boundary, exact exports, desktop/mobile layouts, SPA navigation, and security/cache headers. +6. Manually check the privacy boundary, exact exports, desktop/mobile layouts, SPA navigation, and security/cache headers. +7. Read the head SHA again before merge. If it changed, the prior review is stale and must be repeated. Use `gh pr merge --merge --match-head-commit ` or an equivalent fixed-SHA operation. -### 4. Deploy production and publish the GitHub Release +### 5. Deploy production and publish the GitHub Release -1. Review and merge the pull request into `main`. -2. Wait for the production Workers Build and deployment. -3. Run the same E2E and deployment checks against `https://wallpect.k-y.cc`. -4. Confirm that the active Workers deployment corresponds to the merged `main` commit. -5. Create the `v` tag and GitHub Release, and mark that release as latest. -6. Release notes should cover user-visible changes, deployment changes, the privacy boundary, and known limitations without exposing credentials, private motives, or personal data. +1. Merge the pull request into `main` at the reviewed fixed SHA. +2. After `git fetch origin`, align local `main` with `git merge --ff-only origin/main`, then confirm local `HEAD`, `origin/main`, and the GitHub merged commit agree. +3. Wait for GitHub CI and the production Workers Build/deployment for that SHA. +4. Run the same E2E, header, SPA-route, and no-cache HTTP checks against `https://wallpect.k-y.cc`; byte/hash-compare deployed hashed JS/CSS with local `dist`. +5. Confirm that the active Workers deployment corresponds to the merged `main` commit and production traffic. If token or Dashboard access cannot prove the deployment percentage, record that evidence gap explicitly and do not claim verified 100% traffic. +6. Only after production passes, create and push the `v` tag from the verified `main` SHA, then create the latest GitHub Release. +7. Release notes should cover user-visible changes, deployment changes, the privacy boundary, and known limitations without exposing credentials, private motives, or personal data. -### 5. Roll back +### 6. Roll back If the release is unhealthy, select the previous stable version under Worker → Deployments in the Cloudflare dashboard, or run `wrangler rollback `. A rollback creates a new deployment and sends 100% of production traffic to the selected version. diff --git a/eslint.config.js b/eslint.config.js index 69d7d56..0d2edc3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,4 +19,14 @@ export default tseslint.config( "@typescript-eslint/no-explicit-any": "off", }, }, + { + files: ["scripts/**/*.mjs"], + languageOptions: { + globals: { + URL: "readonly", + console: "readonly", + process: "readonly", + }, + }, + }, ); diff --git a/package-lock.json b/package-lock.json index 6d37415..67ccca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wallpect", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wallpect", - "version": "0.2.2", + "version": "0.2.3", "license": "MIT", "dependencies": { "lucide-react": "1.24.0", @@ -3016,16 +3016,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -4270,13 +4270,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" diff --git a/package.json b/package.json index a2a0f86..fc2aefe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wallpect", "private": true, - "version": "0.2.2", + "version": "0.2.3", "description": "Privacy-first wallpaper preview and fitting tool for Apple devices.", "license": "MIT", "homepage": "https://wallpect.k-y.cc", @@ -26,7 +26,8 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "check": "npm run lint && npm test && npm run build", + "performance:check": "node scripts/check-build-budget.mjs", + "check": "npm run lint && npm test && npm run build && npm run performance:check", "worker:dev": "wrangler dev", "worker:check": "wrangler deploy --dry-run", "worker:preview": "wrangler versions upload", diff --git a/playwright.config.ts b/playwright.config.ts index 6f35477..49a3a01 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ reporter: "list", outputDir: "/tmp/wallpect-playwright-results", use: { - baseURL: deployedBaseUrl ?? "http://127.0.0.1:4173", + baseURL: deployedBaseUrl ?? "http://127.0.0.1:4174", trace: "on-first-retry", screenshot: "only-on-failure", }, @@ -33,8 +33,8 @@ export default defineConfig({ webServer: deployedBaseUrl ? undefined : { - command: "npm run dev -- --host 127.0.0.1 --port 4173", - url: "http://127.0.0.1:4173", - reuseExistingServer: true, + command: "npm run build && npm run preview -- --host 127.0.0.1 --port 4174", + url: "http://127.0.0.1:4174", + reuseExistingServer: false, }, }); diff --git a/public/sw.js b/public/sw.js index 2435df3..b449635 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,25 +1,31 @@ const CACHE_PREFIX = "wallpect-"; -const CACHE = `${CACHE_PREFIX}v2`; +const CACHE = `${CACHE_PREFIX}v3`; const SHELL = ["/", "/assets/sample-aurora-wallpaper.png"]; self.addEventListener("install", (event) => { - event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); - self.skipWaiting(); + event.waitUntil( + caches + .open(CACHE) + .then((cache) => cache.addAll(SHELL)) + .then(() => self.skipWaiting()), + ); }); self.addEventListener("activate", (event) => { event.waitUntil( - caches - .keys() - .then((keys) => - Promise.all( - keys - .filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE) - .map((key) => caches.delete(key)), + Promise.all([ + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE) + .map((key) => caches.delete(key)), + ), ), - ), + self.clients.claim(), + ]), ); - self.clients.claim(); }); self.addEventListener("fetch", (event) => { @@ -34,6 +40,14 @@ self.addEventListener("fetch", (event) => { } return response; }) - .catch(() => caches.match(event.request).then((cached) => cached || caches.match("/"))), + .catch(async () => { + const cached = await caches.match(event.request); + if (cached) return cached; + if (event.request.mode === "navigate") { + const shell = await caches.match("/"); + if (shell) return shell; + } + return Response.error(); + }), ); }); diff --git a/scripts/check-build-budget.mjs b/scripts/check-build-budget.mjs new file mode 100644 index 0000000..8675b8a --- /dev/null +++ b/scripts/check-build-budget.mjs @@ -0,0 +1,67 @@ +import { readdir, stat } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const distRoot = fileURLToPath(new URL("../dist", import.meta.url)); + +async function collectFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...(await collectFiles(path))); + else if (entry.isFile()) files.push(path); + } + return files; +} + +const files = await collectFiles(distRoot); +const sizes = new Map( + await Promise.all( + files.map(async (path) => { + const file = await stat(path); + return [relative(distRoot, path).replaceAll("\\", "/"), file.size]; + }), + ), +); + +const budgets = [ + { label: "application JavaScript", pattern: /^assets\/index-[\w-]+\.js$/, max: 350 * 1024 }, + { label: "application CSS", pattern: /^assets\/index-[\w-]+\.css$/, max: 40 * 1024 }, + { label: "HTML shell", pattern: /^index\.html$/, max: 8 * 1024 }, + { label: "service worker", pattern: /^sw\.js$/, max: 8 * 1024 }, + { + label: "bundled sample image", + pattern: /^assets\/sample-aurora-wallpaper\.png$/, + max: 3 * 1024 * 1024, + }, +]; + +const failures = []; +for (const budget of budgets) { + const matches = [...sizes].filter(([path]) => budget.pattern.test(path)); + if (matches.length !== 1) { + failures.push(`${budget.label}: expected one matching file, found ${matches.length}`); + continue; + } + const [[path, size]] = matches; + if (size > budget.max) { + failures.push(`${path}: ${size} bytes exceeds the ${budget.max}-byte ${budget.label} budget`); + } +} + +const sourceMaps = [...sizes.keys()].filter((path) => path.endsWith(".map")); +if (sourceMaps.length) failures.push(`production source maps found: ${sourceMaps.join(", ")}`); + +const total = [...sizes.values()].reduce((sum, size) => sum + size, 0); +const totalBudget = 3.5 * 1024 * 1024; +if (total > totalBudget) { + failures.push(`dist total: ${total} bytes exceeds the ${totalBudget}-byte budget`); +} + +if (failures.length) { + console.error(`Build budget failed:\n- ${failures.join("\n- ")}`); + process.exitCode = 1; +} else { + console.log(`Build budget passed for ${sizes.size} files (${total} bytes total).`); +} diff --git a/src/components/device-selector/DeviceSelector.tsx b/src/components/device-selector/DeviceSelector.tsx index 397484e..f3c38dd 100644 --- a/src/components/device-selector/DeviceSelector.tsx +++ b/src/components/device-selector/DeviceSelector.tsx @@ -48,7 +48,7 @@ export function DeviceSelector() { const initialGroup = groupForProfile(profile); const [group, setGroup] = useState(initialGroup); const [query, setQuery] = useState(""); - const listRef = useRef(null); + const listRef = useRef(null); const resultCountId = useId(); const groupCounts = useMemo( () => ({ @@ -78,7 +78,7 @@ export function DeviceSelector() { useLayoutEffect(() => { const list = listRef.current; - const selected = list?.querySelector('[aria-selected="true"]'); + const selected = list?.querySelector('[aria-pressed="true"]'); if (!list || !selected || query) return; const selectedTopWithinList = selected.offsetTop - list.offsetTop; list.scrollTop = Math.max(0, selectedTopWithinList - 4); @@ -157,57 +157,52 @@ export function DeviceSelector() { {t("{count} results", { count: filtered.length })} -
+
    {filtered.map((item) => ( - +
  • + +
  • ))} {!filtered.length ? ( -
    +
  • {t("No matching devices.")}

    {query ? ( ) : null} -
  • + ) : null} -
+ {profile.supportedOrientations.length > 1 ? (
{t("Orientation")} diff --git a/src/components/info/InfoDialog.tsx b/src/components/info/InfoDialog.tsx index 9e38052..b0226a5 100644 --- a/src/components/info/InfoDialog.tsx +++ b/src/components/info/InfoDialog.tsx @@ -1,5 +1,5 @@ import { Database, ExternalLink, Scale, ShieldCheck, Target, X } from "lucide-react"; -import { useEffect, useRef } from "react"; +import { useEffect, useId, useRef } from "react"; import { useI18n } from "../../i18n/i18n"; import { useEditor } from "../../state/editor-context"; import type { InfoView } from "../header/Header"; @@ -111,7 +111,7 @@ function DeviceContent({ count, t }: { count: number; t: Translate }) { function LegalContent({ t }: { t: Translate }) { return (
-

{t("Effective date: 25 July 2026")}

+

{t("Effective date: 2 August 2026")}

{t("Terms of use")}

@@ -223,6 +223,8 @@ function LegalContent({ t }: { t: Translate }) { export function InfoDialog({ view, onClose }: { view: InfoView; onClose: () => void }) { const { t } = useI18n(); const ref = useRef(null); + const titleId = useId(); + const introId = useId(); const { profiles } = useEditor(); useEffect(() => { const dialog = ref.current; @@ -233,13 +235,21 @@ export function InfoDialog({ view, onClose }: { view: InfoView; onClose: () => v const item = copy[view]; const Icon = item.icon; return ( -

+ -

{t(item.title)}

-

{t(item.intro)}

+

{t(item.title)}

+

+ {t(item.intro)} +

{view === "about" ? (
diff --git a/src/core/preferences.ts b/src/core/preferences.ts new file mode 100644 index 0000000..e862bf3 --- /dev/null +++ b/src/core/preferences.ts @@ -0,0 +1,84 @@ +export const RECENT_DEVICES_STORAGE_KEY = "wallpect:recent-devices:v1"; +export const LEGACY_RECENT_DEVICES_STORAGE_KEY = "wallpect:recent-devices"; +export const MAX_RECENT_DEVICES = 4; + +type PreferenceStorage = Pick; + +export function sanitizeRecentDeviceIds( + value: unknown, + validDeviceIds: ReadonlySet, +): string[] { + if (!Array.isArray(value)) return []; + + const result: string[] = []; + for (const item of value) { + if (typeof item === "string" && validDeviceIds.has(item) && !result.includes(item)) { + result.push(item); + } + if (result.length === MAX_RECENT_DEVICES) break; + } + return result; +} + +function parseRecentDeviceIds(raw: string | null, validDeviceIds: ReadonlySet) { + if (raw === null) return []; + try { + return sanitizeRecentDeviceIds(JSON.parse(raw) as unknown, validDeviceIds); + } catch { + return []; + } +} + +export function loadRecentDeviceIds( + storage: PreferenceStorage, + validDeviceIds: ReadonlySet, +): string[] { + let currentRaw: string | null; + try { + currentRaw = storage.getItem(RECENT_DEVICES_STORAGE_KEY); + } catch { + return []; + } + + if (currentRaw !== null) { + const current = parseRecentDeviceIds(currentRaw, validDeviceIds); + try { + storage.setItem(RECENT_DEVICES_STORAGE_KEY, JSON.stringify(current)); + storage.removeItem(LEGACY_RECENT_DEVICES_STORAGE_KEY); + } catch { + // Preferences are optional and must not block the editor. + } + return current; + } + + let legacyRaw: string | null; + try { + legacyRaw = storage.getItem(LEGACY_RECENT_DEVICES_STORAGE_KEY); + } catch { + return []; + } + if (legacyRaw === null) return []; + + const migrated = parseRecentDeviceIds(legacyRaw, validDeviceIds); + try { + storage.setItem(RECENT_DEVICES_STORAGE_KEY, JSON.stringify(migrated)); + storage.removeItem(LEGACY_RECENT_DEVICES_STORAGE_KEY); + } catch { + // Keep the legacy value if migration cannot be completed safely. + } + return migrated; +} + +export function saveRecentDeviceIds( + storage: PreferenceStorage, + value: unknown, + validDeviceIds: ReadonlySet, +): string[] { + const sanitized = sanitizeRecentDeviceIds(value, validDeviceIds); + try { + storage.setItem(RECENT_DEVICES_STORAGE_KEY, JSON.stringify(sanitized)); + } catch { + // Preferences are optional and must not block the editor. + } + return sanitized; +} diff --git a/src/i18n/i18n.tsx b/src/i18n/i18n.tsx index e65afbb..d30fdef 100644 --- a/src/i18n/i18n.tsx +++ b/src/i18n/i18n.tsx @@ -213,7 +213,7 @@ const zhHant: Record = { "Legal, privacy, and data use.": "條款、私隱與資料使用。", "Plain-language terms, limitations, and source rules for using Wallpect.": "以清晰文字說明 Wallpect 的使用條款、限制及資料來源規則。", - "Effective date: 25 July 2026": "生效日期:2026 年 7 月 25 日", + "Effective date: 2 August 2026": "生效日期:2026 年 8 月 2 日", "Terms of use": "使用條款", "By using Wallpect, you agree to use it lawfully and not to disrupt, bypass security controls, overload, or misuse the service. If you do not agree, do not use the service.": "使用 Wallpect 即表示你同意合法使用本服務,不會干擾、規避安全控制、令服務過載或以其他方式濫用服務。如你不同意,請勿使用本服務。", diff --git a/src/main.tsx b/src/main.tsx index 67fb6f4..9cb9387 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -13,5 +13,8 @@ createRoot(document.getElementById("root")!).render( ); if ("serviceWorker" in navigator && import.meta.env.PROD) { - window.addEventListener("load", () => void navigator.serviceWorker.register("/sw.js")); + window.addEventListener( + "load", + () => void navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" }), + ); } diff --git a/src/state/editor-context.tsx b/src/state/editor-context.tsx index 1052851..26a3e77 100644 --- a/src/state/editor-context.tsx +++ b/src/state/editor-context.tsx @@ -12,6 +12,7 @@ import { import { deviceProfiles, getDeviceProfile } from "../data/devices"; import { generateFilename } from "../core/export"; import { decodeBundledImage } from "../core/image-decoder"; +import { loadRecentDeviceIds, saveRecentDeviceIds } from "../core/preferences"; import { orientDevice } from "../core/transforms"; import { useI18n } from "../i18n/i18n"; import type { DeviceProfile, Orientation, OrientedDevice } from "../types/device"; @@ -62,6 +63,7 @@ type EditorContextValue = { }; const EditorContext = createContext(null); +const validDeviceIds = new Set(deviceProfiles.map((profile) => profile.id)); function transformKey(deviceId: string, orientation: Orientation) { return `${deviceId}:${orientation}`; @@ -81,13 +83,9 @@ export function EditorProvider({ children }: { children: ReactNode }) { const [overlays, setOverlays] = useState(DEFAULT_OVERLAYS); const [mobileTab, setMobileTab] = useState("device"); const [announcement, setAnnouncement] = useState(""); - const [recentDeviceIds, setRecentDeviceIds] = useState(() => { - try { - return JSON.parse(localStorage.getItem("wallpect:recent-devices") ?? "[]") as string[]; - } catch { - return []; - } - }); + const [recentDeviceIds, setRecentDeviceIds] = useState(() => + loadRecentDeviceIds(localStorage, validDeviceIds), + ); const orientedDevice = useMemo(() => orientDevice(profile, orientation), [orientation, profile]); const key = transformKey(deviceId, orientation); const transform = transforms[key] ?? DEFAULT_TRANSFORM; @@ -145,12 +143,7 @@ export function EditorProvider({ children }: { children: ReactNode }) { setAnnouncement(t("{model} selected.", { model: next.model })); setRecentDeviceIds((current) => { const updated = [next.id, ...current.filter((item) => item !== next.id)].slice(0, 4); - try { - localStorage.setItem("wallpect:recent-devices", JSON.stringify(updated)); - } catch { - // Local storage is optional; editor behavior does not depend on it. - } - return updated; + return saveRecentDeviceIds(localStorage, updated, validDeviceIds); }); }, [t], diff --git a/src/styles/index.css b/src/styles/index.css index 5b2fcfd..ce5831d 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -1,4 +1,5 @@ :root { + color-scheme: light; font-family: Inter, ui-sans-serif, @@ -516,9 +517,12 @@ summary:focus-visible, gap: 7px; max-height: clamp(220px, 27vh, 310px); margin-top: 7px; + margin-bottom: 0; + padding: 0; overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; + list-style: none; } .recent-devices { display: flex; diff --git a/tests/e2e/image-dimensions.ts b/tests/e2e/image-dimensions.ts new file mode 100644 index 0000000..84a979a --- /dev/null +++ b/tests/e2e/image-dimensions.ts @@ -0,0 +1,64 @@ +import type { Buffer } from "node:buffer"; + +export type ImageDimensions = { width: number; height: number }; + +function jpegDimensions(bytes: Buffer): ImageDimensions { + if (bytes[0] !== 0xff || bytes[1] !== 0xd8) throw new Error("Invalid JPEG signature"); + let offset = 2; + const startOfFrameMarkers = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, + ]); + + while (offset + 8 < bytes.length) { + while (bytes[offset] === 0xff) offset += 1; + const marker = bytes[offset]; + offset += 1; + if (marker === 0xd8 || marker === 0xd9) continue; + if (offset + 1 >= bytes.length) break; + const segmentLength = bytes.readUInt16BE(offset); + if (startOfFrameMarkers.has(marker)) { + return { height: bytes.readUInt16BE(offset + 3), width: bytes.readUInt16BE(offset + 5) }; + } + if (segmentLength < 2) break; + offset += segmentLength; + } + throw new Error("JPEG dimensions were not found"); +} + +function webpDimensions(bytes: Buffer): ImageDimensions { + if (bytes.toString("ascii", 0, 4) !== "RIFF" || bytes.toString("ascii", 8, 12) !== "WEBP") + throw new Error("Invalid WebP signature"); + + const chunk = bytes.toString("ascii", 12, 16); + if (chunk === "VP8X") { + return { + width: bytes.readUIntLE(24, 3) + 1, + height: bytes.readUIntLE(27, 3) + 1, + }; + } + if (chunk === "VP8L") { + if (bytes[20] !== 0x2f) throw new Error("Invalid lossless WebP header"); + const dimensions = bytes.readUInt32LE(21); + return { + width: (dimensions & 0x3fff) + 1, + height: ((dimensions >>> 14) & 0x3fff) + 1, + }; + } + if (chunk === "VP8 ") { + if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) + throw new Error("Invalid lossy WebP header"); + return { + width: bytes.readUInt16LE(26) & 0x3fff, + height: bytes.readUInt16LE(28) & 0x3fff, + }; + } + throw new Error(`Unsupported WebP chunk: ${chunk}`); +} + +export function imageDimensions(bytes: Buffer, format: "png" | "jpeg" | "webp") { + if (format === "png") { + if (bytes.toString("ascii", 1, 4) !== "PNG") throw new Error("Invalid PNG signature"); + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; + } + return format === "jpeg" ? jpegDimensions(bytes) : webpDimensions(bytes); +} diff --git a/tests/e2e/wallpect.spec.ts b/tests/e2e/wallpect.spec.ts index 95bd5e0..27a5295 100644 --- a/tests/e2e/wallpect.spec.ts +++ b/tests/e2e/wallpect.spec.ts @@ -1,11 +1,61 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Download, type Page } from "@playwright/test"; import { readFile } from "node:fs/promises"; +import { imageDimensions } from "./image-dimensions"; -test("@desktop upload, select, adjust, toggle an overlay, and export exact PNG", async ({ +type ExportFormat = "png" | "jpeg" | "webp"; +type ObjectUrlAudit = { created: string[]; revoked: string[] }; + +async function downloadFormat(page: Page, format: ExportFormat): Promise { + const label = format === "png" ? "PNG" : format === "jpeg" ? "JPEG" : "WebP"; + await page + .getByRole("group", { name: "匯出格式" }) + .getByRole("button", { name: label, exact: true }) + .click(); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: /下載桌布/ }).click(); + return downloadPromise; +} + +async function downloadedBytes(download: Download) { + const path = await download.path(); + if (!path) throw new Error("Playwright did not provide a download path"); + return readFile(path); +} + +test("@desktop local upload stays private and all export formats keep exact dimensions", async ({ page, }) => { + await page.addInitScript(() => { + const audit: ObjectUrlAudit = { created: [], revoked: [] }; + const createObjectUrl = URL.createObjectURL; + const revokeObjectUrl = URL.revokeObjectURL; + URL.createObjectURL = (object: Blob | MediaSource) => { + const url = createObjectUrl.call(URL, object); + audit.created.push(url); + return url; + }; + URL.revokeObjectURL = (url: string) => { + audit.revoked.push(url); + revokeObjectUrl.call(URL, url); + }; + Object.defineProperty(window, "__wallpectObjectUrlAudit", { value: audit }); + }); + await page.goto("/"); await expect(page.getByText("Wallpect", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("sample-aurora-wallpaper.png").first()).toBeVisible(); + await page.evaluate(async () => { + if ("serviceWorker" in navigator) await navigator.serviceWorker.ready; + }); + await page.waitForLoadState("networkidle"); + + const postLoadRequests: string[] = []; + page.on("request", (request) => { + const protocol = new URL(request.url()).protocol; + if (protocol === "http:" || protocol === "https:") + postLoadRequests.push(`${request.method()} ${request.url()}`); + }); + const fileInput = page.locator('input[type="file"]'); await fileInput.setInputFiles("public/assets/sample-aurora-wallpaper.png"); await expect(page.getByText("sample-aurora-wallpaper.png").first()).toBeVisible(); @@ -14,7 +64,7 @@ test("@desktop upload, select, adjust, toggle an overlay, and export exact PNG", .locator(".left-rail") .getByPlaceholder("搜尋型號、年份或解析度…") .fill("iPhone 14 Pro"); - await page.getByRole("option", { name: /iPhone 14 Pro,2022/ }).click(); + await page.getByRole("button", { name: /iPhone 14 Pro,2022/ }).click(); await page.getByRole("button", { name: "完整顯示" }).first().click(); await page.getByRole("slider", { name: "縮放", exact: true }).fill("118"); const safeArea = page.getByRole("checkbox", { @@ -24,15 +74,88 @@ test("@desktop upload, select, adjust, toggle an overlay, and export exact PNG", await safeArea.setChecked(false, { force: true }); await expect(safeArea).not.toBeChecked(); - const downloadPromise = page.waitForEvent("download"); - await page.getByRole("button", { name: /下載桌布/ }).click(); - const download = await downloadPromise; - const path = await download.path(); - expect(path).toBeTruthy(); - const png = await readFile(path!); - expect(png.toString("ascii", 1, 4)).toBe("PNG"); - expect(png.readUInt32BE(16)).toBe(1179); - expect(png.readUInt32BE(20)).toBe(2556); + for (const format of ["png", "jpeg", "webp"] as const) { + const bytes = await downloadedBytes(await downloadFormat(page, format)); + expect(imageDimensions(bytes, format)).toEqual({ width: 1179, height: 2556 }); + } + + await page.getByRole("button", { name: "移除圖片" }).first().click(); + await page.waitForTimeout(1_100); + const objectUrls = await page.evaluate( + () => + (window as Window & { __wallpectObjectUrlAudit: ObjectUrlAudit }).__wallpectObjectUrlAudit, + ); + expect(new Set(objectUrls.created).size).toBe(4); + expect(new Set(objectUrls.revoked)).toEqual(new Set(objectUrls.created)); + expect(postLoadRequests).toEqual([]); +}); + +test("@desktop production shell works offline and does not serve HTML for missing assets", async ({ + context, + page, +}) => { + await page.goto("/"); + const updateViaCache = await page.evaluate(async () => { + const registration = await navigator.serviceWorker.ready; + return registration.updateViaCache; + }); + expect(updateViaCache).toBe("none"); + await page.reload(); + await expect(page.getByText("sample-aurora-wallpaper.png").first()).toBeVisible(); + await expect + .poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))) + .toBe(true); + + await context.setOffline(true); + try { + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page).toHaveTitle("Wallpect — 精準檢視桌布構圖"); + await expect(page.locator("canvas").first()).toBeVisible(); + const missingAsset = await page.evaluate(async () => { + try { + const response = await fetch(`/missing-offline-${Date.now()}.js`); + return { rejected: false, status: response.status }; + } catch { + return { rejected: true, status: 0 }; + } + }); + expect(missingAsset).toEqual({ rejected: true, status: 0 }); + } finally { + await context.setOffline(false); + } +}); + +test("@desktop keyboard editing and labelled dialogs remain accessible", async ({ page }) => { + await page.goto("/"); + const canvas = page.getByRole("application", { name: /桌布畫布/ }); + const xPosition = page.locator(".position-control label").first().locator("input"); + await expect(xPosition).toHaveValue("0"); + await canvas.focus(); + await page.keyboard.press("ArrowRight"); + await expect(xPosition).toHaveValue("9"); + + const about = page.getByRole("button", { name: "關於", exact: true }).first(); + await about.click(); + await expect(page.getByRole("dialog", { name: "安心掌握每一個像素。" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(about).toBeFocused(); +}); + +test("@desktop keeps the accepted mixed interface stable under a dark OS preference", async ({ + page, +}) => { + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto("/"); + const appearance = await page.evaluate(() => ({ + colorScheme: getComputedStyle(document.documentElement).colorScheme, + bodyWidth: document.body.scrollWidth, + viewportWidth: document.documentElement.clientWidth, + })); + expect(appearance.colorScheme).toBe("light"); + expect(appearance.bodyWidth).toBeLessThanOrEqual(appearance.viewportWidth); + await expect(page.locator(".preview-stage")).toBeVisible(); + await expect(page.locator(".left-rail")).toBeVisible(); }); test("@mobile workspace has no horizontal overflow and exposes all editor tabs", async ({ @@ -40,13 +163,16 @@ test("@mobile workspace has no horizontal overflow and exposes all editor tabs", }) => { await page.goto("/"); await expect(page.getByRole("navigation", { name: "編輯器面板" })).toBeVisible(); - for (const label of ["圖片", "裝置", "調整", "覆疊", "匯出"]) - await expect(page.getByRole("button", { name: label, exact: true })).toBeVisible(); - const dimensions = await page.evaluate(() => ({ - scrollWidth: document.documentElement.scrollWidth, - width: document.documentElement.clientWidth, - })); - expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.width); + for (const label of ["圖片", "裝置", "調整", "覆疊", "匯出"]) { + const tab = page.getByRole("button", { name: label, exact: true }); + await tab.click(); + await expect(tab).toHaveAttribute("aria-current", "page"); + const dimensions = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + width: document.documentElement.clientWidth, + })); + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.width); + } }); test("@desktop defaults to Traditional Chinese and persists language switches", async ({ @@ -86,6 +212,7 @@ test("@desktop exposes accurate privacy, terms, disclaimer, and source notices", await expect(legalDialog.getByRole("heading", { name: "條款、私隱與資料使用。" })).toBeVisible(); await expect(legalDialog.getByRole("heading", { name: "使用條款" })).toBeVisible(); await expect(legalDialog.getByRole("heading", { name: "資料來源與方法" })).toBeVisible(); + await expect(legalDialog).toContainText("生效日期:2026 年 8 月 2 日"); await expect(legalDialog).toContainText("不使用 Apple 或第三方裝置資料 API"); await expect(legalDialog).toContainText("並非法律意見"); await expect(legalDialog.getByRole("link", { name: /Apple 網站使用條款/ })).toHaveAttribute( diff --git a/tests/integration/editor-controls.test.tsx b/tests/integration/editor-controls.test.tsx index d26dd66..ac7adb7 100644 --- a/tests/integration/editor-controls.test.tsx +++ b/tests/integration/editor-controls.test.tsx @@ -21,9 +21,9 @@ describe("editor controls", () => { const user = userEvent.setup(); renderWithEditor(); await user.type(screen.getByPlaceholderText("搜尋型號、年份或解析度…"), "14 Pro"); - const option = screen.getByRole("option", { name: /iPhone 14 Pro,2022/ }); + const option = screen.getByRole("button", { name: /iPhone 14 Pro,2022/ }); await user.click(option); - expect(option).toHaveAttribute("aria-selected", "true"); + expect(option).toHaveAttribute("aria-pressed", "true"); }); it("filters devices by resolution and clears the search with Escape", async () => { @@ -33,7 +33,7 @@ describe("editor controls", () => { await user.type(search, "1290"); expect(screen.getByText("4 個結果")).toBeInTheDocument(); - expect(screen.getByRole("option", { name: /iPhone 15 Pro Max,2023/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /iPhone 15 Pro Max,2023/ })).toBeInTheDocument(); await user.type(search, "{Escape}"); expect(search).toHaveValue(""); @@ -64,7 +64,7 @@ describe("editor controls", () => { ); await user.click(screen.getByRole("button", { name: "Mac · 27" })); - await user.click(screen.getByRole("option", { name: /MacBook Neo,2026/ })); + await user.click(screen.getByRole("button", { name: /MacBook Neo,2026/ })); expect(container.querySelector(".laptop-base")).toBeInTheDocument(); expect(container.querySelector(".computer-stand")).not.toBeInTheDocument(); diff --git a/tests/setup.ts b/tests/setup.ts index 7618dd4..896ff56 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -27,6 +27,11 @@ Object.defineProperty(globalThis.Image.prototype, "decode", { value: () => Promise.resolve(), }); +Object.defineProperty(globalThis.HTMLCanvasElement.prototype, "getContext", { + configurable: true, + value: () => null, +}); + globalThis.ResizeObserver = class ResizeObserver { observe() {} unobserve() {} diff --git a/tests/unit/export.test.ts b/tests/unit/export.test.ts index 7505705..3fb5cd2 100644 --- a/tests/unit/export.test.ts +++ b/tests/unit/export.test.ts @@ -1,10 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { + downloadBlob, ensureFilenameExtension, extensionForFormat, generateFilename, } from "../../src/core/export"; +afterEach(() => vi.restoreAllMocks()); + describe("export naming", () => { it("uses the required device-orientation-resolution convention", () => { expect(generateFilename("iphone-15-pro", "portrait", 1179, 2556, "png")).toBe( @@ -20,4 +23,22 @@ describe("export naming", () => { it("removes path separators and control characters from download names", () => { expect(ensureFilenameExtension("../unsafe\u202Ename\n.png", "png")).toBe("..-unsafename.png"); }); + + it("revokes the temporary download URL after use", () => { + vi.useFakeTimers(); + const createObjectUrl = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:wallpect-test"); + const revokeObjectUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + + downloadBlob(new Blob(["wallpaper"], { type: "image/png" }), "wallpaper.png"); + + expect(createObjectUrl).toHaveBeenCalledOnce(); + expect(click).toHaveBeenCalledOnce(); + expect(revokeObjectUrl).not.toHaveBeenCalled(); + vi.runAllTimers(); + expect(revokeObjectUrl).toHaveBeenCalledWith("blob:wallpect-test"); + vi.useRealTimers(); + }); }); diff --git a/tests/unit/fit.test.ts b/tests/unit/fit.test.ts index ffa63d1..8911613 100644 --- a/tests/unit/fit.test.ts +++ b/tests/unit/fit.test.ts @@ -18,13 +18,17 @@ describe("wallpaper fit calculations", () => { }); it("uses normalized translation for preview/export parity", () => { - const geometry = calculateRenderGeometry(1000, 1000, 100, 200, { + const transform = { ...DEFAULT_TRANSFORM, translateX: 0.1, translateY: -0.25, - }); - expect(geometry.centerX).toBe(60); - expect(geometry.centerY).toBe(50); + }; + const preview = calculateRenderGeometry(1000, 1000, 393, 852, transform); + const output = calculateRenderGeometry(1000, 1000, 1179, 2556, transform); + expect(output.centerX / preview.centerX).toBeCloseTo(3); + expect(output.centerY / preview.centerY).toBeCloseTo(3); + expect(output.renderedWidth / preview.renderedWidth).toBeCloseTo(3); + expect(output.renderedHeight / preview.renderedHeight).toBeCloseTo(3); }); it("reports crop percentage and clamps unsafe transforms", () => { diff --git a/tests/unit/preferences.test.ts b/tests/unit/preferences.test.ts new file mode 100644 index 0000000..f1bee66 --- /dev/null +++ b/tests/unit/preferences.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + LEGACY_RECENT_DEVICES_STORAGE_KEY, + RECENT_DEVICES_STORAGE_KEY, + loadRecentDeviceIds, + saveRecentDeviceIds, +} from "../../src/core/preferences"; + +const validIds = new Set(["one", "two", "three", "four", "five"]); + +describe("recent device preferences", () => { + beforeEach(() => localStorage.clear()); + + it("sanitizes the current version to valid, unique IDs with a four-item limit", () => { + localStorage.setItem( + RECENT_DEVICES_STORAGE_KEY, + JSON.stringify(["one", "missing", "one", "two", 3, "three", "four", "five"]), + ); + + expect(loadRecentDeviceIds(localStorage, validIds)).toEqual(["one", "two", "three", "four"]); + expect(JSON.parse(localStorage.getItem(RECENT_DEVICES_STORAGE_KEY) ?? "null")).toEqual([ + "one", + "two", + "three", + "four", + ]); + }); + + it("migrates the legacy key without retaining duplicate preference data", () => { + localStorage.setItem(LEGACY_RECENT_DEVICES_STORAGE_KEY, JSON.stringify(["two", "one"])); + + expect(loadRecentDeviceIds(localStorage, validIds)).toEqual(["two", "one"]); + expect(localStorage.getItem(LEGACY_RECENT_DEVICES_STORAGE_KEY)).toBeNull(); + expect(localStorage.getItem(RECENT_DEVICES_STORAGE_KEY)).toBe('["two","one"]'); + }); + + it("recovers from malformed or unavailable storage without blocking the editor", () => { + localStorage.setItem(RECENT_DEVICES_STORAGE_KEY, "not-json"); + expect(loadRecentDeviceIds(localStorage, validIds)).toEqual([]); + + const unavailableStorage = { + getItem: () => { + throw new Error("disabled"); + }, + setItem: () => { + throw new Error("disabled"); + }, + removeItem: () => { + throw new Error("disabled"); + }, + }; + expect(loadRecentDeviceIds(unavailableStorage, validIds)).toEqual([]); + expect(saveRecentDeviceIds(unavailableStorage, ["one"], validIds)).toEqual(["one"]); + }); +});