diff --git a/.superpowers/sdd/2026-07-27-linux-workspaces-desktop-status/final-fix-report.md b/.superpowers/sdd/2026-07-27-linux-workspaces-desktop-status/final-fix-report.md new file mode 100644 index 0000000..d4f46ba --- /dev/null +++ b/.superpowers/sdd/2026-07-27-linux-workspaces-desktop-status/final-fix-report.md @@ -0,0 +1,138 @@ +# Codex Pet Pause 0.2.2 Final Fix Report + +## Status + +The final review findings are addressed at the strongest maintainable +repository-owned boundaries available in the current harness. This fix wave +changes tests and this report only. It does not change production code, +dependencies, native integrations, release workflows, or files under +`release/`. + +## Finding P2: Linux policy and pointer interaction + +### Regression added + +- `electron/main.test.js` now imports the real main process with + `process.platform` set deterministically to `linux`. +- The test proves that the pet `BrowserWindow` receives `focusable: false`, + retains `alwaysOnTop: true`, and receives + `setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })`. +- In the same Linux-policy test, the registered `pet:drag-window` handler is + executed against the current pet sender and must call `setPosition` with + normalized coordinates. +- The registered `pet:show-context-menu` handler is executed against the same + pet sender and must open the menu for that window at bounded local + coordinates. +- `src/app/DesktopApp.test.tsx` now sends real pointer-down, pointer-move, and + context-menu events to the rendered pet. It proves the renderer continues to + call the desktop shell's drag and context-menu APIs. +- The existing `electron/window-policy.test.js` continues to prove that only + Linux resolves to the non-focusable policy. + +These assertions would fail if the Linux policy stopped being spread into the +pet window, if either main-process IPC channel stopped being registered or +functional, or if the renderer stopped forwarding drag or right-click input. + +### TDD evidence + +RED mutation: + +```text +PET_PAUSE_MAIN_TEST_PLATFORM=darwin npm run test:electron -- electron/main.test.js +``` + +The command exited 1. The new Linux regression failed at the intended boundary: +the pet received `focusable: true` instead of `false`. The run contained one +failed and two passed tests. + +GREEN: + +```text +npm run test:electron -- electron/main.test.js electron/window-policy.test.js +``` + +The command exited 0 with 2 test files and 6 tests passing. + +```text +npm run test:run -- src/app/CapabilityStatus.test.tsx src/app/DesktopApp.test.tsx +``` + +The command exited 0 with 2 test files and 15 tests passing. + +```text +npm run typecheck +``` + +The command exited 0. + +## Finding P3: Desktop degraded-state and PWA suppression + +`src/app/CapabilityStatus.test.tsx` now covers both desktop notification +degradation states, `denied` and `unavailable`, while storage is temporary and +PWA registration has failed. Each case proves: + +- the applicable notification warning remains visible; +- the temporary-storage warning remains visible; +- desktop background guidance remains visible; +- the PWA registration-failure message is suppressed; +- the PWA preparing message is suppressed; +- page-visibility and browser-close lifecycle warnings remain suppressed. + +The production component already had the required branch structure, so these +characterization cases passed when first run. No production change was needed +or justified. The assertions protect the previously uncovered branches against +future regressions. + +## Self-review + +- The Linux test uses the real `resolvePetWindowPolicy` and main-process module, + with Electron itself mocked at the repository boundary. +- The temporary platform override is restored in a `finally` block. +- Pointer tests assert repository-owned outcomes rather than retesting an + Electron or desktop-environment implementation. +- The settings window's normal focusable default remains covered. +- `git diff --check` completed successfully. +- The pre-existing untracked `release/` directory was left untouched and is + excluded from this fix wave. + +## Native acceptance residual + +A real packaged cross-workspace smoke test is not feasible in the existing +harness. The repository has Vitest tests with mocked Electron and browser +Playwright tests, but it does not have a packaged-app driver, Linux compositor +session provisioning, workspace-switching control, or native pointer +automation. Adding those here would introduce the large and brittle +desktop-environment dependency explicitly excluded from this fix. + +The upstream behavior therefore relies on Electron's official contract: + +- Electron documents that Linux `focusable: false` stops the window interacting + with the window manager so it remains on top across workspaces: + [BaseWindowConstructorOptions](https://www.electronjs.org/docs/latest/api/structures/base-window-options). +- Electron documents `setVisibleOnAllWorkspaces` for Linux and macOS: + [BrowserWindow](https://www.electronjs.org/docs/latest/api/browser-window/#winsetvisibleonallworkspacesvisible-options). +- Electron defines mouse-event ignoring through the separate + `setIgnoreMouseEvents` API. This application does not enable it, while the + repository tests prove that its renderer and main-process pointer paths + remain active. + +The remaining native uncertainty is precise: + +- No automated evidence switches real X11 workspaces or confirms pointer + delivery from a specific X11 window manager to the packaged non-focusable + window. +- No automated evidence switches real Wayland workspaces or confirms pointer + delivery from a specific compositor to the packaged non-focusable window. +- Electron documents that `setPosition` is unsupported on Wayland. The + repository regression proves that drag IPC remains enabled and reaches + Electron, but it cannot guarantee that a native Wayland compositor moves the + window. +- Packaged AppImage and DEB acceptance on representative X11 and Wayland + sessions remains a manual or release-infrastructure follow-up, not a claim + made by this test wave. + +## Release boundary + +This fix wave runs focused repository tests only. The release controller still +owns the full `npm run check:desktop-release`, GitHub validation, packaging, +signing, and artifact-job evidence required before publishing `v0.2.2`. diff --git a/docs/superpowers/plans/2026-07-27-linux-workspaces-desktop-status.md b/docs/superpowers/plans/2026-07-27-linux-workspaces-desktop-status.md new file mode 100644 index 0000000..f4d50ba --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-linux-workspaces-desktop-status.md @@ -0,0 +1,313 @@ +# Linux Workspaces and Desktop Status Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the Linux pet visible across workspaces and show accurate background-reminder status in packaged settings windows. + +**Architecture:** A pure Electron window-policy function owns platform differences and is applied only to the pet window. `CapabilityStatus` receives an explicit desktop-host flag from `AppShell`, preserving browser behavior while replacing browser-only lifecycle guidance in Electron. + +**Tech Stack:** Electron 35, React 19, TypeScript, Vitest, Testing Library, Playwright, electron-builder + +## Global Constraints + +- Linux pet window is non-focusable, remains pointer-interactive, and stays visible across workspaces. +- macOS all-spaces and fullscreen behavior remains unchanged. +- Windows stays always on top in the current virtual desktop; no native virtual-desktop module is added. +- Settings windows remain focusable on every platform. +- Browser capability guidance remains unchanged. +- Electron settings omit PWA readiness, document visibility, and browser-close warnings. +- Chinese desktop copy is `桌面应用正在后台运行,关闭设置窗口后提醒仍会继续。` +- English desktop copy is `The desktop app keeps running in the background. Reminders continue after you close the settings window.` +- Release version is `0.2.2`. + +--- + +### Task 1: Platform-specific pet window policy + +**Files:** +- Create: `electron/window-policy.js` +- Create: `electron/window-policy.test.js` +- Modify: `electron/main.js` +- Modify: `electron/main.test.js` + +**Interfaces:** +- Produces: `resolvePetWindowPolicy(platform: NodeJS.Platform): { focusable: boolean }` +- Consumes: Electron `BrowserWindow` constructor options and `setVisibleOnAllWorkspaces` + +- [ ] **Step 1: Write the failing pure-policy tests** + +```js +import { describe, expect, it } from 'vitest'; +import { resolvePetWindowPolicy } from './window-policy.js'; + +describe('pet window platform policy', () => { + it('makes the Linux pet non-focusable so it follows every workspace', () => { + expect(resolvePetWindowPolicy('linux')).toEqual({ focusable: false }); + }); + + it.each(['darwin', 'win32'])('keeps the %s pet focusable', (platform) => { + expect(resolvePetWindowPolicy(platform)).toEqual({ focusable: true }); + }); +}); +``` + +- [ ] **Step 2: Run the policy test and verify RED** + +Run: `npx vitest run electron/window-policy.test.js` + +Expected: FAIL because `electron/window-policy.js` does not exist. + +- [ ] **Step 3: Implement the minimal policy** + +```js +export function resolvePetWindowPolicy(platform) { + return { + focusable: platform !== 'linux', + }; +} +``` + +- [ ] **Step 4: Apply the policy to the pet window only** + +Import `resolvePetWindowPolicy` in `electron/main.js` and spread +`resolvePetWindowPolicy(process.platform)` into the pet `BrowserWindow` +options. Do not apply it to `settingsWindow`. + +Keep: + +```js +alwaysOnTop: true, +``` + +and keep the existing call: + +```js +petWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); +``` + +- [ ] **Step 5: Add main-process regression assertions** + +Extend `electron/main.test.js` to assert: + +```js +const firstPetWindow = electron.windows[0]; +expect(firstPetWindow.options.alwaysOnTop).toBe(true); +expect(firstPetWindow.options.focusable).toBe(process.platform !== 'linux'); +expect(firstPetWindow.setVisibleOnAllWorkspaces) + .toHaveBeenCalledWith(true, { visibleOnFullScreen: true }); +``` + +After opening settings, assert: + +```js +expect(firstSettingsWindow.options.focusable).toBeUndefined(); +``` + +- [ ] **Step 6: Run Electron tests and verify GREEN** + +Run: `npm run test:electron && npx vitest run electron/window-policy.test.js` + +Expected: all Electron and window-policy tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add electron/window-policy.js electron/window-policy.test.js electron/main.js electron/main.test.js +git commit -m "fix: keep Linux pet across workspaces" +``` + +### Task 2: Desktop-specific capability guidance + +**Files:** +- Modify: `src/app/CapabilityStatus.tsx` +- Modify: `src/app/CapabilityStatus.test.tsx` +- Modify: `src/app/AppShell.tsx` +- Modify: `src/app/AppShell.test.tsx` +- Modify: `src/i18n/messages.ts` + +**Interfaces:** +- Changes: `CapabilityStatus` props add `desktopShellAvailable: boolean` +- Consumes: `window.petShell !== undefined` +- Produces: translation key `capability.desktopBackground` + +- [ ] **Step 1: Write failing desktop capability tests** + +Extend the `renderStatus` helper to accept `desktopShellAvailable = false` and +pass it to `CapabilityStatus`. + +Add a Chinese desktop test: + +```tsx +test('shows native background guidance without browser lifecycle warnings', () => { + renderStatus('granted', 'persistent', false, 'zh-CN', true); + + expect(screen.getByText('桌面应用正在后台运行,关闭设置窗口后提醒仍会继续。')).toBeVisible(); + expect(screen.queryByText('离线启动尚未准备好')).not.toBeInTheDocument(); + expect(screen.queryByText('页面当前在前台运行')).not.toBeInTheDocument(); + expect(screen.queryByText('关闭网页或浏览器后,提醒不会继续运行。')).not.toBeInTheDocument(); +}); +``` + +Add an English desktop test: + +```tsx +test('localizes native background guidance in English', () => { + renderStatus('granted', 'persistent', false, 'en', true); + + expect(screen.getByText( + 'The desktop app keeps running in the background. Reminders continue after you close the settings window.', + )).toBeVisible(); +}); +``` + +Keep the existing browser tests unchanged. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `npx vitest run src/app/CapabilityStatus.test.tsx` + +Expected: FAIL because the new prop and localized desktop message do not exist. + +- [ ] **Step 3: Add localized desktop copy** + +Add to both locale maps in `src/i18n/messages.ts`: + +```ts +'capability.desktopBackground': '桌面应用正在后台运行,关闭设置窗口后提醒仍会继续。', +``` + +```ts +'capability.desktopBackground': 'The desktop app keeps running in the background. Reminders continue after you close the settings window.', +``` + +- [ ] **Step 4: Implement desktop/browser status branches** + +Add the required prop: + +```ts +desktopShellAvailable: boolean; +``` + +Render browser-only status only when `desktopShellAvailable` is false: + +```tsx +{!desktopShellAvailable && !offlineReady && ( +

{offlineRegistrationFailed + ? t('pwa.error.registrationUnavailable') + : t('capability.offline.preparing')}

+)} +{!desktopShellAvailable && ( +

{visibility === 'hidden' + ? t('capability.visibility.hidden') + : t('capability.visibility.visible')}

+)} +

{t(desktopShellAvailable + ? 'capability.desktopBackground' + : 'capability.closedWarning')}

+``` + +Notification and temporary-storage warnings remain outside this branch. + +- [ ] **Step 5: Pass the host capability from AppShell** + +In `src/app/AppShell.tsx` pass: + +```tsx +desktopShellAvailable={window.petShell !== undefined} +``` + +- [ ] **Step 6: Add an AppShell integration regression** + +In `src/app/AppShell.test.tsx`, render the hosted settings URL with +`window.petShell` defined. Assert that the desktop background message is +visible and the browser-close warning is absent. Restore `window.petShell` +after the test. + +- [ ] **Step 7: Run focused UI tests and verify GREEN** + +Run: + +```bash +npx vitest run src/app/CapabilityStatus.test.tsx src/app/AppShell.test.tsx +``` + +Expected: all focused tests PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/app/CapabilityStatus.tsx src/app/CapabilityStatus.test.tsx src/app/AppShell.tsx src/app/AppShell.test.tsx src/i18n/messages.ts +git commit -m "fix: show accurate desktop reminder status" +``` + +### Task 3: Version, review, and release verification + +**Files:** +- Modify: `package.json` +- Modify: `package-lock.json` + +**Interfaces:** +- Produces: package version `0.2.2` and tag `v0.2.2` + +- [ ] **Step 1: Bump the package version** + +Run: + +```bash +npm version 0.2.2 --no-git-tag-version +``` + +Expected: `package.json` and `package-lock.json` both report `0.2.2`. + +- [ ] **Step 2: Commit the version** + +```bash +git add package.json package-lock.json +git commit -m "chore: bump desktop release to 0.2.2" +``` + +- [ ] **Step 3: Request whole-branch code review** + +Review the complete diff from `origin/main` through the branch head. Require +findings-first review covering Linux pointer interaction risk, Windows and +macOS regressions, desktop/browser copy branching, localization, and release +configuration. + +- [ ] **Step 4: Run the full local release gate** + +Run: + +```bash +npm run check:desktop-release +``` + +Expected: + +- TypeScript passes. +- Unit and Electron tests pass. +- All Playwright tests pass. +- Web and desktop builds pass. +- Packaged-resource smoke tests pass. + +- [ ] **Step 5: Push and validate GitHub CI** + +Push `fix/linux-workspaces-desktop-status`, open a pull request, and wait for +the `Build Desktop` validation job to succeed. + +- [ ] **Step 6: Merge and publish** + +Merge the approved pull request, tag the resulting `main` commit as `v0.2.2`, +and wait for validate, macOS ARM64, macOS x64, Windows x64, Linux x64, and +release jobs to succeed. + +- [ ] **Step 7: Verify public release assets** + +Confirm the release contains: + +```text +Codex-Pet-Pause-0.2.2-mac-arm64.dmg +Codex-Pet-Pause-0.2.2-mac-x64.dmg +Codex-Pet-Pause-0.2.2-windows-x64.exe +Codex-Pet-Pause-0.2.2-linux-x64.AppImage +Codex-Pet-Pause-0.2.2-linux-x64.deb +``` diff --git a/docs/superpowers/specs/2026-07-27-linux-workspaces-desktop-status-design.md b/docs/superpowers/specs/2026-07-27-linux-workspaces-desktop-status-design.md new file mode 100644 index 0000000..6831a9e --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-linux-workspaces-desktop-status-design.md @@ -0,0 +1,74 @@ +# Linux Workspaces and Desktop Status Design + +## Goal + +Make the Linux desktop pet visible on every workspace and replace browser-only +capability warnings with accurate desktop-app guidance in packaged settings +windows. + +## Platform behavior + +### Linux + +The pet window will be non-focusable on Linux. Electron documents this window +policy as staying on top across all workspaces. The settings window remains a +normal focusable window. + +The pet must retain pointer interaction through the renderer and existing IPC: +dragging, activation, reminder actions, and the right-click menu continue to +work. Keyboard focus on the Linux pet window is not guaranteed because it +conflicts with the all-workspaces window policy. + +The existing `setVisibleOnAllWorkspaces(true)` call remains as a compatible +signal for Linux window managers that support it. + +### macOS + +The existing all-spaces and fullscreen behavior remains unchanged. + +### Windows + +The pet remains always on top in the current Windows virtual desktop. Electron +does not support pinning a window to every Windows virtual desktop, so this +change will not add a native module based on undocumented Windows APIs. + +## Settings capability status + +`CapabilityStatus` will distinguish a browser renderer from an Electron +renderer through an explicit prop supplied by `AppShell`. + +In an Electron settings window: + +- Browser-only PWA readiness, page visibility, and browser-close warnings are + omitted. +- A localized desktop message states that the app continues running in the + background after the settings window closes. +- Real degraded-state warnings, such as temporary storage or unavailable + notifications, remain visible. + +In a browser, the current capability status and warnings remain unchanged. + +Chinese desktop copy: + +> 桌面应用正在后台运行,关闭设置窗口后提醒仍会继续。 + +English desktop copy: + +> The desktop app keeps running in the background. Reminders continue after you +> close the settings window. + +## Testing + +- Add a pure, platform-specific pet-window policy and test Linux, macOS, and + Windows behavior without changing the host test process platform. +- Verify the Electron main process applies both the platform policy and the + all-workspaces API. +- Add `CapabilityStatus` tests for Chinese and English desktop renderers. +- Preserve existing browser capability-status tests. +- Run the full desktop release gate before publishing. + +## Release + +After review and successful local and GitHub validation, publish the fix as +`v0.2.2` with the existing macOS ARM64, macOS x64, Windows x64, Linux AppImage, +and Linux DEB artifacts. diff --git a/electron/main.js b/electron/main.js index f7de06f..3fe97d5 100644 --- a/electron/main.js +++ b/electron/main.js @@ -10,6 +10,7 @@ import { nativeImage, ipcMain, } from 'electron'; +import { resolvePetWindowPolicy } from './window-policy.js'; const IS_DEV = process.argv.includes('--dev') || process.env.NODE_ENV === 'development'; const EDGE_HANDLE_SIZE = 24; @@ -402,6 +403,7 @@ function createWindow() { transparent: HAS_TRANSPARENT_WINDOW, show: false, alwaysOnTop: true, + ...resolvePetWindowPolicy(process.platform), skipTaskbar: true, hasShadow: false, icon: resolveAssetPath('pwa-192x192.png') ?? resolveAssetPath('pwa-512x512.png') ?? undefined, diff --git a/electron/main.test.js b/electron/main.test.js index 8a0e4c7..6a6f442 100644 --- a/electron/main.test.js +++ b/electron/main.test.js @@ -100,8 +100,17 @@ const electron = vi.hoisted(() => { vi.mock('electron', () => electron); beforeAll(async () => { - await import('./main.js'); - await Promise.resolve(); + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { + ...originalPlatform, + value: process.env.PET_PAUSE_MAIN_TEST_PLATFORM ?? 'linux', + }); + try { + await import('./main.js'); + await Promise.resolve(); + } finally { + Object.defineProperty(process, 'platform', originalPlatform); + } }); describe('Electron process lifecycle', () => { @@ -121,10 +130,15 @@ describe('Electron process lifecycle', () => { expect(preventDefault).not.toHaveBeenCalled(); }); - it('registers process IPC once and targets recreated windows safely', () => { + it('applies the Linux workspace policy while drag and context-menu IPC remain active', () => { const activate = electron.appListeners.get('activate'); const firstPetWindow = electron.windows[0]; + expect(firstPetWindow.options.alwaysOnTop).toBe(true); + expect(firstPetWindow.options.focusable).toBe(false); + expect(firstPetWindow.setVisibleOnAllWorkspaces) + .toHaveBeenCalledWith(true, { visibleOnFullScreen: true }); + firstPetWindow.destroyed = true; activate(); const secondPetWindow = electron.windows.at(-1); @@ -158,10 +172,25 @@ describe('Electron process lifecycle', () => { expect(currentPetWindow.setPosition).toHaveBeenCalledWith(50, 61, false); expect(firstPetWindow.setPosition).not.toHaveBeenCalled(); + const showContextMenu = electron.ipcMain.on.mock.calls + .find(([channel]) => channel === 'pet:show-context-menu')[1]; + showContextMenu( + { sender: currentPetWindow.webContents }, + { x: 200, y: 210 }, + ); + + const contextMenu = electron.Menu.buildFromTemplate.mock.results.at(-1).value; + expect(contextMenu.popup).toHaveBeenCalledWith({ + window: currentPetWindow, + x: 20, + y: 30, + }); + const openSettings = electron.ipcMain.handle.mock.calls .find(([channel]) => channel === 'pet:open-settings')[1]; openSettings(); const firstSettingsWindow = electron.windows.at(-1); + expect(firstSettingsWindow.options.focusable).toBeUndefined(); firstSettingsWindow.listeners.get('closed')(); openSettings(); diff --git a/electron/window-policy.js b/electron/window-policy.js new file mode 100644 index 0000000..b2177be --- /dev/null +++ b/electron/window-policy.js @@ -0,0 +1,5 @@ +export function resolvePetWindowPolicy(platform) { + return { + focusable: platform !== 'linux', + }; +} diff --git a/electron/window-policy.test.js b/electron/window-policy.test.js new file mode 100644 index 0000000..c2d531d --- /dev/null +++ b/electron/window-policy.test.js @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { resolvePetWindowPolicy } from './window-policy.js'; + +describe('pet window platform policy', () => { + it('makes the Linux pet non-focusable so it follows every workspace', () => { + expect(resolvePetWindowPolicy('linux')).toEqual({ focusable: false }); + }); + + it.each(['darwin', 'win32'])('keeps the %s pet focusable', (platform) => { + expect(resolvePetWindowPolicy(platform)).toEqual({ focusable: true }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 4bcbac0..56b134c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-pet-pause", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-pet-pause", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "dependencies": { "@zip.js/zip.js": "^2.8.34", diff --git a/package.json b/package.json index 2757bb1..0aa33a8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-pet-pause", "private": true, - "version": "0.2.1", + "version": "0.2.2", "description": "A playful, local-first break reminder PWA with interactive and Codex-compatible pets.", "license": "MIT", "repository": { diff --git a/src/app/AppShell.test.tsx b/src/app/AppShell.test.tsx index d4f9036..894fab0 100644 --- a/src/app/AppShell.test.tsx +++ b/src/app/AppShell.test.tsx @@ -73,6 +73,23 @@ test('desktop settings route keeps the full shell, opens settings, and hides the window.history.replaceState({}, '', '/'); }); +test('hosted settings show desktop background guidance instead of browser-close warning', () => { + const originalPetShell = window.petShell; + Object.defineProperty(window, 'petShell', { configurable: true, value: {} }); + window.history.replaceState({}, '', '/?mode=web&view=settings&hidePet=1'); + const controller = createAppController(createFakeDependencies({ now: 0 })); + + try { + renderShell(controller); + + expect(screen.getByText('桌面应用正在后台运行,关闭设置窗口后提醒仍会继续。')).toBeVisible(); + expect(screen.queryByText('关闭网页或浏览器后,提醒不会继续运行。')).not.toBeInTheDocument(); + } finally { + Object.defineProperty(window, 'petShell', { configurable: true, value: originalPetShell }); + window.history.replaceState({}, '', '/'); + } +}); + test('returns the viewport to the top when the shell opens and views change', async () => { const user = userEvent.setup(); const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 0b7e678..e404a4c 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -61,6 +61,7 @@ export function AppShell() { notificationStatus={snapshot.notificationStatus} storageMode={snapshot.storageMode} offlineReady={pwaSnapshot.offlineReady} + desktopShellAvailable={window.petShell !== undefined} /> {!hidePetFromHost && ( diff --git a/src/app/CapabilityStatus.test.tsx b/src/app/CapabilityStatus.test.tsx index b7e594c..64f4571 100644 --- a/src/app/CapabilityStatus.test.tsx +++ b/src/app/CapabilityStatus.test.tsx @@ -15,6 +15,7 @@ function renderStatus( storageMode: 'persistent' | 'temporary', offlineReady: boolean, locale: 'zh-CN' | 'en' = 'zh-CN', + desktopShellAvailable = false, ) { return render( @@ -22,6 +23,7 @@ function renderStatus( notificationStatus={notificationStatus} storageMode={storageMode} offlineReady={offlineReady} + desktopShellAvailable={desktopShellAvailable} /> , ); @@ -71,3 +73,39 @@ test('shows capability guidance in English', () => { expect(screen.getByText('This is a temporary session. Settings may be lost after refreshing.')).toBeVisible(); expect(screen.getByText('Offline startup is not ready yet.')).toBeVisible(); }); + +test('shows native background guidance without browser lifecycle warnings', () => { + renderStatus('granted', 'persistent', false, 'zh-CN', true); + + expect(screen.getByText('桌面应用正在后台运行,关闭设置窗口后提醒仍会继续。')).toBeVisible(); + expect(screen.queryByText('离线启动尚未准备好')).not.toBeInTheDocument(); + expect(screen.queryByText('页面当前在前台运行')).not.toBeInTheDocument(); + expect(screen.queryByText('关闭网页或浏览器后,提醒不会继续运行。')).not.toBeInTheDocument(); +}); + +test.each([ + ['denied', '系统通知已关闭,后台提醒可靠性会降低'], + ['unavailable', '当前浏览器仅支持网页内提醒'], +] as const)('retains desktop %s notification and temporary-storage warnings while suppressing PWA failures', ( + notificationStatus, + notificationWarning, +) => { + pwaStatus.markRegistrationError(); + renderStatus(notificationStatus, 'temporary', false, 'zh-CN', true); + + expect(screen.getByText(notificationWarning)).toBeVisible(); + expect(screen.getByText('当前是临时会话,刷新后设置可能丢失')).toBeVisible(); + expect(screen.getByText('桌面应用正在后台运行,关闭设置窗口后提醒仍会继续。')).toBeVisible(); + expect(screen.queryByText('离线能力不可用')).not.toBeInTheDocument(); + expect(screen.queryByText('离线启动尚未准备好')).not.toBeInTheDocument(); + expect(screen.queryByText('页面当前在前台运行')).not.toBeInTheDocument(); + expect(screen.queryByText('关闭网页或浏览器后,提醒不会继续运行。')).not.toBeInTheDocument(); +}); + +test('localizes native background guidance in English', () => { + renderStatus('granted', 'persistent', false, 'en', true); + + expect(screen.getByText( + 'The desktop app keeps running in the background. Reminders continue after you close the settings window.', + )).toBeVisible(); +}); diff --git a/src/app/CapabilityStatus.tsx b/src/app/CapabilityStatus.tsx index 731f522..7bbdc4b 100644 --- a/src/app/CapabilityStatus.tsx +++ b/src/app/CapabilityStatus.tsx @@ -7,6 +7,7 @@ interface Props { notificationStatus: NotificationStatus; storageMode: 'persistent' | 'temporary'; offlineReady: boolean; + desktopShellAvailable: boolean; } function subscribeToVisibility(listener: () => void): () => void { @@ -19,7 +20,7 @@ function getVisibility(): DocumentVisibilityState { return typeof document === 'undefined' ? 'visible' : document.visibilityState; } -export function CapabilityStatus({ notificationStatus, storageMode, offlineReady }: Props) { +export function CapabilityStatus({ notificationStatus, storageMode, offlineReady, desktopShellAvailable }: Props) { const { t } = useI18n(); const visibility = useSyncExternalStore(subscribeToVisibility, getVisibility, () => 'visible'); const pwaSnapshot = useSyncExternalStore(pwaStatus.subscribe, pwaStatus.getSnapshot, pwaStatus.getSnapshot); @@ -34,9 +35,13 @@ export function CapabilityStatus({ notificationStatus, storageMode, offlineReady ); } diff --git a/src/app/DesktopApp.test.tsx b/src/app/DesktopApp.test.tsx index 486586d..2d1f411 100644 --- a/src/app/DesktopApp.test.tsx +++ b/src/app/DesktopApp.test.tsx @@ -19,9 +19,10 @@ test('renders desktop shell with builtin cat by default', async () => { expect(screen.queryByTestId('pet-stage')).not.toBeInTheDocument(); }); -test('opens desktop context menu on right click', async () => { +test('routes drag and right-click pointer input through the desktop shell', async () => { const showContextMenu = vi.fn(); - window.petShell = { showContextMenu, dragWindowTo: vi.fn() }; + const dragWindowTo = vi.fn(); + window.petShell = { showContextMenu, dragWindowTo }; const controller = createAppController(createFakeDependencies({ now: 1 })); render(); @@ -31,6 +32,23 @@ test('opens desktop context menu on right click', async () => { }); const cat = await screen.findByRole('button', { name: '摸摸 Momo' }); + fireEvent.pointerDown(cat, { + pointerId: 1, + button: 0, + clientX: 10, + clientY: 20, + screenX: 110, + screenY: 220, + }); + fireEvent.pointerMove(cat, { + pointerId: 1, + clientX: 20, + clientY: 30, + screenX: 120, + screenY: 230, + }); + expect(dragWindowTo).toHaveBeenCalledWith(110, 210); + fireEvent.contextMenu(cat, { screenX: 640, screenY: 360 }); expect(showContextMenu).toHaveBeenCalledOnce(); expect(showContextMenu).toHaveBeenCalledWith(640, 360); diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 5431d8c..50d8f45 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -99,6 +99,7 @@ const zhCN = { 'capability.offline.preparing': '离线启动尚未准备好', 'capability.visibility.hidden': '页面当前在后台运行', 'capability.visibility.visible': '页面当前在前台运行', + 'capability.desktopBackground': '桌面应用正在后台运行,关闭设置窗口后提醒仍会继续。', 'capability.closedWarning': '关闭网页或浏览器后,提醒不会继续运行。', 'onboarding.kicker': '你的桌面休息伙伴', 'onboarding.title': '欢迎来到 Codex Pet Pause', @@ -383,6 +384,7 @@ const en: Catalog = { 'capability.offline.preparing': 'Offline startup is not ready yet.', 'capability.visibility.hidden': 'This page is currently running in the background.', 'capability.visibility.visible': 'This page is currently running in the foreground.', + 'capability.desktopBackground': 'The desktop app keeps running in the background. Reminders continue after you close the settings window.', 'capability.closedWarning': 'Reminders will stop when you close this page or browser.', 'onboarding.kicker': 'Your desktop break companion', 'onboarding.title': 'Welcome to Codex Pet Pause', diff --git a/vitest.electron.config.js b/vitest.electron.config.js index d023bb8..deffa52 100644 --- a/vitest.electron.config.js +++ b/vitest.electron.config.js @@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['electron/main.test.js'], + include: ['electron/*.test.js'], }, });