From bb7554a7cde032c5de2a1763062a133604cb12ca Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Thu, 13 Aug 2026 18:09:16 +0900 Subject: [PATCH 01/12] =?UTF-8?q?docs:=20Player=20iOS=20=EB=9F=B0=EC=B2=98?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-13 스펙(player-ios-launcher-design)의 8개 태스크 구현 계획. 런처 순수 로직 → 정적 페이지 → 빌드 스크립트 → 각 서브앱 전환 버튼 → 실기기 검증 순서. Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-08-13-player-ios-launcher.md | 815 ++++++++++++++++++ 1 file changed, 815 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-13-player-ios-launcher.md diff --git a/docs/superpowers/plans/2026-08-13-player-ios-launcher.md b/docs/superpowers/plans/2026-08-13-player-ios-launcher.md new file mode 100644 index 0000000..7c7a648 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-player-ios-launcher.md @@ -0,0 +1,815 @@ +# Player iOS 런처 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:** Coding 앱(`kr.selim.maestro`)과 동일한 iOS 설치 안에, Player(공개 URL 리플레이)로 들어가는 완전히 분리된 화면 트리를 추가한다. 진입은 얇은 정적 런처 화면이 담당한다. + +**Architecture:** 루트 앱(`src/`)과 `player/`는 지금처럼 독립된 Vite 빌드를 유지한다. 새 스크립트 `scripts/build-ios-shell.mjs`가 두 빌드 결과물을 `dist-ios-shell/coding/`, `dist-ios-shell/player/`로 나란히 배치하고, 정적 런처(`ios/launcher/`)를 `dist-ios-shell/index.html`로 복사한다. `capacitor.config.json`의 `webDir`을 `dist-ios-shell`로 바꿔 `cap sync ios`가 이 합쳐진 셸을 그대로 iOS 웹뷰에 반영한다. 각 서브앱 헤더에는 네이티브 셸에서만 보이는 "⇄ 전환" 버튼을 추가해 런처로 돌아갈 수 있게 한다. + +**Tech Stack:** Vite(프로그래매틱 `build()` API), 순수 ESM 스크립트(Node 20), React(각 서브앱 헤더 컴포넌트), Vitest+jsdom(UI 테스트), `node:test`(스크립트/정적 파일 테스트), Capacitor(iOS 셸). + +## Global Constraints + +- `player/`는 자체 `package.json`/빌드/테스트를 유지하고 루트 앱(`src/`, `tests/`, `maestro-server.js`)을 import하지 않는다. 루트 앱도 `player/` 코드를 import하지 않는다 — 산출물(정적 파일) 레벨에서만 결합한다. +- Player의 iOS 모드는 **공개 URL 모드만** 노출한다. Local Repo / Connected Account 모드는 비범위. +- 기존 웹 배포(GH Pages, 루트 앱 base `/maestro-coding/`)와 Chrome 확장(`player/extension/`)은 이 작업으로 변경되지 않는다. +- 홈 화면 앱 아이콘은 신규 제작하지 않고 현재 것을 유지한다. `Info.plist`의 `CFBundleDisplayName`도 "Maestro" 그대로 유지한다. +- 전환은 풀 페이지 리로드다(SPA 라우팅 통합 아님) — 전환 시 각 서브앱의 내부 상태는 초기화된다. + +--- + +## Task 1: `dist-ios-shell/` 빌드 산출물 gitignore 처리 + +**Files:** +- Modify: `.gitignore` + +**Interfaces:** +- Consumes: 없음 +- Produces: 이후 태스크가 생성하는 `dist-ios-shell/` 디렉토리가 git에 추적되지 않음 + +- [ ] **Step 1: `.gitignore`에 항목 추가** + +`.gitignore`의 `dist/` 줄 바로 아래에 다음 줄을 추가한다: + +``` +dist-ios-shell/ +``` + +- [ ] **Step 2: 확인** + +Run: `git check-ignore -v dist-ios-shell/coding/index.html || echo "NOT IGNORED"` +Expected: `.gitignore::dist-ios-shell/ dist-ios-shell/coding/index.html` (경로가 출력되면 무시 규칙이 걸린 것 — `NOT IGNORED`가 출력되면 실패) + +- [ ] **Step 3: Commit** + +```bash +git add .gitignore +git commit -m "chore(ios): dist-ios-shell 빌드 산출물 gitignore 처리" +``` + +--- + +## Task 2: 런처 순수 로직 (`ios/launcher/launcher.js`) + +**Files:** +- Create: `ios/launcher/launcher.js` +- Test: `tests/launcher.test.mjs` + +**Interfaces:** +- Consumes: 없음 (순수 함수, `localStorage`류 storage 객체를 인자로 받음 — DOM 의존 없음) +- Produces: + - `LAST_APP_STORAGE_KEY: string` (값 `'maestro-shell-last-app'`) + - `getLastApp(storage): 'coding' | 'player' | null` + - `setLastApp(storage, appId: 'coding' | 'player'): void` + - `buildLauncherState(lastApp: 'coding' | 'player' | null): { coding: { badge: boolean }, player: { badge: boolean } }` + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/launcher.test.mjs`: + +```javascript +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + LAST_APP_STORAGE_KEY, + getLastApp, + setLastApp, + buildLauncherState, +} from '../ios/launcher/launcher.js'; + +function createFakeStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => { map.set(key, value); }, + }; +} + +test('LAST_APP_STORAGE_KEY는 maestro- 접두사를 쓴다', () => { + assert.equal(LAST_APP_STORAGE_KEY, 'maestro-shell-last-app'); +}); + +test('getLastApp은 저장된 값이 없으면 null을 반환한다', () => { + const storage = createFakeStorage(); + assert.equal(getLastApp(storage), null); +}); + +test('getLastApp은 coding/player가 아닌 값을 무시한다', () => { + const storage = createFakeStorage(); + storage.setItem(LAST_APP_STORAGE_KEY, 'garbage'); + assert.equal(getLastApp(storage), null); +}); + +test('setLastApp으로 저장한 값을 getLastApp이 그대로 읽는다', () => { + const storage = createFakeStorage(); + setLastApp(storage, 'player'); + assert.equal(getLastApp(storage), 'player'); +}); + +test('buildLauncherState는 마지막 선택에만 배지를 켠다', () => { + assert.deepEqual(buildLauncherState(null), { + coding: { badge: false }, + player: { badge: false }, + }); + assert.deepEqual(buildLauncherState('coding'), { + coding: { badge: true }, + player: { badge: false }, + }); + assert.deepEqual(buildLauncherState('player'), { + coding: { badge: false }, + player: { badge: true }, + }); +}); +``` + +- [ ] **Step 2: 테스트가 실패하는지 확인** + +Run: `node --test tests/launcher.test.mjs` +Expected: FAIL — `Cannot find module '../ios/launcher/launcher.js'` + +- [ ] **Step 3: 최소 구현 작성** + +`ios/launcher/launcher.js`: + +```javascript +// iOS 셸 런처의 순수 로직 — DOM 의존 없음, storage 인터페이스(getItem/setItem)만 받는다. +export const LAST_APP_STORAGE_KEY = 'maestro-shell-last-app'; + +export function getLastApp(storage) { + const value = storage.getItem(LAST_APP_STORAGE_KEY); + return value === 'coding' || value === 'player' ? value : null; +} + +export function setLastApp(storage, appId) { + storage.setItem(LAST_APP_STORAGE_KEY, appId); +} + +export function buildLauncherState(lastApp) { + return { + coding: { badge: lastApp === 'coding' }, + player: { badge: lastApp === 'player' }, + }; +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `node --test tests/launcher.test.mjs` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ios/launcher/launcher.js tests/launcher.test.mjs +git commit -m "feat(ios): 런처 순수 로직(마지막 선택 저장/조회)" +``` + +--- + +## Task 3: 런처 정적 페이지 (`ios/launcher/index.html`) + +**Files:** +- Create: `ios/launcher/index.html` +- Test: `tests/launcher.test.mjs` (Task 2 파일에 케이스 추가) + +**Interfaces:** +- Consumes: Task 2의 `LAST_APP_STORAGE_KEY`, `getLastApp`, `setLastApp`, `buildLauncherState` (`./launcher.js`를 ES module로 import) +- Produces: 정적 HTML 파일 — 이후 Task 4의 빌드 스크립트가 그대로 복사 + +- [ ] **Step 1: 실패하는 테스트 추가** + +`tests/launcher.test.mjs` 파일 맨 위 import 블록에 추가: + +```javascript +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +``` + +그리고 파일 끝에 다음 테스트를 추가: + +```javascript +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +test('launcher index.html은 Coding/Player 버튼과 launcher.js import를 포함한다', () => { + const html = readFileSync(resolve(ROOT_DIR, 'ios/launcher/index.html'), 'utf8'); + assert.match(html, /data-app="coding"/); + assert.match(html, /data-app="player"/); + assert.match(html, /from\s+['"]\.\/launcher\.js['"]/); + assert.match(html, /coding\/index\.html/); + assert.match(html, /player\/index\.html/); +}); +``` + +- [ ] **Step 2: 테스트가 실패하는지 확인** + +Run: `node --test tests/launcher.test.mjs` +Expected: FAIL — `ENOENT: no such file or directory ... ios/launcher/index.html` + +- [ ] **Step 3: 최소 구현 작성** + +`ios/launcher/index.html`: + +```html + + + + + + Maestro + + + +

Maestro

+
+ + +
+ + + +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `node --test tests/launcher.test.mjs` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ios/launcher/index.html tests/launcher.test.mjs +git commit -m "feat(ios): 런처 정적 페이지 (Coding/Player 선택 화면)" +``` + +--- + +## Task 4: iOS 셸 빌드 스크립트 + Capacitor 설정 연결 + +**Files:** +- Create: `scripts/build-ios-shell.mjs` +- Modify: `capacitor.config.json` +- Modify: `package.json` (`ios:build` 스크립트) +- Test: `tests/build-ios-shell.test.mjs` + +**Interfaces:** +- Consumes: Task 3의 `ios/launcher/index.html`(+`launcher.js`), 루트 `vite.config.js`, `player/vite.config.js` +- Produces: `dist-ios-shell/{index.html, launcher.js, coding/, player/}` — 이후 `cap sync ios`가 그대로 반영 + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tests/build-ios-shell.test.mjs`: + +```javascript +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SHELL_DIR = resolve(ROOT_DIR, 'dist-ios-shell'); + +test('build-ios-shell.mjs는 coding/player 빌드와 런처를 하나의 셸로 배치한다', { timeout: 120_000 }, () => { + rmSync(SHELL_DIR, { recursive: true, force: true }); + + execFileSync('node', ['scripts/build-ios-shell.mjs'], { cwd: ROOT_DIR, stdio: 'inherit' }); + + assert.ok(existsSync(resolve(SHELL_DIR, 'index.html')), '런처 index.html 없음'); + assert.ok(existsSync(resolve(SHELL_DIR, 'launcher.js')), '런처 launcher.js 없음'); + assert.ok(existsSync(resolve(SHELL_DIR, 'coding/index.html')), 'coding 빌드 없음'); + assert.ok(existsSync(resolve(SHELL_DIR, 'player/index.html')), 'player 빌드 없음'); + + const codingHtml = readFileSync(resolve(SHELL_DIR, 'coding/index.html'), 'utf8'); + assert.match(codingHtml, /src="\.\/assets\//, 'coding 빌드가 상대 경로(base ./)를 쓰지 않음'); + + const playerHtml = readFileSync(resolve(SHELL_DIR, 'player/index.html'), 'utf8'); + assert.match(playerHtml, /src="\.\/assets\//, 'player 빌드가 상대 경로(base ./)를 쓰지 않음'); + + rmSync(SHELL_DIR, { recursive: true, force: true }); +}); + +test('capacitor.config.json의 webDir은 dist-ios-shell을 가리킨다', () => { + const config = JSON.parse(readFileSync(resolve(ROOT_DIR, 'capacitor.config.json'), 'utf8')); + assert.equal(config.webDir, 'dist-ios-shell'); +}); +``` + +- [ ] **Step 2: 테스트가 실패하는지 확인** + +Run: `node --test tests/build-ios-shell.test.mjs` +Expected: FAIL — `Cannot find module ... scripts/build-ios-shell.mjs` 및 `webDir` assertion 실패(현재값 `"dist"`) + +- [ ] **Step 3: `capacitor.config.json` 수정** + +`capacitor.config.json` 전체를 다음으로 교체: + +```json +{ + "appId": "kr.selim.maestro", + "appName": "Maestro", + "webDir": "dist-ios-shell" +} +``` + +- [ ] **Step 4: 빌드 스크립트 작성** + +`scripts/build-ios-shell.mjs`: + +```javascript +#!/usr/bin/env node +// Coding(루트 앱)·Player 정적 빌드 + 런처를 하나의 iOS 웹뷰 셸로 합친다. +// dist-ios-shell/{index.html, launcher.js, coding/, player/} +// node scripts/build-ios-shell.mjs +import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const shellDir = path.join(rootDir, 'dist-ios-shell'); +const launcherDir = path.join(rootDir, 'ios/launcher'); + +rmSync(shellDir, { recursive: true, force: true }); +mkdirSync(shellDir, { recursive: true }); + +await build({ + configFile: path.join(rootDir, 'vite.config.js'), + base: './', + logLevel: 'warn', + build: { + outDir: path.join(shellDir, 'coding'), + emptyOutDir: true, + }, +}); + +await build({ + configFile: path.join(rootDir, 'player/vite.config.js'), + root: path.join(rootDir, 'player'), + base: './', + logLevel: 'warn', + build: { + outDir: path.join(shellDir, 'player'), + emptyOutDir: true, + }, +}); + +for (const file of ['index.html', 'launcher.js']) { + const src = path.join(launcherDir, file); + if (!existsSync(src)) { + throw new Error(`런처 소스가 없습니다: ${src}`); + } + cpSync(src, path.join(shellDir, file)); +} + +console.log(`iOS 셸 빌드 완료: ${path.relative(rootDir, shellDir)}/{index.html, launcher.js, coding/, player/}`); +``` + +- [ ] **Step 5: 테스트 통과 확인** + +Run: `node --test tests/build-ios-shell.test.mjs` +Expected: PASS (2 tests) — 빌드 2회 실행으로 수십 초 걸릴 수 있음 + +- [ ] **Step 6: `package.json`의 `ios:build` 갱신** + +`package.json`에서: + +```json + "ios:build": "CAPACITOR_BUILD=1 vite build && cap sync ios", +``` + +를 다음으로 교체: + +```json + "ios:build": "node scripts/build-ios-shell.mjs && cap sync ios", +``` + +- [ ] **Step 7: Commit** + +```bash +git add scripts/build-ios-shell.mjs capacitor.config.json package.json tests/build-ios-shell.test.mjs +git commit -m "feat(ios): coding+player 빌드를 하나의 iOS 셸로 합치는 스크립트" +``` + +--- + +## Task 5: Player 네이티브 셸 감지 유틸 + +**Files:** +- Create: `player/src/lib/nativeShell.js` +- Test: `player/src/lib/nativeShell.test.js` + +**Interfaces:** +- Consumes: `window.Capacitor?.isNativePlatform?.()` (전역, 신규 의존성 없음 — 루트 앱 `src/utils/server-address.js`의 `isNativeShell` 패턴과 동일) +- Produces: `isNativeShell(): boolean` + +- [ ] **Step 1: 실패하는 테스트 작성** + +`player/src/lib/nativeShell.test.js`: + +```javascript +import { afterEach, describe, expect, test } from 'vitest'; +import { isNativeShell } from './nativeShell.js'; + +describe('isNativeShell', () => { + afterEach(() => { + delete window.Capacitor; + }); + + test('Capacitor 전역이 없으면 false', () => { + expect(isNativeShell()).toBe(false); + }); + + test('Capacitor.isNativePlatform()이 false면 false', () => { + window.Capacitor = { isNativePlatform: () => false }; + expect(isNativeShell()).toBe(false); + }); + + test('Capacitor.isNativePlatform()이 true면 true', () => { + window.Capacitor = { isNativePlatform: () => true }; + expect(isNativeShell()).toBe(true); + }); +}); +``` + +- [ ] **Step 2: 테스트가 실패하는지 확인** + +Run: `cd player && npx vitest run src/lib/nativeShell.test.js` +Expected: FAIL — `Cannot find module './nativeShell.js'` + +- [ ] **Step 3: 최소 구현 작성** + +`player/src/lib/nativeShell.js`: + +```javascript +// Capacitor 네이티브 셸(iOS 런처) 감지 — 전역 브릿지만 확인, 신규 의존성 없음. +// 루트 앱 src/utils/server-address.js의 isNativeShell과 동일한 패턴. +export const isNativeShell = () => ( + typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.() === true +); +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `cd player && npx vitest run src/lib/nativeShell.test.js` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add player/src/lib/nativeShell.js player/src/lib/nativeShell.test.js +git commit -m "feat(player): 네이티브 셸 감지 유틸" +``` + +--- + +## Task 6: Player 헤더에 "전환" 버튼 추가 + +**Files:** +- Modify: `player/src/App.jsx` (헤더의 `player-hero__controls` 블록, 대략 393-414행) +- Modify: `player/src/styles.css` (`.player-language-switch__button.is-active` 블록 뒤에 추가, 대략 151행) +- Test: `player/src/App.ui.test.jsx` (기존 파일에 케이스 추가) + +**Interfaces:** +- Consumes: Task 5의 `isNativeShell()` (`./lib/nativeShell.js`) +- Produces: 없음 (leaf UI) + +- [ ] **Step 1: 실패하는 테스트 추가** + +`player/src/App.ui.test.jsx`의 `describeIfApp('Player Shell UI', ...)` 블록 안에 케이스 추가 (기존 `afterEach`가 이미 `window.Capacitor`를 정리하지 않으므로, 이 테스트 안에서 직접 정리한다): + +```javascript + test('네이티브 셸에서만 전환 버튼이 보인다', () => { + window.Capacitor = { isNativePlatform: () => true }; + renderPlayerApp(App); + + expect(screen.getByRole('button', { name: 'Coding으로 전환' })).toBeVisible(); + + delete window.Capacitor; + }); + + test('웹/확장 배포(Capacitor 없음)에서는 전환 버튼이 없다', () => { + renderPlayerApp(App); + expect(screen.queryByRole('button', { name: 'Coding으로 전환' })).toBeNull(); + }); +``` + +(주의: 클릭 시 `window.location.href` 변경 자체는 jsdom이 실제 네비게이션을 +구현하지 않아 — `location`은 재정의도 막혀 있어 — 단위 테스트로 안정적으로 +검증할 수 없다. 버튼 노출 조건만 자동화하고, 실제 이동은 Task 8의 실기기 +수동 검증에서 확인한다.) + +- [ ] **Step 2: 테스트가 실패하는지 확인** + +Run: `cd player && npx vitest run src/App.ui.test.jsx` +Expected: FAIL — `Unable to find role="button" and name "Coding으로 전환"` + +- [ ] **Step 3: `styles.css`에 버튼 스타일 추가** + +`player/src/styles.css`의 `.player-language-switch__button.is-active { ... }` 블록(대략 148-151행) 바로 뒤에 추가: + +```css +.player-switch-app { + border: 1px solid var(--player-panel-border); + border-radius: 999px; + padding: 0.4rem 0.85rem; + background: rgba(2, 6, 23, 0.4); + color: var(--player-text-dim); + font-size: 0.78rem; + cursor: pointer; + transition: color 160ms ease, border-color 160ms ease; +} + +.player-switch-app:hover { + color: var(--player-text); + border-color: var(--player-violet); +} +``` + +- [ ] **Step 4: `App.jsx` 수정** + +import 블록(파일 상단, 다른 `./lib/*` import들 근처)에 추가: + +```javascript +import { isNativeShell } from './lib/nativeShell.js'; +``` + +`
` 블록(대략 395행) 맨 앞에 버튼 추가: + +```jsx +
+ {isNativeShell() && ( + + )} + {copy.languageLabel} +``` + +(주의: 버튼 `aria-label`은 별도로 두지 않는다 — 버튼 텍스트 자체가 "Coding으로 전환"을 포함해 접근성 이름이 그대로 일치한다.) + +- [ ] **Step 5: 테스트 통과 확인** + +Run: `cd player && npx vitest run src/App.ui.test.jsx` +Expected: PASS (기존 케이스 포함 전체) + +- [ ] **Step 6: Commit** + +```bash +git add player/src/App.jsx player/src/styles.css player/src/App.ui.test.jsx +git commit -m "feat(player): 네이티브 셸에서 Coding 전환 버튼 노출" +``` + +--- + +## Task 7: Coding(루트) 헤더에 "전환" 버튼 추가 + +**Files:** +- Modify: `src/components/maestro/MaestroHeader.jsx` (우측 컨트롤 그룹, 대략 392-455행) +- Test: `src/App.native-shell-switch.ui.test.jsx` (신규) + +**Interfaces:** +- Consumes: 기존 `isNativeShell` (`../../utils/server-address.js`, 이미 export되어 있음 — 신규 코드 없음) +- Produces: 없음 (leaf UI) + +- [ ] **Step 1: 실패하는 테스트 작성** + +`src/App.native-shell-switch.ui.test.jsx`: + +```javascript +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import App from './App.jsx'; +import { + setupAppUiEnvironment, + teardownAppUiEnvironment, +} from './test/appUiHarness.jsx'; + +describe('App UI - native shell 전환 버튼', () => { + beforeEach(() => { + setupAppUiEnvironment(); + }); + + afterEach(() => { + teardownAppUiEnvironment(); + delete window.Capacitor; + }); + + test('네이티브 셸에서는 Player 전환 버튼이 보인다', () => { + window.Capacitor = { isNativePlatform: () => true }; + render(); + + expect(screen.getByRole('button', { name: 'Player로 전환' })).toBeVisible(); + }); + + test('웹 배포(Capacitor 없음)에서는 전환 버튼이 없다', () => { + render(); + expect(screen.queryByRole('button', { name: 'Player로 전환' })).toBeNull(); + }); +}); +``` + +(주의: 클릭 시 `window.location.href` 변경 자체는 jsdom이 실제 네비게이션을 +구현하지 않아 단위 테스트로 안정적으로 검증할 수 없다. 버튼 노출 조건만 +자동화하고, 실제 이동은 Task 8의 실기기 수동 검증에서 확인한다.) + +- [ ] **Step 2: 테스트가 실패하는지 확인** + +Run: `npx vitest run src/App.native-shell-switch.ui.test.jsx` +Expected: FAIL — `Unable to find role="button" and name "Player로 전환"` + +- [ ] **Step 3: `MaestroHeader.jsx` 수정** + +import 블록(파일 상단, 2-3행 근처)에 추가: + +```javascript +import { isNativeShell } from '../../utils/server-address.js'; +``` + +우측 컨트롤 그룹 `
`(대략 392행) 맨 앞에 버튼 추가: + +```jsx +
+ {isNativeShell() && ( + + )} +
+``` + +(기존 `
`로 시작하던 "Merged PRs" 블록은 그대로 유지 — 전환 버튼만 그 앞에 추가한다.) + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `npx vitest run src/App.native-shell-switch.ui.test.jsx` +Expected: PASS (2 tests) + +- [ ] **Step 5: 회귀 확인** + +Run: `npm run test:ui` +Expected: 기존 UI 테스트 전체 PASS (헤더 구조 변경이 다른 테스트를 깨지 않았는지 확인) + +- [ ] **Step 6: Commit** + +```bash +git add src/components/maestro/MaestroHeader.jsx src/App.native-shell-switch.ui.test.jsx +git commit -m "feat(ios): Coding 헤더에 Player 전환 버튼 노출" +``` + +--- + +## Task 8: 실기기 수동 검증 + +**Files:** 없음 (검증 전용 태스크) + +**Interfaces:** +- Consumes: Task 1-7의 모든 산출물 +- Produces: 없음 + +- [ ] **Step 1: 전체 자동 테스트 재확인** + +```bash +npm test +(cd player && npm run qa) +``` + +Expected: 둘 다 PASS + +- [ ] **Step 2: iOS 빌드** + +```bash +npm run ios:build +``` + +Expected: `iOS 셸 빌드 완료: dist-ios-shell/{index.html, launcher.js, coding/, player/}` 출력 후 `cap sync ios` 성공 로그 + +- [ ] **Step 3: Xcode 빌드 + 아이패드 설치** + +이 세션에서 이미 검증한 방식과 동일: + +```bash +cd ios/App +xcodebuild build \ + -project App.xcodeproj -scheme App -configuration Debug \ + -destination "id=<아이패드 UDID>" \ + -allowProvisioningUpdates -allowProvisioningDeviceRegistration \ + DEVELOPMENT_TEAM=X34AHRFTK3 +xcrun devicectl device install app --device <아이패드 device id> +xcrun devicectl device process launch --device <아이패드 device id> kr.selim.maestro +``` + +- [ ] **Step 4: 수동 확인 체크리스트** + +1. 앱 실행 시 런처(Coding/Player 버튼)가 먼저 뜨는지. +2. "Coding" 선택 → 기존 기능(서버 주소 설정, 헤더 등) 정상 동작 확인. +3. 헤더의 "⇄ Player로 전환" 클릭 → 런처로 돌아가는지. +4. "Player" 선택 → 공개 GitHub URL 입력 → 리플레이 재생까지 확인. +5. Player 헤더의 "⇄ Coding으로 전환" 클릭 → 런처로 돌아가는지. +6. 런처에서 마지막 선택한 항목에 "마지막 사용" 배지가 뜨는지. + +- [ ] **Step 5: 증거 기록** + +`docs/maestro-player/goal-roadmap.md` 또는 새 evidence 파일에 스크린샷/확인 결과를 남긴다 (기존 `docs/maestro-player/evidence/` 디렉토리 관례 참고). 이 스텝은 실기기 접근 권한이 있는 사람이 직접 수행한다. From 6e4519eb215029396890a745af801dd538aa7e58 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Fri, 14 Aug 2026 18:19:47 +0900 Subject: [PATCH 02/12] =?UTF-8?q?chore(ios):=20dist-ios-shell=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=EC=82=B0=EC=B6=9C=EB=AC=BC=20gitignore=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 217b252..40219fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +dist-ios-shell/ .env .env.local .env.*.local From b23caa7a974cd5a3fa113950dff7834313423787 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Fri, 14 Aug 2026 18:21:42 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat(ios):=20=EB=9F=B0=EC=B2=98=20?= =?UTF-8?q?=EC=88=9C=EC=88=98=20=EB=A1=9C=EC=A7=81(=EB=A7=88=EC=A7=80?= =?UTF-8?q?=EB=A7=89=20=EC=84=A0=ED=83=9D=20=EC=A0=80=EC=9E=A5/=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- ios/launcher/launcher.js | 18 ++++++++++++++ tests/launcher.test.mjs | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 ios/launcher/launcher.js create mode 100644 tests/launcher.test.mjs diff --git a/ios/launcher/launcher.js b/ios/launcher/launcher.js new file mode 100644 index 0000000..fc7296c --- /dev/null +++ b/ios/launcher/launcher.js @@ -0,0 +1,18 @@ +// iOS 셸 런처의 순수 로직 — DOM 의존 없음, storage 인터페이스(getItem/setItem)만 받는다. +export const LAST_APP_STORAGE_KEY = 'maestro-shell-last-app'; + +export function getLastApp(storage) { + const value = storage.getItem(LAST_APP_STORAGE_KEY); + return value === 'coding' || value === 'player' ? value : null; +} + +export function setLastApp(storage, appId) { + storage.setItem(LAST_APP_STORAGE_KEY, appId); +} + +export function buildLauncherState(lastApp) { + return { + coding: { badge: lastApp === 'coding' }, + player: { badge: lastApp === 'player' }, + }; +} diff --git a/tests/launcher.test.mjs b/tests/launcher.test.mjs new file mode 100644 index 0000000..999a85c --- /dev/null +++ b/tests/launcher.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + LAST_APP_STORAGE_KEY, + getLastApp, + setLastApp, + buildLauncherState, +} from '../ios/launcher/launcher.js'; + +function createFakeStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => { map.set(key, value); }, + }; +} + +test('LAST_APP_STORAGE_KEY는 maestro- 접두사를 쓴다', () => { + assert.equal(LAST_APP_STORAGE_KEY, 'maestro-shell-last-app'); +}); + +test('getLastApp은 저장된 값이 없으면 null을 반환한다', () => { + const storage = createFakeStorage(); + assert.equal(getLastApp(storage), null); +}); + +test('getLastApp은 coding/player가 아닌 값을 무시한다', () => { + const storage = createFakeStorage(); + storage.setItem(LAST_APP_STORAGE_KEY, 'garbage'); + assert.equal(getLastApp(storage), null); +}); + +test('setLastApp으로 저장한 값을 getLastApp이 그대로 읽는다', () => { + const storage = createFakeStorage(); + setLastApp(storage, 'player'); + assert.equal(getLastApp(storage), 'player'); +}); + +test('buildLauncherState는 마지막 선택에만 배지를 켠다', () => { + assert.deepEqual(buildLauncherState(null), { + coding: { badge: false }, + player: { badge: false }, + }); + assert.deepEqual(buildLauncherState('coding'), { + coding: { badge: true }, + player: { badge: false }, + }); + assert.deepEqual(buildLauncherState('player'), { + coding: { badge: false }, + player: { badge: true }, + }); +}); From 0b5f6a30c84005207583f441c17c85cc081ea113 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Fri, 14 Aug 2026 18:24:42 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat(ios):=20=EB=9F=B0=EC=B2=98=20?= =?UTF-8?q?=EC=A0=95=EC=A0=81=20=ED=8E=98=EC=9D=B4=EC=A7=80=20(Coding/Play?= =?UTF-8?q?er=20=EC=84=A0=ED=83=9D=20=ED=99=94=EB=A9=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/launcher/index.html | 111 ++++++++++++++++++++++++++++++++++++++++ tests/launcher.test.mjs | 14 +++++ 2 files changed, 125 insertions(+) create mode 100644 ios/launcher/index.html diff --git a/ios/launcher/index.html b/ios/launcher/index.html new file mode 100644 index 0000000..db23003 --- /dev/null +++ b/ios/launcher/index.html @@ -0,0 +1,111 @@ + + + + + + Maestro + + + +

Maestro

+
+ + +
+ + + diff --git a/tests/launcher.test.mjs b/tests/launcher.test.mjs index 999a85c..8a77c11 100644 --- a/tests/launcher.test.mjs +++ b/tests/launcher.test.mjs @@ -1,5 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { LAST_APP_STORAGE_KEY, getLastApp, @@ -50,3 +53,14 @@ test('buildLauncherState는 마지막 선택에만 배지를 켠다', () => { player: { badge: true }, }); }); + +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +test('launcher index.html은 Coding/Player 버튼과 launcher.js import를 포함한다', () => { + const html = readFileSync(resolve(ROOT_DIR, 'ios/launcher/index.html'), 'utf8'); + assert.match(html, /data-app="coding"/); + assert.match(html, /data-app="player"/); + assert.match(html, /from\s+['"]\.\/launcher\.js['"]/); + assert.match(html, /coding\/index\.html/); + assert.match(html, /player\/index\.html/); +}); From 4ea6350884d54e31d215bf22115061662e2351c2 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Fri, 14 Aug 2026 18:28:15 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat(ios):=20coding+player=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=ED=95=98=EB=82=98=EC=9D=98=20iOS=20?= =?UTF-8?q?=EC=85=B8=EB=A1=9C=20=ED=95=A9=EC=B9=98=EB=8A=94=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- capacitor.config.json | 2 +- package.json | 2 +- scripts/build-ios-shell.mjs | 46 ++++++++++++++++++++++++++++++++++ tests/build-ios-shell.test.mjs | 33 ++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 scripts/build-ios-shell.mjs create mode 100644 tests/build-ios-shell.test.mjs diff --git a/capacitor.config.json b/capacitor.config.json index e6200d7..d63542d 100644 --- a/capacitor.config.json +++ b/capacitor.config.json @@ -1,5 +1,5 @@ { "appId": "kr.selim.maestro", "appName": "Maestro", - "webDir": "dist" + "webDir": "dist-ios-shell" } diff --git a/package.json b/package.json index b76b55c..857f404 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "qa": "bash scripts/qa-agent.sh", "setup": "sh scripts/setup_env.sh", "install:hook": "node scripts/install-maestro-hook.mjs --target=all", - "ios:build": "CAPACITOR_BUILD=1 vite build && cap sync ios", + "ios:build": "node scripts/build-ios-shell.mjs && cap sync ios", "ios:open": "cap open ios", "ios:run": "npm run ios:build && cap run ios", "configure": "node scripts/configure.js", diff --git a/scripts/build-ios-shell.mjs b/scripts/build-ios-shell.mjs new file mode 100644 index 0000000..3d8b890 --- /dev/null +++ b/scripts/build-ios-shell.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// Coding(루트 앱)·Player 정적 빌드 + 런처를 하나의 iOS 웹뷰 셸로 합친다. +// dist-ios-shell/{index.html, launcher.js, coding/, player/} +// node scripts/build-ios-shell.mjs +import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const shellDir = path.join(rootDir, 'dist-ios-shell'); +const launcherDir = path.join(rootDir, 'ios/launcher'); + +rmSync(shellDir, { recursive: true, force: true }); +mkdirSync(shellDir, { recursive: true }); + +await build({ + configFile: path.join(rootDir, 'vite.config.js'), + base: './', + logLevel: 'warn', + build: { + outDir: path.join(shellDir, 'coding'), + emptyOutDir: true, + }, +}); + +await build({ + configFile: path.join(rootDir, 'player/vite.config.js'), + root: path.join(rootDir, 'player'), + base: './', + logLevel: 'warn', + build: { + outDir: path.join(shellDir, 'player'), + emptyOutDir: true, + }, +}); + +for (const file of ['index.html', 'launcher.js']) { + const src = path.join(launcherDir, file); + if (!existsSync(src)) { + throw new Error(`런처 소스가 없습니다: ${src}`); + } + cpSync(src, path.join(shellDir, file)); +} + +console.log(`iOS 셸 빌드 완료: ${path.relative(rootDir, shellDir)}/{index.html, launcher.js, coding/, player/}`); diff --git a/tests/build-ios-shell.test.mjs b/tests/build-ios-shell.test.mjs new file mode 100644 index 0000000..ea0bd4a --- /dev/null +++ b/tests/build-ios-shell.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SHELL_DIR = resolve(ROOT_DIR, 'dist-ios-shell'); + +test('build-ios-shell.mjs는 coding/player 빌드와 런처를 하나의 셸로 배치한다', { timeout: 120_000 }, () => { + rmSync(SHELL_DIR, { recursive: true, force: true }); + + execFileSync('node', ['scripts/build-ios-shell.mjs'], { cwd: ROOT_DIR, stdio: 'inherit' }); + + assert.ok(existsSync(resolve(SHELL_DIR, 'index.html')), '런처 index.html 없음'); + assert.ok(existsSync(resolve(SHELL_DIR, 'launcher.js')), '런처 launcher.js 없음'); + assert.ok(existsSync(resolve(SHELL_DIR, 'coding/index.html')), 'coding 빌드 없음'); + assert.ok(existsSync(resolve(SHELL_DIR, 'player/index.html')), 'player 빌드 없음'); + + const codingHtml = readFileSync(resolve(SHELL_DIR, 'coding/index.html'), 'utf8'); + assert.match(codingHtml, /src="\.\/assets\//, 'coding 빌드가 상대 경로(base ./)를 쓰지 않음'); + + const playerHtml = readFileSync(resolve(SHELL_DIR, 'player/index.html'), 'utf8'); + assert.match(playerHtml, /src="\.\/assets\//, 'player 빌드가 상대 경로(base ./)를 쓰지 않음'); + + rmSync(SHELL_DIR, { recursive: true, force: true }); +}); + +test('capacitor.config.json의 webDir은 dist-ios-shell을 가리킨다', () => { + const config = JSON.parse(readFileSync(resolve(ROOT_DIR, 'capacitor.config.json'), 'utf8')); + assert.equal(config.webDir, 'dist-ios-shell'); +}); From 408f6d4fe36aca4440aeab32c5ce965ffb1665d9 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Fri, 14 Aug 2026 18:32:02 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat(player):=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=ED=8B=B0=EB=B8=8C=20=EC=85=B8=20=EA=B0=90=EC=A7=80=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- player/src/lib/nativeShell.js | 5 +++++ player/src/lib/nativeShell.test.js | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 player/src/lib/nativeShell.js create mode 100644 player/src/lib/nativeShell.test.js diff --git a/player/src/lib/nativeShell.js b/player/src/lib/nativeShell.js new file mode 100644 index 0000000..74731bc --- /dev/null +++ b/player/src/lib/nativeShell.js @@ -0,0 +1,5 @@ +// Capacitor 네이티브 셸(iOS 런처) 감지 — 전역 브릿지만 확인, 신규 의존성 없음. +// 루트 앱 src/utils/server-address.js의 isNativeShell과 동일한 패턴. +export const isNativeShell = () => ( + typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.() === true +); diff --git a/player/src/lib/nativeShell.test.js b/player/src/lib/nativeShell.test.js new file mode 100644 index 0000000..b5c1eb9 --- /dev/null +++ b/player/src/lib/nativeShell.test.js @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { isNativeShell } from './nativeShell.js'; + +describe('isNativeShell', () => { + afterEach(() => { + delete window.Capacitor; + }); + + test('Capacitor 전역이 없으면 false', () => { + expect(isNativeShell()).toBe(false); + }); + + test('Capacitor.isNativePlatform()이 false면 false', () => { + window.Capacitor = { isNativePlatform: () => false }; + expect(isNativeShell()).toBe(false); + }); + + test('Capacitor.isNativePlatform()이 true면 true', () => { + window.Capacitor = { isNativePlatform: () => true }; + expect(isNativeShell()).toBe(true); + }); +}); From fb992a43841efc4ebd7447d9c1c9ad12432c1f1e Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Fri, 14 Aug 2026 18:36:33 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat(player):=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=ED=8B=B0=EB=B8=8C=20=EC=85=B8=EC=97=90=EC=84=9C=20Coding=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EB=B2=84=ED=8A=BC=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isNativeShell()이 true일 때만 player-hero__controls에 "Coding으로 전환" 버튼을 표시한다. 화살표 글리프는 aria-hidden으로 감춰 접근성 이름이 정확히 "Coding으로 전환"과 일치하도록 했다 (버튼 텍스트를 그대로 버튼명으로 쓰는 브리프 의도 유지). --- player/src/App.jsx | 10 ++++++++++ player/src/App.ui.test.jsx | 14 ++++++++++++++ player/src/styles.css | 16 ++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/player/src/App.jsx b/player/src/App.jsx index 68dfdff..cf039fa 100644 --- a/player/src/App.jsx +++ b/player/src/App.jsx @@ -23,6 +23,7 @@ import { loadPublicRepoReplayEvents, createPublicRepoSource } from './lib/public import { buildMusicPlan } from './lib/musicIntentMapper.js'; import { buildGoldenListeningPackEntries, buildGoldenListeningSource } from './lib/goldenListeningPack.js'; import { registerLocalRepoSource } from './lib/sourceRegistry.js'; +import { isNativeShell } from './lib/nativeShell.js'; import './styles.css'; const INITIAL_DRAFTS = { @@ -393,6 +394,15 @@ export default function App({ bootstrap = null }) {

{renderHeroTitle(copy, language)}

+ {isNativeShell() && ( + + )} {copy.languageLabel}
{PLAYER_LANGUAGES.map((item) => { diff --git a/player/src/App.ui.test.jsx b/player/src/App.ui.test.jsx index 93b9b66..1c6427b 100644 --- a/player/src/App.ui.test.jsx +++ b/player/src/App.ui.test.jsx @@ -448,6 +448,20 @@ describeIfApp('Player Shell UI', () => { await user.click(screen.getByText('Source guide')); expect(screen.getByRole('heading', { name: 'Choose the right input path' })).toBeVisible(); }); + + test('네이티브 셸에서만 전환 버튼이 보인다', () => { + window.Capacitor = { isNativePlatform: () => true }; + renderPlayerApp(App); + + expect(screen.getByRole('button', { name: 'Coding으로 전환' })).toBeVisible(); + + delete window.Capacitor; + }); + + test('웹/확장 배포(Capacitor 없음)에서는 전환 버튼이 없다', () => { + renderPlayerApp(App); + expect(screen.queryByRole('button', { name: 'Coding으로 전환' })).toBeNull(); + }); }); async function openDeckTab(user, name) { diff --git a/player/src/styles.css b/player/src/styles.css index 59e264f..7f01b53 100644 --- a/player/src/styles.css +++ b/player/src/styles.css @@ -150,6 +150,22 @@ textarea { color: var(--player-text); } +.player-switch-app { + border: 1px solid var(--player-panel-border); + border-radius: 999px; + padding: 0.4rem 0.85rem; + background: rgba(2, 6, 23, 0.4); + color: var(--player-text-dim); + font-size: 0.78rem; + cursor: pointer; + transition: color 160ms ease, border-color 160ms ease; +} + +.player-switch-app:hover { + color: var(--player-text); + border-color: var(--player-violet); +} + .player-pill { display: inline-flex; align-items: center; From df8289b700b0cd3cf1eb6b860595ed4e233b2b62 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Fri, 14 Aug 2026 18:41:10 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat(ios):=20Coding=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=EC=97=90=20Player=20=EC=A0=84=ED=99=98=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isNativeShell()이 true일 때만 우측 컨트롤 그룹 맨 앞에 "Player로 전환" 버튼을 표시한다. Task 6(Player 헤더)과 동일하게 화살표 글리프를 aria-hidden으로 감춰 접근성 이름이 정확히 "Player로 전환"과 일치하도록 했다. --- src/App.native-shell-switch.ui.test.jsx | 30 ++++++++++++++++++++++++ src/components/maestro/MaestroHeader.jsx | 10 ++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/App.native-shell-switch.ui.test.jsx diff --git a/src/App.native-shell-switch.ui.test.jsx b/src/App.native-shell-switch.ui.test.jsx new file mode 100644 index 0000000..aaf68b0 --- /dev/null +++ b/src/App.native-shell-switch.ui.test.jsx @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import App from './App.jsx'; +import { + setupAppUiEnvironment, + teardownAppUiEnvironment, +} from './test/appUiHarness.jsx'; + +describe('App UI - native shell 전환 버튼', () => { + beforeEach(() => { + setupAppUiEnvironment(); + }); + + afterEach(() => { + teardownAppUiEnvironment(); + delete window.Capacitor; + }); + + test('네이티브 셸에서는 Player 전환 버튼이 보인다', () => { + window.Capacitor = { isNativePlatform: () => true }; + render(); + + expect(screen.getByRole('button', { name: 'Player로 전환' })).toBeVisible(); + }); + + test('웹 배포(Capacitor 없음)에서는 전환 버튼이 없다', () => { + render(); + expect(screen.queryByRole('button', { name: 'Player로 전환' })).toBeNull(); + }); +}); diff --git a/src/components/maestro/MaestroHeader.jsx b/src/components/maestro/MaestroHeader.jsx index 8d14853..ac0febe 100644 --- a/src/components/maestro/MaestroHeader.jsx +++ b/src/components/maestro/MaestroHeader.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Activity, Play, Pause, Square, Wifi, WifiOff } from 'lucide-react'; import { isHapticsEnabled, setHapticsEnabled } from '../../utils/haptics.js'; +import { isNativeShell } from '../../utils/server-address.js'; export default function MaestroHeader({ headerRef, @@ -390,6 +391,15 @@ export default function MaestroHeader({
+ {isNativeShell() && ( + + )}
Merged PRs {mergedCount} From a7e360f0636a0fc7e11aeff9eb7839f56e281fe7 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Fri, 14 Aug 2026 19:09:08 +0900 Subject: [PATCH 09/12] fix(ios): exclude iOS shell build test from default CI test/qa glob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/build-ios-shell.test.mjs runs a real Vite build of player/ and requires player/node_modules, which the default `npm test`/`npm run qa` chain (and thus qa-gate.yml's qa job and deploy.yml's QA gate step) never installs. Rename it to .itest.mjs so it's excluded from the tests/*.test.mjs glob, add a dedicated `test:ios-shell` script to run it manually/locally, and add a timeout to its execFileSync call so a wedged build fails instead of hanging past the test's own timeout. Also add a regression assertion that the Coding/Player header "전환" buttons' target path (../index.html) still matches where the launcher lands in the built shell. --- package.json | 1 + ...hell.test.mjs => build-ios-shell.itest.mjs} | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) rename tests/{build-ios-shell.test.mjs => build-ios-shell.itest.mjs} (59%) diff --git a/package.json b/package.json index 857f404..cad1426 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "start:app": "npm run check:env && node scripts/start-app.mjs", "test": "npm run test:server && npm run test:ui", "test:server": "node --test tests/*.test.mjs", + "test:ios-shell": "node --test tests/build-ios-shell.itest.mjs", "test:ui": "vitest run --passWithNoTests", "test:e2e": "playwright test", "smoke:integration": "bash scripts/smoke-agent-integration.sh", diff --git a/tests/build-ios-shell.test.mjs b/tests/build-ios-shell.itest.mjs similarity index 59% rename from tests/build-ios-shell.test.mjs rename to tests/build-ios-shell.itest.mjs index ea0bd4a..0b57252 100644 --- a/tests/build-ios-shell.test.mjs +++ b/tests/build-ios-shell.itest.mjs @@ -1,3 +1,6 @@ +// 이 테스트는 player/를 Vite로 실제 빌드하며 player/node_modules 설치가 필요하다. +// 기본 `npm test`/`npm run qa` 체인은 이를 보장하지 않으므로(별도의 player-app CI 잡에서만 설치), +// tests/*.test.mjs 글롭에서 제외되도록 .itest.mjs 확장자를 쓰고 `npm run test:ios-shell`로 수동 실행한다. import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; @@ -11,7 +14,12 @@ const SHELL_DIR = resolve(ROOT_DIR, 'dist-ios-shell'); test('build-ios-shell.mjs는 coding/player 빌드와 런처를 하나의 셸로 배치한다', { timeout: 120_000 }, () => { rmSync(SHELL_DIR, { recursive: true, force: true }); - execFileSync('node', ['scripts/build-ios-shell.mjs'], { cwd: ROOT_DIR, stdio: 'inherit' }); + execFileSync('node', ['scripts/build-ios-shell.mjs'], { + cwd: ROOT_DIR, + stdio: 'inherit', + timeout: 120_000, + killSignal: 'SIGKILL', + }); assert.ok(existsSync(resolve(SHELL_DIR, 'index.html')), '런처 index.html 없음'); assert.ok(existsSync(resolve(SHELL_DIR, 'launcher.js')), '런처 launcher.js 없음'); @@ -31,3 +39,11 @@ test('capacitor.config.json의 webDir은 dist-ios-shell을 가리킨다', () => const config = JSON.parse(readFileSync(resolve(ROOT_DIR, 'capacitor.config.json'), 'utf8')); assert.equal(config.webDir, 'dist-ios-shell'); }); + +test('헤더 전환 버튼은 빌드된 셸의 런처 경로(../index.html)를 참조한다', () => { + const codingSource = readFileSync(resolve(ROOT_DIR, 'src/components/maestro/MaestroHeader.jsx'), 'utf8'); + assert.match(codingSource, /['"]\.\.\/index\.html['"]/, 'Coding 헤더의 전환 버튼이 런처 경로(../index.html)를 참조하지 않음'); + + const playerSource = readFileSync(resolve(ROOT_DIR, 'player/src/App.jsx'), 'utf8'); + assert.match(playerSource, /['"]\.\.\/index\.html['"]/, 'Player 헤더의 전환 버튼이 런처 경로(../index.html)를 참조하지 않음'); +}); From b8914d758ca70c49aa75896911150f33deba30d3 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Fri, 14 Aug 2026 19:09:19 +0900 Subject: [PATCH 10/12] fix(ios): harden launcher against localStorage failures, add safe-area padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder ios/launcher/index.html's inline script so both button click listeners attach unconditionally before touching localStorage. Wrap the getLastApp/badge-reading logic and the per-click setLastApp call in their own try/catch blocks so a storage read/write failure (e.g. WKWebView storage restricted or full) only skips the "마지막 사용" badge instead of leaving the picker screen with dead buttons. Also give the launcher's body padding safe-area awareness (env(safe-area-inset-*)) to match the rest of the Capacitor shell, so content doesn't sit under the device's rounded corners/home indicator. --- ios/launcher/index.html | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/ios/launcher/index.html b/ios/launcher/index.html index db23003..07a89e8 100644 --- a/ios/launcher/index.html +++ b/ios/launcher/index.html @@ -21,7 +21,8 @@ align-items: center; justify-content: center; gap: 20px; - padding: 24px; + padding: max(24px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right)) + max(24px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left)); box-sizing: border-box; } h1 { @@ -92,20 +93,30 @@

Maestro

From 8aeb7d88d17ecb7c4a9633b7e5b43693cd4fdff0 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Fri, 14 Aug 2026 19:09:26 +0900 Subject: [PATCH 11/12] test(player): move window.Capacitor cleanup into afterEach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 네이티브 셸에서만 전환 버튼이 보인다 test set window.Capacitor and deleted it as the last line of the test body. If an earlier assertion threw, cleanup never ran and window.Capacitor leaked into the next test. Move the cleanup into the file's existing top-level afterEach, matching the pattern already used in src/App.native-shell-switch.ui.test.jsx. --- player/src/App.ui.test.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/player/src/App.ui.test.jsx b/player/src/App.ui.test.jsx index 1c6427b..e7b254a 100644 --- a/player/src/App.ui.test.jsx +++ b/player/src/App.ui.test.jsx @@ -24,6 +24,7 @@ afterEach(() => { cleanup(); globalThis.localStorage?.clear(); teardownPlayerAppUiEnvironment(); + delete window.Capacitor; }); describeIfApp('Player Shell UI', () => { @@ -454,8 +455,6 @@ describeIfApp('Player Shell UI', () => { renderPlayerApp(App); expect(screen.getByRole('button', { name: 'Coding으로 전환' })).toBeVisible(); - - delete window.Capacitor; }); test('웹/확장 배포(Capacitor 없음)에서는 전환 버튼이 없다', () => { From 2e6acef465f9d3d43f85d54091d425573ca4add7 Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Fri, 14 Aug 2026 19:09:36 +0900 Subject: [PATCH 12/12] docs: correct iOS launcher flow and shipped design in guide/spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USER_GUIDE.md's iOS section still described the pre-launcher flow (server-address screen auto-opening on first launch). Add a launcher subsection explaining the Coding/Player picker appears first and the "⇄ 전환" header button returns to it, and update the manual verification checklist to match. The design spec's §2/§3 still described an abandoned approach (copying into ios/App/App/public/, launcher source at public/launcher/, CAPACITOR_BUILD=1 vite build). Rewrite them to describe what was actually built: dist-ios-shell/ as the Vite output referenced by capacitor.config.json's webDir, and launcher source at ios/launcher/ (kept out of public/ specifically so it doesn't leak into Coding's own bundle). Also correct §6, which implied the iOS Player build exposes only the public-URL mode — in fact it's the same web build as browser/extension, with Local Repo / Connected Account tabs still visible but non-functional on iOS. Documentation only, no code change. --- USER_GUIDE.md | 9 ++- .../2026-08-13-player-ios-launcher-design.md | 60 ++++++++++++------- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 24e2f99..6b5a775 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -189,6 +189,10 @@ npm run ios:run - 첫 실행 시 `Signing & Capabilities`에서 본인 Team을 선택하세요 (bundle id: `kr.selim.maestro`). - 앱이 PC 서버에 처음 연결할 때 iOS가 **로컬 네트워크 접근 권한**을 묻습니다 — 허용해야 연결됩니다. +### 런처 (Coding / Player 선택) + +앱을 실행하면 Coding/Player 중 하나를 고르는 런처 화면이 먼저 뜹니다. 각 앱 헤더의 **"⇄ 전환"** 버튼을 누르면 언제든 런처로 돌아갈 수 있습니다. 마지막으로 선택했던 항목에는 "마지막 사용" 표시가 붙습니다. + ### 서버 연결 - 저장된 주소가 없으면 실행 직후 서버 주소 설정 화면이 자동으로 열립니다. @@ -205,12 +209,15 @@ npm run ios:run ### 실기기 수동 검증 체크리스트 -- [ ] 설치 후 첫 실행 → 서버 주소 설정 화면 자동 오픈 +- [ ] 설치 후 첫 실행 → 런처(Coding/Player 선택) 화면이 먼저 뜬다 +- [ ] "Coding" 선택 → 서버 주소 설정 화면 자동 오픈 - [ ] 로컬 네트워크 권한 팝업 허용 - [ ] (플러그인 탑재 빌드) 주변 서버 찾기 → PC 발견 → 주소 채움 - [ ] 연결 테스트 성공 → 저장 → 헤더 LIVE 표시 - [ ] 승인/반려/롤백 플로우 정상 동작 - [ ] 판정(PERFECT/GREAT/LATE)·콤보 10단위에서 햅틱 체감 +- [ ] 헤더의 "⇄ 전환" 버튼으로 런처로 복귀 가능 +- [ ] "Player" 선택 → 공개 GitHub/GitLab URL 입력 → 리플레이 재생 확인 --- diff --git a/docs/superpowers/specs/2026-08-13-player-ios-launcher-design.md b/docs/superpowers/specs/2026-08-13-player-ios-launcher-design.md index 99af372..47c948a 100644 --- a/docs/superpowers/specs/2026-08-13-player-ios-launcher-design.md +++ b/docs/superpowers/specs/2026-08-13-player-ios-launcher-design.md @@ -33,17 +33,28 @@ Coding/Player를 완전히 분리된 화면 트리로 제공**하는 방향으 ## 2. iOS 웹뷰 디렉토리 구조 ``` -ios/App/App/public/ - index.html ← 런처(신규, 정적 HTML/CSS/JS, React 미사용) +dist-ios-shell/ + index.html ← 런처(정적 HTML, React 미사용) + launcher.js ← 런처 순수 로직(마지막 선택 저장/조회) coding/ ← 루트 앱(src/) vite build 결과물 - player/ ← player/ vite build 결과물 (공개 URL 모드만) + player/ ← player/ vite build 결과물 ``` +`dist-ios-shell/`은 `scripts/build-ios-shell.mjs`가 생성하는 빌드 +산출물 디렉토리이며, `capacitor.config.json`의 `"webDir": +"dist-ios-shell"`이 이를 가리킨다. `cap sync ios`가 이 디렉토리를 +그대로 네이티브 프로젝트로 복사하므로 `ios/App/App/public/`에 수동으로 +파일을 복사하는 단계는 없다. + - Capacitor 웹뷰는 `capacitor://localhost` 단일 origin으로 이 전체를 서빙하므로, `coding/`과 `player/`는 origin이 같아 `localStorage`를 공유한다 (런처의 "마지막 선택" 저장에 사용). -- `player/` 서브트리는 확장/웹 배포와 동일한 빌드(공개 URL 모드만 - 노출)를 그대로 재사용한다 — iOS 전용 빌드 변형을 만들지 않는다. +- `player/` 서브트리는 확장/웹 배포와 동일한 빌드를 그대로 재사용한다 + — iOS 전용 빌드 변형을 만들지 않는다 (자세한 기능 범위는 §6 참고). +- 런처 소스는 `ios/launcher/`(`index.html` + 순수 로직 `launcher.js`)에 + 둔다. `public/launcher/`가 아닌 이유: `public/`은 Vite가 빌드 시 + 그대로 복사하는 정적 디렉토리라서, 여기에 두면 런처 파일이 Coding + 앱 자체의 번들(`dist-ios-shell/coding/`)에도 새어 들어가게 된다. ## 3. 빌드 파이프라인 @@ -51,20 +62,19 @@ ios/App/App/public/ `package.json`의 `ios:build`가 이를 호출하도록 바꾼다. ``` -CAPACITOR_BUILD=1 vite build --outDir dist/coding-tmp -(cd player && vite build --outDir ../dist/player-tmp) -# dist/coding-tmp → ios/App/App/public/coding/ -# dist/player-tmp → ios/App/App/public/player/ -# public/launcher/index.html (신규 정적 파일) → ios/App/App/public/index.html -cap sync ios +vite build --config vite.config.js --base ./ --outDir dist-ios-shell/coding +(cd player && vite build --base ./ --outDir ../dist-ios-shell/player) +# ios/launcher/{index.html, launcher.js} → dist-ios-shell/{index.html, launcher.js} +cap sync ios # capacitor.config.json의 webDir(dist-ios-shell)을 네이티브 프로젝트로 복사 ``` -- 런처의 정적 파일은 `public/launcher/`(신규 디렉토리)에 소스로 두고, - 빌드 스크립트가 그대로 복사한다 (별도 빌드 단계 불필요 — 순수 - HTML/CSS/inline JS). -- `player/vite.config.js`의 base path 설정이 `/player/` 서브경로에서도 - 정상 동작하는지 확인 필요(현재 확장/웹 배포 base와 다를 수 있음 — - 구현 시 점검). +- 런처의 정적 파일은 `ios/launcher/`에 소스로 두고, 빌드 스크립트가 + 그대로 복사한다 (별도 빌드 단계 불필요 — 순수 HTML/CSS/inline JS + + 테스트 가능한 `launcher.js`). +- Coding·Player 두 빌드 모두 `base: './'`로 상대 경로 에셋을 생성해야 + `dist-ios-shell/coding/`, `dist-ios-shell/player/` 서브경로에서 + 정상 동작한다 — `scripts/build-ios-shell.mjs`에서 각 빌드 호출 시 + 이를 지정한다. ## 4. 런처 화면 동작 @@ -95,14 +105,22 @@ cap sync ios ## 6. Player iOS 모드의 기능 범위 -- **공개 URL 모드만** 지원한다 (Chrome 확장과 동일 범위). - - Local Repo 모드: 데스크톱/서버 브리지가 필요해 iOS에 의미 없음 — - 비범위. +- iOS의 Player 화면은 `player/`를 별도로 변형하지 않고 브라우저/확장 + 배포와 **동일한 웹 빌드**를 그대로 공유한다 (계획서상 `player/`의 + 기존 모드 선택 UI를 건드리지 않기로 했기 때문). 따라서 Local Repo / + Connected Account 탭도 화면에는 그대로 노출된다. +- 다만 실제로 동작하는 것은 **공개 URL 모드뿐**이다. + - Local Repo 모드: 데스크톱/서버 브리지가 필요한데 iOS에는 그 + 브리지가 없어, 선택하면 "브리지 필요"/사용 불가 안내로 + 막다른 길이 된다. - Connected Account 모드: OAuth 등 전제조건이 제품 전체 범위에서 - 이미 deferred(G4) — 비범위. + 이미 deferred(G4)라 마찬가지로 사용 불가 안내로 막다른 길이 + 된다. - 공개 URL 모드는 GitHub/GitLab public API를 클라이언트에서 직접 호출 하므로 Maestro 서버 연결이 필요 없다 (Coding과 달리 "서버 주소" 설정 불필요). +- 이는 문서상의 정정일 뿐 코드 변경 사항은 아니다 — Local Repo / + Connected Account 탭을 iOS에서 숨기는 것은 비범위로 남는다. ## 7. 테스트 & 검증 계획