diff --git a/.claude/skills/run-openhwp/SKILL.md b/.claude/skills/run-openhwp/SKILL.md index 60b6269..4eedf57 100644 --- a/.claude/skills/run-openhwp/SKILL.md +++ b/.claude/skills/run-openhwp/SKILL.md @@ -1,79 +1,103 @@ --- name: run-openhwp -description: Build, launch, drive, and screenshot the OpenHWP Deno desktop app. Use to run OpenHWP, start the viewer, take a screenshot, smoke-test the webview UI, or verify the rhwp (HWP/HWPX → SVG) render pipeline. +description: Build, launch, drive, and screenshot the OpenHWP Deno desktop app. Use to run OpenHWP, start the editor, take a screenshot, smoke-test the embedded rhwp-studio editor, or verify the HWP/HWPX edit/save pipeline boots. --- # Run OpenHWP OpenHWP is a Deno **desktop** app (`deno desktop`, CEF/Chromium backend) that is -a thin host shell around the [rhwp](https://github.com/edwardkim/rhwp) WASM -engine. `main.ts` (the host) serves the webview UI (`src/ui/`) over local HTTP -and points a Chromium window at it; the document work (open, render to SVG) all -happens in that webview. +a thin native shell around the full +[rhwp-studio](https://github.com/edwardkim/rhwp) editor (menus, toolbar, tables, +formatting, undo, open/save). The shell lives in `apps/desktop`; it serves the +studio's built bundle (`apps/studio-host/dist`) over local HTTP and points a +Chromium window at it. All document work — open, edit, render, save — happens +inside that webview via the studio's own UI. The desktop window needs a display; a headless container / SSH session has none. So the **primary way to run and observe this app is the driver**, which serves -the same `src/ui/` and drives it with headless Chrome — no display, CEF, or -WindowServer required. It reaches the exact UI + engine the desktop window shows. +the same `apps/studio-host/dist` and drives it with headless Chrome — no +display, CEF, or WindowServer required. It reaches the exact editor the desktop +window shows. **All paths below are relative to the unit root** (the repo root — the directory -with `deno.json` and `main.ts`). `cd` there first. +with the workspace `deno.json`). `cd` there first. ## Prerequisites Verified on macOS (Darwin x86_64) this session: -- **Deno ≥ 2.9** (`deno desktop` was introduced in 2.9). This session: `deno 2.9.3`. +- **Deno ≥ 2.9** (`deno desktop` was introduced in 2.9). This session: + `deno 2.9.3`. ```bash deno --version ``` +- **Node.js + npm** — only to _build_ the studio bundle (Vite); the app itself + needs no Node at runtime. This session: `node v24.14.0`, `npm 11.9.0`. +- **git** — `deno task setup` materializes the pinned upstream via a sparse + partial clone. This session: `git 2.49.0`. - **A Chromium-family browser** for the driver. This session used the installed - Google Chrome at `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` - (the driver's default). Point elsewhere with `OPENHWP_CHROME=/path/to/chrome` - (e.g. a `chromium` binary on Linux). If the path is missing, [Astral](https://jsr.io/@astral/astral) - downloads its own Chromium. -- Network access: the rhwp engine is vendored (`src/ui/vendor/rhwp`), so the app - runs offline; only the driver's own deps (Astral from `jsr:`) fetch on first run. + Google Chrome at + `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` (the driver's + default). Point elsewhere with `OPENHWP_CHROME=/path/to/chrome` (e.g. a + `chromium` binary on Linux). If the path is missing, + [Astral](https://jsr.io/@astral/astral) downloads its own Chromium. +- Network access is needed **only at build time** (npm + the sparse clone). The + built app + the vendored wasm engine run fully offline. -## Build / verify +## Build -No compile step is needed to run the driver, but this fetches deps, writes -`deno.lock`, and type-checks the host (uses the official `deno.desktop` lib -declared in `deno.json` → `compilerOptions.lib`): +The studio bundle (`apps/studio-host/dist`) is **not committed** — build it +once. From the unit root: ```bash -deno check main.ts -deno lint -deno fmt --check +deno task setup # materialize third_party/rhwp (sparse, pinned) — once +deno task build:studio # build the studio → apps/studio-host/dist ``` -## Run — agent path (driver, headless, screenshots) +- `deno task setup` (`scripts/setup-rhwp.ts`) sparse-partial-clones upstream + rhwp into `third_party/rhwp`, pinned to the commit in + `config/rhwp-studio-overrides.json` (~80 MB, not the full 1.1 GB monorepo). It + is idempotent: re-running verifies `HEAD` matches the pin and re-pins if not. +- `deno task build:studio` (`scripts/build-studio.ts`) supplies `pkg/` from the + committed `apps/studio-host/vendor/rhwp-core` (so the Rust/wasm-pack step is + skipped), disables the PWA service worker, drops the bundled sample docs, runs + the upstream Vite build with `--base=/`, and restores the upstream tree. + Output is a ~48 MB self-contained `apps/studio-host/dist` (no `sw.js`). -This is the path to use. From the unit root: +Type-check the shell (uses the official `deno.desktop` lib declared in +`apps/desktop/deno.json` → `compilerOptions.lib`): + +```bash +deno task check +``` + +## Run — agent path (driver, headless, screenshot) + +This is the path to use. It needs `apps/studio-host/dist` (build it first). From +the unit root: ```bash deno run -A --no-lock .claude/skills/run-openhwp/driver.ts ``` -It: (1) serves `src/ui/` on an ephemeral port, (2) opens it in headless Chrome, -(3) screenshots the UI shell, and (4) loads `@rhwp/core` and renders a blank -document page to SVG, screenshotting that too. Exit code is `0` only when the -shell smoke check passes. Expected output: +It: (1) serves `apps/studio-host/dist` on an ephemeral port (the same bundle +`apps/desktop/main.ts` serves), (2) opens it in headless Chrome, (3) gates on +the editor **booting** — the static shell (`#menu-bar`, `#scroll-container`) +plus the document engine leaving its loading states (`#sb-message` reaches the +ready prompt, not an init-failure message), and (4) screenshots. Exit code is +`0` only when the studio boots with no uncaught page errors. Expected output: ``` -[driver] serving src/ui at http://127.0.0.1: -[driver] shell: {"hasOpen":true,"placeholder":"Open a Hancom document to view it.","title":"OpenHWP"} -[driver] wrote .../screenshots/shell.png -[driver] render: {"ok":true,"svgLen":460,"pages":1} -[driver] wrote .../screenshots/render.png -[driver] PASS (shell smoke) +[driver] serving studio at http://127.0.0.1: +[driver] studio: {"ready":true,"message":"HWP 파일을 선택해주세요."} +[driver] wrote .../screenshots/studio.png +[driver] PASS (studio boots) ``` -Screenshots land in `.claude/skills/run-openhwp/screenshots/` -(`shell.png` = the toolbar + "Open a Hancom document to view it." placeholder; -`render.png` = a blank page rendered by the rhwp engine). They are gitignored — -regenerated every run. **Open the PNGs and look** — a blank/error image means a -regression. +`screenshots/studio.png` shows the full editor toolbar (cut/copy/paste, +formatting, table, list, image…) over the empty editor canvas — the ready state +before a document is opened. It is gitignored — regenerated every run. **Open +the PNG and look** — a blank/error image means a regression. Drive a different browser: @@ -83,27 +107,33 @@ OPENHWP_CHROME=/path/to/chromium deno run -A --no-lock .claude/skills/run-openhw ### What the driver does NOT cover -- The real **File → Open** flow uses `window.showOpenFilePicker` (File System - Access API), which needs a user gesture + a native picker — not driveable - headless. The driver bypasses it with `HwpDocument.createBlankDocument()` to - still exercise the render pipeline. To test rendering a *real* `.hwp`, do it in - the running desktop app. +- **File → Open / Save** use the web File System Access API + (`showOpenFilePicker` / `showSaveFilePicker`), which need a real user + gesture + a native picker — not driveable headless. The driver asserts only + that the editor boots to its ready state. To test opening / editing / saving a + real `.hwp`, do it in the running desktop app (below), using the studio's + **own** menu / `Ctrl+O` / `Ctrl+S` (which carry the gesture — the native OS + menu does not). ## Run — desktop path (real window) -Launches the actual desktop runtime. `deno task dev` uses the CEF backend from -`deno.json`, which **downloads Chromium (CEF) on first run** (large/slow). To -skip that download, force the system-webview backend — this is what was run this -session, and it compiled + started the runtime and served the UI: +Launches the actual desktop runtime. From the unit root, `deno task dev` uses +the CEF backend from `apps/desktop/deno.json`, which **downloads Chromium (CEF) +on first run** (large/slow): + +```bash +deno task dev +``` + +To skip the CEF download, force the system-webview backend: ```bash -deno desktop --hmr --backend webview --allow-net --allow-read --allow-env main.ts +cd apps/desktop && deno desktop --hmr --backend webview --allow-net --allow-read --allow-env main.ts ``` -Output ends with `Runtime started` … `Listening on http://127.0.0.1:/` and -stays running (Ctrl-C to stop). On a session with a display a native window -opens; on a headless/no-display session the runtime + server start but no visible -window appears — use the driver above to see the UI. Kill a stuck run: +On a session with a display a native window opens showing the full editor; on a +headless/no-display session the runtime + server start but no window appears — +use the driver above to see the UI. Kill a stuck run: ```bash pkill -f "deno desktop" @@ -111,34 +141,38 @@ pkill -f "deno desktop" ## Gotchas (battle scars from this session) -- **`deno desktop` ships official types (`lib.deno.desktop.d.ts`).** Do **not** - hand-write ambient declarations for `Deno.BrowserWindow` / `Deno.MenuItem` — they - conflict/drift. Instead set `compilerOptions.lib: ["deno.window", "deno.desktop"]` - in `deno.json` (works for plain `deno check`, verified). Plain `deno check` - without that lib reports `Property 'BrowserWindow' does not exist on Deno`. -- **`win.bind(name, handler)` handlers must return a `Promise`.** A plain-object - return fails type-check (`TS2739 … missing then/catch/finally`). But marking the - handler `async` with no `await` trips the `require-await` lint. Use - `() => Promise.resolve({...})` to satisfy both. -- **The viewer loads the vendored `@rhwp/core`** from `src/ui/vendor/rhwp/rhwp.js` - (no CDN). It is wasm-bindgen `--target web` output, so `default()` with no - argument loads `rhwp_bg.wasm` relative to the JS module — the two files must stay - in the same dir. Because there is no inline import map or remote script, the app - ships a strict CSP (`script-src 'self' 'wasm-unsafe-eval'`). Update the engine via - `src/ui/vendor/rhwp/README.md`. -- **`rhwp` needs `globalThis.measureTextWidth`** defined before `init()` (a canvas - text-measure callback); `src/ui/app.js` sets it. Without it, layout breaks. +- **The studio owns the File / Edit menus, not the native OS menu.** File System + Access pickers require _transient user activation_; a native-menu → + `executeJs` path does not carry that gesture, so file open/save fails from the + OS menu. The studio's in-webview menu / shortcuts carry the real gesture. So + `main.ts` keeps only a minimal native menu (Reload, Toggle DevTools) and lets + the studio drive open/edit/save. +- **The build skips Rust/wasm-pack.** `scripts/build-studio.ts` copies the + committed `apps/studio-host/vendor/rhwp-core` (`@rhwp/core@0.7.19`, the + wasm-bindgen `pkg/` output) into the upstream tree as `pkg/`, so no Rust + toolchain is needed. The upstream **source** is pinned to the matching + `v0.7.19` tag so studio ↔ core APIs stay consistent. Bump both together via + `config/rhwp-studio-overrides.json` + `vendor/rhwp-core/PROVENANCE.json`. +- **`third_party/rhwp` is a sparse partial clone**, not a full submodule — the + upstream monorepo is 1.1 GB (samples/pdf/mydocs); a cone sparse-checkout of + `rhwp-studio` + `assets` at the pinned commit is ~80 MB. It is gitignored and + materialized by `deno task setup`. +- **Prod builds expose no DEV globals.** Upstream only sets `window.__wasm` + under `import.meta.env.DEV`, so the driver cannot poll it. Readiness is gated + on the static shell plus `#sb-message` leaving the `...로딩 중...` loading + messages without hitting a `...실패`/`...오류` init-failure message. - **Astral**: pass the installed browser via `path` (the driver reads - `OPENHWP_CHROME`) to avoid a ~150MB Chromium download; launched with + `OPENHWP_CHROME`) to avoid a ~150 MB Chromium download; launched with `--no-sandbox`. ## Troubleshooting -- **`TS2739 … Promise` / `TS1356 … mark this function as 'async'`** - on a `win.bind` call → the handler must return a Promise. Use `Promise.resolve(...)` - (see Gotchas), not a bare object. -- **`Property 'BrowserWindow' does not exist on type 'typeof Deno'`** → `deno.json` - is missing `compilerOptions.lib: ["deno.window", "deno.desktop"]`. +- **`[driver] studio bundle missing at …`** → the bundle isn't built. Run + `deno task setup && deno task build:studio`, then re-run the driver. +- **`[driver] FAIL (studio boots)` with a `실패`/`오류` message or page errors** + → the document engine failed to initialize (e.g. CanvasKit couldn't start). + Open `screenshots/studio.png` and re-run with the desktop path to see the + console. - **Driver hangs / no screenshot** → Chrome path wrong or Astral is downloading Chromium on first run. Set `OPENHWP_CHROME` to an existing browser binary. - **`deno task dev` seems stuck downloading** → that's CEF (first run). Use the @@ -146,6 +180,7 @@ pkill -f "deno desktop" ## The harness -`.claude/skills/run-openhwp/driver.ts` — committed next to this file. It reuses -the app's own `serveDir({ fsRoot: "src/ui" })` serving and drives the real UI + -engine. Edit it to add flows (e.g. load a sample `.hwp` fixture) as the app grows. +`.claude/skills/run-openhwp/driver.ts` — committed next to this file. It serves +`apps/studio-host/dist` (the same bundle `apps/desktop/main.ts` serves) and +drives the real embedded editor. Edit it to add flows (e.g. load a sample `.hwp` +fixture) as the app grows. diff --git a/.claude/skills/run-openhwp/driver.ts b/.claude/skills/run-openhwp/driver.ts index 15e815e..1d2edfa 100644 --- a/.claude/skills/run-openhwp/driver.ts +++ b/.claude/skills/run-openhwp/driver.ts @@ -1,32 +1,45 @@ -// OpenHWP driver — launches and drives the app's webview UI headlessly. +// OpenHWP driver — launches and drives the embedded rhwp-studio editor headlessly. // -// The desktop window (main.ts) is a Chromium view pointed at a local -// `Deno.serve()` server. This driver serves the same UI (`src/ui/`) and drives -// it with headless Chrome via Astral, so it works without a display / CEF / -// WindowServer — the reproducible harness for UI-layer changes. +// The desktop shell (apps/desktop/main.ts) serves the built studio bundle +// (apps/studio-host/dist) and points a CEF window at it. This driver serves the +// same bundle and drives it with headless Chrome via Astral, so it works without +// a display / CEF / WindowServer — the reproducible harness for the web layer. // -// Run from the UNIT ROOT (the repo root), not from the skill dir: +// Run from the UNIT ROOT (the repo root): +// deno task setup # once: materialize third_party/rhwp +// deno task build:studio # once: produce apps/studio-host/dist // deno run -A --no-lock .claude/skills/run-openhwp/driver.ts -// See SKILL.md for the exact invocation. Screenshots land in -// .claude/skills/run-openhwp/screenshots/ +// Screenshots land in .claude/skills/run-openhwp/screenshots/ // -// What it does: -// 1. serve src/ui on an ephemeral port, -// 2. open it in headless Chrome and screenshot the UI shell, -// 3. attempt the rhwp render path (load the vendored @rhwp/core engine, render -// a page to SVG) and screenshot it. -// Exit code is 0 only if the shell smoke check passes AND the rhwp render path -// succeeds (engine loads + a page renders to non-empty SVG). Any render-path -// failure — a failed engine load or a post-load render regression — gates. +// Exit code is 0 only if the studio boots: the static shell is present +// (#menu-bar + #scroll-container) AND the document engine reaches its ready +// state (the status bar leaves the "...로딩 중..." messages without an init +// failure), with no uncaught page errors. -// The `page.evaluate()` callbacks below run in the browser, so this driver -// references DOM globals (document, etc.) that deno.json's lib does not provide. -// Pull in the DOM lib for this file so `deno check` passes on it. +// This is a standalone agent-tooling script, run with `deno run -A --no-lock` +// outside the workspace import graph — so it pins its deps with inline `jsr:` +// specifiers rather than a shared import map. Silence no-import-prefix for it. +// deno-lint-ignore-file no-import-prefix + +// page.evaluate() callbacks below run in the browser, so this file references +// DOM globals that deno.json's lib does not provide. Pull in the DOM lib. /// import { serveDir } from "jsr:@std/http@1/file-server"; +import { fromFileUrl } from "jsr:@std/path@1"; import { launch } from "jsr:@astral/astral@0.5"; +// The studio bundle, resolved relative to this file so cwd does not matter. +const DIST = fromFileUrl( + new URL("../../../apps/studio-host/dist", import.meta.url), +); +const built = await Deno.stat(`${DIST}/index.html`).then(() => true).catch(() => false); +if (!built) { + console.error(`[driver] studio bundle missing at ${DIST}`); + console.error(`[driver] run: deno task setup && deno task build:studio`); + Deno.exit(1); +} + // Chrome to drive. macOS default; override with OPENHWP_CHROME (e.g. a Linux // chromium path). Astral downloads its own Chromium if this path is missing. const CHROME = Deno.env.get("OPENHWP_CHROME") ?? @@ -36,14 +49,14 @@ const SHOT_DIR = new URL("./screenshots/", import.meta.url); await Deno.mkdir(SHOT_DIR, { recursive: true }); const shotPath = (name: string) => decodeURIComponent(new URL(name, SHOT_DIR).pathname); -// 1. Serve the UI (same fsRoot as main.ts). Run from the unit root. +// Serve the built studio (same bundle main.ts serves). Loopback only. let port = 0; const server = Deno.serve( - { port: 0, onListen: (addr) => (port = addr.port) }, - (req) => serveDir(req, { fsRoot: "src/ui", quiet: true }), + { hostname: "127.0.0.1", port: 0, onListen: (addr) => (port = addr.port) }, + (req) => serveDir(req, { fsRoot: DIST, quiet: true }), ); const base = `http://127.0.0.1:${port}`; -console.log(`[driver] serving src/ui at ${base}`); +console.log(`[driver] serving studio at ${base}`); const chromeExists = await Deno.stat(CHROME).then(() => true).catch(() => false); const browser = await launch({ @@ -55,92 +68,49 @@ const browser = await launch({ let ok = false; try { const page = await browser.newPage(); - const consoleErrors: string[] = []; - page.addEventListener("console", (e) => { - // deno-lint-ignore no-explicit-any - const d: any = e.detail; - if (d?.type === "error") consoleErrors.push(String(d.text)); - }); + const pageErrors: string[] = []; page.addEventListener("pageerror", (e) => { // deno-lint-ignore no-explicit-any - consoleErrors.push(String((e as any).detail?.message ?? e.detail)); + pageErrors.push(String((e as any).detail?.message ?? e.detail)); }); await page.goto(base, { waitUntil: "networkidle2" }); - await page.waitForSelector("#viewer"); - - // 2. Shell smoke: the toolbar Open button + placeholder must be present, and - // app.js must have initialized (it installs globalThis.openhwp.onMenu). A - // module that throws during init leaves the static HTML intact, so checking - // only #open/.placeholder would pass a dead UI — require app readiness too. - const shell = await page.evaluate(() => ({ - hasOpen: !!document.querySelector("#open"), - placeholder: document.querySelector(".placeholder")?.textContent?.trim() ?? "", - title: document.title, - appReady: - typeof (globalThis as { openhwp?: { onMenu?: unknown } }).openhwp?.onMenu === "function", - })); - await Deno.writeFile(shotPath("shell.png"), await page.screenshot()); - console.log(`[driver] shell:`, JSON.stringify(shell)); - console.log(`[driver] wrote ${shotPath("shell.png")}`); - // Snapshot console/page errors BEFORE the best-effort render probe so the - // probe's own failures stay non-gating. - const shellErrors = [...consoleErrors]; - if (shellErrors.length) console.log(`[driver] shell console errors:`, shellErrors); - ok = shell.hasOpen && shell.placeholder.length > 0 && shell.appReady && - shellErrors.length === 0; + // Static studio shell must be present (menu bar + editor scroll area). + await page.waitForSelector("#menu-bar"); + await page.waitForSelector("#scroll-container"); - // 3. Render path: load the vendored @rhwp/core engine and render a page to - // SVG, then gate on it — if the engine cannot load or cannot render, the app - // cannot display documents, so the run fails. The engine is vendored locally - // (src/ui/vendor/rhwp), so a load failure is a real regression (missing or - // broken vendored files), not a transient CDN blip. - try { - const render = await page.evaluate(async () => { - let loaded = false; - try { - // @ts-ignore vendored engine served at /vendor/rhwp/rhwp.js - const mod = await import("./vendor/rhwp/rhwp.js"); - await mod.default(); // init WASM (loaded from the local vendor dir) - loaded = true; - const make = mod.HwpDocument.createBlankDocument ?? mod.HwpDocument.createEmpty; - if (typeof make !== "function") { - return { loaded, ok: false, why: "no createBlank/Empty", svgLen: 0, pages: null }; - } - const doc = make.call(mod.HwpDocument); - const svg = doc.renderPageSvg(0); - document.getElementById("viewer")!.innerHTML = svg; - return { - loaded, - ok: svg.length > 0, - why: "", - svgLen: svg.length, - pages: doc.pageCount?.() ?? null, - }; - } catch (err) { - return { loaded, ok: false, why: String((err as Error)?.message ?? err), svgLen: 0, pages: null }; - } - }); - console.log(`[driver] render:`, JSON.stringify(render)); - if (render.ok) { - await Deno.writeFile(shotPath("render.png"), await page.screenshot()); - console.log(`[driver] wrote ${shotPath("render.png")}`); - } else { - // The engine failed to load or to render — either way the app cannot - // display documents, so fail the run. `loaded` distinguishes a load - // failure from a post-load render regression for diagnostics; both gate. - console.log(`[driver] render failed (gating) [loaded=${render.loaded}]:`, render.why); - ok = false; + // Poll the studio status bar until the engine leaves its loading states. + // Upstream main.ts drives #sb-message through "...로딩 중..." during init and + // a ready prompt afterward; an init failure sets a "...실패"/"...오류" message. + const readyState = await page.evaluate(async () => { + const msg = () => document.getElementById("sb-message")?.textContent?.trim() ?? ""; + const isLoading = (t: string) => + t === "" || t.includes("로딩") || t.endsWith("중...") || + t.includes("중…"); + const isError = (t: string) => t.includes("실패") || t.includes("오류"); + for (let i = 0; i < 60; i++) { // up to ~30s + const t = msg(); + if (isError(t)) return { ready: false, message: t }; + if (!isLoading(t)) return { ready: true, message: t }; + await new Promise((r) => setTimeout(r, 500)); } - } catch (err) { - console.log(`[driver] render attempt errored (non-fatal):`, String((err as Error)?.message ?? err)); - } + return { ready: false, message: msg() || "(timeout, still loading)" }; + }); - if (consoleErrors.length) console.log(`[driver] console errors:`, consoleErrors); + await Deno.writeFile(shotPath("studio.png"), await page.screenshot()); + console.log(`[driver] studio:`, JSON.stringify(readyState)); + console.log(`[driver] wrote ${shotPath("studio.png")}`); + if (pageErrors.length) console.log(`[driver] page errors:`, pageErrors); + + ok = readyState.ready && pageErrors.length === 0; } finally { - await browser.close(); - await server.shutdown(); + // Settle both cleanups independently — a browser.close() rejection must not + // mask the smoke failure or skip server.shutdown() (which would leak the port). + await browser.close().catch((e) => console.error(`[driver] browser.close: ${e}`)); + await server.shutdown().catch((e) => console.error(`[driver] server.shutdown: ${e}`)); } -console.log(ok ? "[driver] PASS (shell smoke)" : "[driver] FAIL (shell smoke)"); +console.log( + ok ? "[driver] PASS (studio boots)" : "[driver] FAIL (studio boots)", +); Deno.exit(ok ? 0 : 1); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10fbe11..2ddec3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,24 +16,32 @@ jobs: - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 with: deno-version: v2.x - - run: deno check main.ts + - run: deno task check - run: deno lint - run: deno fmt --check smoke: - # Headless UI smoke via the run-openhwp driver: serve src/ui, drive it with - # Chrome, and assert the shell renders. (The rhwp render step is best-effort - # and does not gate — see the driver.) + # Headless UI smoke via the run-openhwp driver: build the embedded rhwp-studio + # editor bundle (apps/studio-host/dist), serve it, drive it with Chrome, and + # assert the editor boots. The bundle is gitignored, so it is materialized and + # built first (deno task setup → build:studio); the build needs Node for Vite. runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 with: deno-version: v2.x + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 - uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2 id: chrome with: install-dependencies: true + - name: Materialize upstream rhwp (sparse, pinned) + run: deno task setup + - name: Build the studio bundle + run: deno task build:studio - name: run-openhwp smoke env: OPENHWP_CHROME: ${{ steps.chrome.outputs.chrome-path }} diff --git a/.gitignore b/.gitignore index dda49b3..1b42071 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,16 @@ # Deno # Anchored to the repo root so it only ignores Deno's top-level `deno vendor` -# output, not the intentionally committed engine at src/ui/vendor/rhwp. +# output, not the intentionally committed core at apps/studio-host/vendor/rhwp-core. /vendor/ node_modules/ +# Upstream rhwp checkout — materialized by `deno task setup` (scripts/setup-rhwp.ts), +# pinned in config/rhwp-studio-overrides.json. Not committed (~80 MB sparse checkout). +third_party/ + +# Built studio bundle — produced by `deno task build:studio` (also matched by dist/ below). +apps/studio-host/dist/ + # Build output — deno desktop binaries and installers build/ dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a36df78..61613f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,10 +9,12 @@ By participating, you agree to abide by our [Code of Conduct](./CODE_OF_CONDUCT. ```bash git clone https://github.com/pleaseai/openhwp.git cd openhwp -deno install # cache dependencies (once deno.json lands) +deno task setup # materialize the pinned upstream rhwp checkout (once) +deno task build:studio # build the embedded rhwp-studio editor bundle ``` -OpenHWP runs on a single **Deno** toolchain (no Node, bun, or mise). You need +OpenHWP's shell runs on a single **Deno** toolchain, but building the embedded +rhwp-studio editor bundle also needs **Node.js + npm** (Vite). You need [Deno](https://deno.com) **≥ 2.9.0** (`deno desktop` was introduced in 2.9); check with `deno --version`. See the [README](./README.en.md) for the full stack. @@ -24,14 +26,14 @@ check with `deno --version`. See the [README](./README.en.md) for the full stack 4. Open a pull request and fill out the template. ```bash +deno task check # type-check the desktop shell deno lint # lint deno fmt --check # formatting -deno test # run the test suite ``` -> **Note:** application code is not in the repository yet. The app-level tasks -> (`deno task dev`, the desktop build) land with the implementation; until then -> these built-in Deno checks are the baseline. +> **Note:** the headless smoke test (`.claude/skills/run-openhwp/driver.ts`, +> run in CI) builds and boots the embedded editor; see +> `.claude/skills/run-openhwp/SKILL.md`. There is no `deno test` suite yet. ## Commit messages diff --git a/README.en.md b/README.en.md index 56bc640..2f8da59 100644 --- a/README.en.md +++ b/README.en.md @@ -4,9 +4,9 @@ **OpenHWP is an open-source HWP/HWPX desktop app built with [Deno desktop](https://docs.deno.com/runtime/desktop/) and [rhwp](https://github.com/edwardkim/rhwp).** -It opens and views Korean HWP and HWPX documents on macOS, Windows, and Linux — no Hancom Office required. Editing is on the roadmap. The [rhwp](https://github.com/edwardkim/rhwp) engine (Rust + WebAssembly) parses and renders documents; OpenHWP is a thin desktop shell around it. +It opens, edits, and saves Korean HWP and HWPX documents on macOS, Windows, and Linux — no Hancom Office required. The full [rhwp-studio](https://github.com/edwardkim/rhwp) editor (Rust + WebAssembly) does the document work; OpenHWP is a thin desktop shell around it. -> **Status: early / work in progress.** The repository currently holds the project scaffold and docs — there is no application code yet. Both the engine and the Deno desktop runtime are young, so expect breaking changes. +> **Status: early / work in progress.** OpenHWP is a thin native shell that embeds the full rhwp-studio editor (see Quick start below). Both the engine and the Deno desktop runtime are young, so expect breaking changes. ## How it works @@ -58,19 +58,22 @@ await writable.close(); ## Quick start -> The application code (`deno.json`'s `desktop` block, `main.ts`, and the UI) does not exist yet. The flow below is the intended shape and fills in as the app is built. +You need [Deno](https://deno.com) 2.9.0 or later (`deno desktop` arrived in 2.9), plus Node.js and npm to build the studio bundle. Check with `deno --version`. -You need [Deno](https://deno.com) 2.9.0 or later (`deno desktop` arrived in 2.9). Check with `deno --version`. +OpenHWP embeds the full [rhwp-studio](https://github.com/edwardkim/rhwp) editor. Its bundle (`apps/studio-host/dist`) is not committed, so build it first. ```sh -# Run in development -deno task dev +# 1. Materialize the pinned upstream rhwp checkout (third_party/rhwp) — once +deno task setup + +# 2. Build the studio bundle → apps/studio-host/dist +deno task build:studio -# Build a standalone binary with the Chromium (CEF) backend -deno desktop main.ts --backend cef +# 3. Run in development (the apps/desktop shell serves the bundle) +deno task dev -# Produce platform installers (.dmg / .msi / .deb) — configured via deno.json's desktop.output -deno desktop main.ts +# Build a standalone binary / installers (.dmg / .msi / .AppImage) — configured via apps/desktop/deno.json's desktop.output +deno task build ``` ## Roadmap diff --git a/README.md b/README.md index ca109a0..86532b2 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ **OpenHWP는 [Deno desktop](https://docs.deno.com/runtime/desktop/)과 [rhwp](https://github.com/edwardkim/rhwp)로 만드는 오픈소스 HWP/HWPX 데스크톱 앱입니다.** -한컴오피스 없이 macOS, Windows, Linux에서 한글 HWP·HWPX 문서를 열고 봅니다. 편집은 로드맵에 있습니다. 문서 파싱과 렌더링은 [rhwp](https://github.com/edwardkim/rhwp)(Rust + WebAssembly) 엔진이 맡고, OpenHWP는 그 위에 데스크톱 껍데기를 얇게 씌웁니다. +한컴오피스 없이 macOS, Windows, Linux에서 한글 HWP·HWPX 문서를 열고, 편집하고, 저장합니다. 문서 작업은 전체 [rhwp-studio](https://github.com/edwardkim/rhwp)(Rust + WebAssembly) 편집기가 맡고, OpenHWP는 그 위에 데스크톱 껍데기를 얇게 씌웁니다. -> **상태: 초기 개발 단계입니다.** 지금 저장소에는 프로젝트 뼈대와 문서만 있고, 애플리케이션 코드는 아직 없습니다. 기반 엔진과 Deno desktop 런타임이 모두 초기라 호환성이 깨지는 변경이 잦을 수 있습니다. +> **상태: 초기 개발 단계입니다.** OpenHWP는 전체 rhwp-studio 편집기를 임베드하는 얇은 네이티브 셸입니다(아래 빠른 시작 참고). 기반 엔진과 Deno desktop 런타임이 모두 초기라 호환성이 깨지는 변경이 잦을 수 있습니다. ## 동작 방식 @@ -58,19 +58,22 @@ await writable.close(); ## 빠른 시작 -> 애플리케이션 코드(`deno.json`의 `desktop` 블록, `main.ts`, UI)는 아직 없습니다. 아래는 앞으로 만들 흐름이며, 구현이 진행되면 채워집니다. +[Deno](https://deno.com) 2.9.0 이상(`deno desktop`은 2.9에서 들어왔습니다)과, 스튜디오 번들을 빌드할 때 필요한 Node.js·npm이 있어야 합니다. 버전은 `deno --version`으로 확인합니다. -[Deno](https://deno.com) 2.9.0 이상이 필요합니다(`deno desktop`은 2.9에서 들어왔습니다). 버전은 `deno --version`으로 확인합니다. +OpenHWP는 전체 [rhwp-studio](https://github.com/edwardkim/rhwp) 편집기를 임베드하며, 그 번들(`apps/studio-host/dist`)은 저장소에 커밋하지 않으므로 먼저 빌드해야 합니다. ```sh -# 개발 모드로 실행 -deno task dev +# 1. 고정된 업스트림 rhwp 체크아웃(third_party/rhwp) 준비 — 최초 1회 +deno task setup + +# 2. 스튜디오 번들 빌드 → apps/studio-host/dist +deno task build:studio -# Chromium(CEF) 백엔드로 실행 바이너리 빌드 -deno desktop main.ts --backend cef +# 3. 개발 모드로 실행 (apps/desktop 셸이 번들을 띄웁니다) +deno task dev -# 플랫폼 설치 파일(.dmg / .msi / .deb) 생성 — deno.json의 desktop.output으로 설정 -deno desktop main.ts +# 실행 바이너리·설치 파일(.dmg / .msi / .AppImage) 빌드 — apps/desktop/deno.json의 desktop.output으로 설정 +deno task build ``` ## 로드맵 diff --git a/apps/desktop/deno.json b/apps/desktop/deno.json new file mode 100644 index 0000000..9ad54c2 --- /dev/null +++ b/apps/desktop/deno.json @@ -0,0 +1,29 @@ +{ + "name": "@openhwp/desktop", + "version": "0.0.0", + "exports": "./main.ts", + "compilerOptions": { + "lib": ["deno.window", "deno.desktop"] + }, + "tasks": { + "dev": "deno desktop --hmr --allow-net --allow-read --allow-env main.ts", + "build": "deno desktop --allow-net --allow-read --allow-env --include ../studio-host/dist main.ts", + "check": "deno check main.ts" + }, + "imports": { + "@std/http": "jsr:@std/http@^1", + "@std/path": "jsr:@std/path@^1" + }, + "desktop": { + "app": { + "name": "OpenHWP", + "identifier": "dev.pleaseai.openhwp" + }, + "backend": "cef", + "output": { + "macos": "dist/OpenHWP.dmg", + "windows": "dist/OpenHWP.msi", + "linux": "dist/OpenHWP.AppImage" + } + } +} diff --git a/apps/desktop/main.ts b/apps/desktop/main.ts new file mode 100644 index 0000000..da54895 --- /dev/null +++ b/apps/desktop/main.ts @@ -0,0 +1,114 @@ +// OpenHWP — Deno desktop host (the native shell). +// +// The shell is intentionally thin: the entire document experience — parsing, +// layout, rendering, EDITING, and saving — lives in the webview, which runs the +// full upstream rhwp-studio editor (built into apps/studio-host/dist). This +// module only: +// 1. serves that studio bundle over local HTTP, +// 2. opens the main window and points it at that server, +// 3. installs a minimal native menu (window/host ops only). +// +// File/Edit are deliberately owned by the studio's own in-app menu bar and +// Ctrl/Cmd+O·S shortcuts: those run *inside* the webview, so the File System +// Access pickers get the transient user activation they require. A native-menu +// → executeJs path would not carry that activation, so native File/Edit +// integration (via a studio override exposing load/save hooks) is a later phase. +// +// Runtime APIs come from the Deno desktop runtime — https://docs.deno.com/runtime/desktop/ + +import { serveDir } from "@std/http/file-server"; +import { fromFileUrl } from "@std/path"; + +// The studio bundle is produced by `deno task build:studio` +// (scripts/build-studio.ts) into apps/studio-host/dist. Resolve it relative to +// this module so the server works regardless of the current working directory. +const UI_ROOT = fromFileUrl(new URL("../studio-host/dist", import.meta.url)); + +// Preflight: the studio bundle is gitignored and built separately, so a fresh +// checkout has none. Fail with the build command rather than opening a window +// onto blank 404s — serveDir runs quiet, so the misconfig would be invisible. +try { + await Deno.stat(`${UI_ROOT}/index.html`); +} catch { + console.error(`OpenHWP: studio bundle not found at ${UI_ROOT}`); + console.error("Build it first: deno task setup && deno task build:studio"); + Deno.exit(1); +} + +// Bind to loopback with an ephemeral port (Deno.serve otherwise defaults to +// 0.0.0.0:8000) — this is a local desktop app; the bundle must not be reachable +// from other hosts, and a fixed well-known port invites collisions. +const server = Deno.serve( + { hostname: "127.0.0.1", port: 0 }, + (req) => serveDir(req, { fsRoot: UI_ROOT, quiet: true }), +); +if (server.addr.transport !== "tcp") { + throw new Error( + `OpenHWP UI server expected a TCP address, got ${server.addr.transport}`, + ); +} +const port = server.addr.port; + +const win = new Deno.BrowserWindow({ + title: "OpenHWP", + width: 1200, + height: 860, +}); +win.navigate(`http://127.0.0.1:${port}`); + +// Minimal native menu — window/host operations only. No File/Edit here: the +// studio provides them, and duplicating Cmd+O/S as native accelerators would +// let the OS intercept the keystroke before the webview (and its gesture) sees +// it. Reload/DevTools are host operations handled below without touching the +// webview's activation state. +const menu: Deno.MenuItem[] = [ + { + submenu: { + label: "OpenHWP", + items: [{ role: { role: "quit" } }], + }, + }, + { + submenu: { + label: "View", + items: [ + { + item: { + label: "Reload", + id: "reload", + accelerator: "CmdOrCtrl+R", + enabled: true, + }, + }, + { + item: { + label: "Toggle DevTools", + id: "devtools", + accelerator: "F12", + enabled: true, + }, + }, + ], + }, + }, +]; +win.setApplicationMenu(menu); + +win.addEventListener("menuclick", (event) => { + switch (event.detail.id) { + case "reload": + win.reload(); + break; + case "devtools": + win.openDevtools(); + break; + } +}); + +// Host info, callable from the webview as `bindings.hostInfo()`. Returns a +// Promise (the binding signature expects one). +win.bind("hostInfo", () => + Promise.resolve({ + denoVersion: Deno.version.deno, + platform: Deno.build.os, + })); diff --git a/apps/studio-host/README.md b/apps/studio-host/README.md new file mode 100644 index 0000000..f230dce --- /dev/null +++ b/apps/studio-host/README.md @@ -0,0 +1,39 @@ +# `@openhwp/studio-host` + +The web layer of OpenHWP: the full [rhwp-studio](https://github.com/edwardkim/rhwp) editor (menus, +toolbar, tables, formatting, undo, open/save), built into `dist/` and served by the deno-desktop +shell (`apps/desktop`). + +OpenHWP embeds the **unmodified upstream** studio. Upstream's file open/save use the web File System +Access API, which works in the CEF webview as-is, so no source overrides are needed yet. +(Desktop-native integration — native menu ↔ editor, PDF/print — will arrive as overrides under +`src/`, tracked in `config/rhwp-studio-overrides.json`.) + +## Layout + +| Path | Committed? | What | +| ------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------- | +| `vendor/rhwp-core/` | yes | `@rhwp/core@0.7.19` wasm engine (`rhwp.js` + `rhwp_bg.wasm`), the `@wasm` alias for the build. See `PROVENANCE.json`. | +| `dist/` | no (gitignored) | Built studio bundle — produced by the build below. | + +The upstream studio **source** is not vendored here; it is materialized at `third_party/rhwp` +(gitignored) from the pin in `config/rhwp-studio-overrides.json`. + +## Build + +From the repo root: + +```bash +deno task setup # materialize third_party/rhwp (sparse, pinned) — once +deno task build:studio # build the studio → apps/studio-host/dist +``` + +`scripts/build-studio.ts` supplies `pkg/` from `vendor/rhwp-core` (so the Rust/wasm-pack step is +skipped), disables the PWA service worker, drops bundled sample docs, and runs the upstream Vite +build with `--base=/`. The upstream tree is restored afterward. + +## Updating rhwp + +Bump `upstream.version`/`tag`/`commit` in `config/rhwp-studio-overrides.json`, refresh +`vendor/rhwp-core` to the matching `@rhwp/core` version (see its `PROVENANCE.json`), then re-run +`deno task setup && deno task build:studio`. diff --git a/src/ui/vendor/rhwp/LICENSE b/apps/studio-host/vendor/rhwp-core/LICENSE similarity index 100% rename from src/ui/vendor/rhwp/LICENSE rename to apps/studio-host/vendor/rhwp-core/LICENSE diff --git a/apps/studio-host/vendor/rhwp-core/PROVENANCE.json b/apps/studio-host/vendor/rhwp-core/PROVENANCE.json new file mode 100644 index 0000000..698d7b9 --- /dev/null +++ b/apps/studio-host/vendor/rhwp-core/PROVENANCE.json @@ -0,0 +1,12 @@ +{ + "package": "@rhwp/core", + "version": "0.7.19", + "source": "npm registry tarball (npm pack @rhwp/core@0.7.19)", + "license": "MIT", + "purpose": "wasm-bindgen --target web output (pkg/) used as the @wasm alias when building the vendored rhwp-studio editor. Supplied here so the studio build does not need the Rust/wasm-pack toolchain; pinned to match third_party/rhwp@v0.7.19.", + "files": { + "rhwp.js": "sha256:1db3945f89368b151dd9be85b4edd590b56ddf0e583bdabbf86c1855537174cf", + "rhwp_bg.wasm": "sha256:5e68bae80611fdd4d231967b9e13dff0f3f1cdbb17a09a090c2052593c3980e9" + }, + "update": "npm pack @rhwp/core@; tar xzf rhwp-core-.tgz; cp package/{rhwp.js,rhwp_bg.wasm,rhwp.d.ts,rhwp_bg.wasm.d.ts,package.json,LICENSE} apps/studio-host/vendor/rhwp-core/; then bump the matching third_party/rhwp pin in config/rhwp-studio-overrides.json." +} diff --git a/apps/studio-host/vendor/rhwp-core/package.json b/apps/studio-host/vendor/rhwp-core/package.json new file mode 100644 index 0000000..d574551 --- /dev/null +++ b/apps/studio-host/vendor/rhwp-core/package.json @@ -0,0 +1,40 @@ +{ + "name": "@rhwp/core", + "version": "0.7.19", + "description": "HWP/HWPX file parser and renderer — Rust + WebAssembly", + "type": "module", + "main": "rhwp.js", + "types": "rhwp.d.ts", + "files": [ + "rhwp_bg.wasm", + "rhwp.js", + "rhwp.d.ts", + "rhwp_bg.wasm.d.ts" + ], + "keywords": [ + "hwp", + "hwpx", + "hancom", + "hangul", + "한글", + "document", + "parser", + "renderer", + "wasm", + "webassembly", + "rust" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/edwardkim/rhwp.git" + }, + "homepage": "https://edwardkim.github.io/rhwp/", + "bugs": { + "url": "https://github.com/edwardkim/rhwp/issues" + }, + "license": "MIT", + "author": "Edward Kim", + "sideEffects": [ + "./snippets/*" + ] +} diff --git a/src/ui/vendor/rhwp/rhwp.d.ts b/apps/studio-host/vendor/rhwp-core/rhwp.d.ts similarity index 100% rename from src/ui/vendor/rhwp/rhwp.d.ts rename to apps/studio-host/vendor/rhwp-core/rhwp.d.ts diff --git a/src/ui/vendor/rhwp/rhwp.js b/apps/studio-host/vendor/rhwp-core/rhwp.js similarity index 100% rename from src/ui/vendor/rhwp/rhwp.js rename to apps/studio-host/vendor/rhwp-core/rhwp.js diff --git a/src/ui/vendor/rhwp/rhwp_bg.wasm b/apps/studio-host/vendor/rhwp-core/rhwp_bg.wasm similarity index 100% rename from src/ui/vendor/rhwp/rhwp_bg.wasm rename to apps/studio-host/vendor/rhwp-core/rhwp_bg.wasm diff --git a/src/ui/vendor/rhwp/rhwp_bg.wasm.d.ts b/apps/studio-host/vendor/rhwp-core/rhwp_bg.wasm.d.ts similarity index 100% rename from src/ui/vendor/rhwp/rhwp_bg.wasm.d.ts rename to apps/studio-host/vendor/rhwp-core/rhwp_bg.wasm.d.ts diff --git a/config/rhwp-studio-overrides.json b/config/rhwp-studio-overrides.json new file mode 100644 index 0000000..d7c50aa --- /dev/null +++ b/config/rhwp-studio-overrides.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "upstream": { + "repo": "https://github.com/edwardkim/rhwp.git", + "version": "0.7.19", + "tag": "v0.7.19", + "commit": "f137b4c9468eaff5bb43e25108e9c9d39a2ed15b", + "sparsePaths": ["rhwp-studio", "assets"], + "counterparts": {} + }, + "overrides": [] +} diff --git a/deno.json b/deno.json index cb5e458..eae0a8c 100644 --- a/deno.json +++ b/deno.json @@ -1,40 +1,19 @@ { - "name": "openhwp", - "version": "0.0.0", - "exports": "./main.ts", - "compilerOptions": { - "lib": ["deno.window", "deno.desktop"] - }, + "workspace": ["./apps/desktop"], "tasks": { - "dev": "deno desktop --hmr --allow-net --allow-read --allow-env main.ts", - "build": "deno desktop --allow-net --allow-read --allow-env --include ./src/ui main.ts", - "check": "deno check main.ts", - "fmt": "deno fmt", - "lint": "deno lint", - "test": "deno test --allow-read --permit-no-files" - }, - "imports": { - "@std/http": "jsr:@std/http@^1" + "setup": "deno run --allow-read --allow-run scripts/setup-rhwp.ts", + "build:studio": "deno run --allow-read --allow-write --allow-run --allow-env scripts/build-studio.ts", + "dev": "cd apps/desktop && deno task dev", + "build": "cd apps/desktop && deno task build", + "check": "cd apps/desktop && deno task check" }, "fmt": { "lineWidth": 100, - "include": ["main.ts", "src/"], - "exclude": ["dist/", "build/", "vendor/", "src/ui/vendor/"] + "include": ["apps/", "scripts/", "config/", ".claude/skills/run-openhwp/driver.ts"], + "exclude": ["apps/studio-host/dist/", "apps/studio-host/vendor/"] }, "lint": { - "include": ["main.ts", "src/"], - "exclude": ["dist/", "build/", "vendor/", "src/ui/vendor/"] - }, - "desktop": { - "app": { - "name": "OpenHWP", - "identifier": "dev.pleaseai.openhwp" - }, - "backend": "cef", - "output": { - "macos": "dist/OpenHWP.dmg", - "windows": "dist/OpenHWP.msi", - "linux": "dist/OpenHWP.AppImage" - } + "include": ["apps/", "scripts/", ".claude/skills/run-openhwp/driver.ts"], + "exclude": ["apps/studio-host/dist/", "apps/studio-host/vendor/"] } } diff --git a/deno.lock b/deno.lock index b5cff44..8c1b77a 100644 --- a/deno.lock +++ b/deno.lock @@ -10,6 +10,7 @@ "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/media-types@^1.1.0": "1.1.0", "jsr:@std/net@^1.0.6": "1.0.6", + "jsr:@std/path@1": "1.1.6", "jsr:@std/path@^1.1.6": "1.1.6", "jsr:@std/streams@^1.1.1": "1.1.1" }, @@ -39,7 +40,7 @@ "jsr:@std/html", "jsr:@std/media-types", "jsr:@std/net", - "jsr:@std/path", + "jsr:@std/path@^1.1.6", "jsr:@std/streams" ] }, @@ -63,8 +64,13 @@ } }, "workspace": { - "dependencies": [ - "jsr:@std/http@1" - ] + "members": { + "apps/desktop": { + "dependencies": [ + "jsr:@std/http@1", + "jsr:@std/path@1" + ] + } + } } } diff --git a/main.ts b/main.ts deleted file mode 100644 index 4355154..0000000 --- a/main.ts +++ /dev/null @@ -1,123 +0,0 @@ -// OpenHWP — Deno desktop host (the "platform shell"). -// -// The shell is intentionally thin: every document concern (parsing, layout, -// rendering, editing) lives in the webview via the rhwp engine. This module -// only: -// 1. serves the static webview UI (src/ui) over local HTTP, -// 2. opens the main application window and points it at that server, -// 3. installs the native application menu and forwards menu clicks to the UI, -// 4. exposes a couple of host functions to the webview through bindings. -// -// Runtime APIs used here come from the Deno desktop runtime — see -// https://docs.deno.com/runtime/desktop/ (Deno.serve, Deno.BrowserWindow, -// win.setApplicationMenu / menuclick, win.bind). - -import { serveDir } from "@std/http/file-server"; - -const UI_ROOT = "src/ui"; - -// 1. Serve the UI. `deno desktop` selects a loopback port and exposes it via -// DENO_SERVE_ADDRESS; Deno.serve() binds to that address regardless of any port -// passed in code. Read the actually-bound port back from the server rather than -// re-parsing the env var (which would fall back to port 0 when it is absent, -// e.g. running this module without the desktop runtime). -const server = Deno.serve((req) => serveDir(req, { fsRoot: UI_ROOT, quiet: true })); -if (server.addr.transport !== "tcp") { - throw new Error(`OpenHWP UI server expected a TCP address, got ${server.addr.transport}`); -} -const port = server.addr.port; - -// 2. The runtime creates an implicit initial window; the first BrowserWindow -// construction adopts it. Navigate it to the local server. -const win = new Deno.BrowserWindow({ - title: "OpenHWP", - width: 1100, - height: 800, -}); -win.navigate(`http://127.0.0.1:${port}`); - -// 3. Native application menu. Items with an `id` emit `menuclick`; `role` items -// are handled by the OS and do not. -const menu: Deno.MenuItem[] = [ - { - submenu: { - label: "File", - items: [ - { item: { label: "Open…", id: "open", accelerator: "CmdOrCtrl+O", enabled: true } }, - { item: { label: "Save", id: "save", accelerator: "CmdOrCtrl+S", enabled: true } }, - { - item: { - label: "Save As…", - id: "save-as", - accelerator: "CmdOrCtrl+Shift+S", - enabled: true, - }, - }, - "separator", - { role: { role: "quit" } }, - ], - }, - }, - { - submenu: { - label: "Edit", - items: [ - { role: { role: "undo" } }, - { role: { role: "redo" } }, - "separator", - { role: { role: "cut" } }, - { role: { role: "copy" } }, - { role: { role: "paste" } }, - { role: { role: "selectAll" } }, - ], - }, - }, - { - submenu: { - label: "View", - items: [ - { item: { label: "Reload", id: "reload", accelerator: "CmdOrCtrl+R", enabled: true } }, - { item: { label: "Toggle DevTools", id: "devtools", accelerator: "F12", enabled: true } }, - ], - }, - }, -]; - -win.setApplicationMenu(menu); - -win.addEventListener("menuclick", (event) => { - switch (event.detail.id) { - case "reload": - win.reload(); - break; - case "devtools": - win.openDevtools(); - break; - // Document actions run in the webview (the File System Access API must be - // invoked there). Forward them; the UI exposes `globalThis.openhwp.onMenu`. - case "open": - case "save": - case "save-as": { - // Fail loudly if the UI bridge isn't ready instead of silently dropping - // the click, and observe the rejection so an absent/broken bridge is - // diagnosable rather than a no-op. - const id = event.detail.id; - win.executeJs( - `if (typeof globalThis.openhwp?.onMenu !== "function") { - throw new Error("OpenHWP UI menu bridge is unavailable"); - } - globalThis.openhwp.onMenu(${JSON.stringify(id)});`, - ).catch((err) => console.error(`[host] menu action "${id}" failed:`, err)); - break; - } - } -}); - -// 4. Host bindings, callable from the webview as `bindings.(...)`. Kept -// minimal — file I/O happens in the webview via the File System Access API. The -// handler returns a Promise (the binding signature expects one). -win.bind("hostInfo", () => - Promise.resolve({ - denoVersion: Deno.version.deno, - platform: Deno.build.os, - })); diff --git a/scripts/build-studio.ts b/scripts/build-studio.ts new file mode 100644 index 0000000..b752e03 --- /dev/null +++ b/scripts/build-studio.ts @@ -0,0 +1,119 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env +// Builds the pristine upstream rhwp-studio (from third_party/rhwp) into +// apps/studio-host/dist — the web bundle the deno-desktop shell serves. +// +// Why a build script instead of a normal Vite project: openhwp embeds the +// UNMODIFIED upstream studio (no source overrides yet), so we build upstream's +// own project in place. We supply pkg/ from the vendored @rhwp/core (skipping +// the Rust/wasm-pack step), disable the PWA service worker (pointless offline + +// it would intercept fetches under the headless driver), and drop the bundled +// sample docs. The upstream tree is restored afterward so re-runs stay clean. +// +// deno run -A scripts/build-studio.ts + +const ROOT = `${import.meta.dirname}/../`; +const SUB = `${ROOT}third_party/rhwp`; +const STUDIO = `${SUB}/rhwp-studio`; +const VENDOR = `${ROOT}apps/studio-host/vendor/rhwp-core`; +const OUT = `${ROOT}apps/studio-host/dist`; +const CORE_FILES = [ + "rhwp.js", + "rhwp_bg.wasm", + "rhwp.d.ts", + "rhwp_bg.wasm.d.ts", + "package.json", +]; + +async function exists(path: string): Promise { + return await Deno.stat(path).then(() => true).catch(() => false); +} + +async function removeIfExists(path: string): Promise { + // Ignore a missing path, but surface a real removal failure (permissions, a + // busy file) instead of silently packaging/renaming broken output over it. + try { + await Deno.remove(path, { recursive: true }); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } +} + +async function run(bin: string, args: string[], cwd: string): Promise { + console.log( + `[build-studio] ${bin} ${args.join(" ")} (cwd: ${cwd.replace(ROOT, "")})`, + ); + const { code } = await new Deno.Command(bin, { + args, + cwd, + stdout: "inherit", + stderr: "inherit", + }) + .output(); + if (code !== 0) throw new Error(`${bin} ${args.join(" ")} exited ${code}`); +} + +async function copyInto( + srcDir: string, + destDir: string, + files: string[], +): Promise { + await Deno.mkdir(destDir, { recursive: true }); + for (const f of files) { + await Deno.copyFile(`${srcDir}/${f}`, `${destDir}/${f}`); + } +} + +if (!await exists(`${STUDIO}/vite.config.ts`)) { + console.error( + "[build-studio] third_party/rhwp is missing. Run: deno run -A scripts/setup-rhwp.ts", + ); + Deno.exit(1); +} + +// 1. Supply pkg/ from the vendored core (the studio's `@wasm` alias → ../pkg). +await copyInto(VENDOR, `${SUB}/pkg`, CORE_FILES); +// 2. The upstream deploy recipe also copies the engine into public/. +await copyInto(`${SUB}/pkg`, `${STUDIO}/public`, ["rhwp_bg.wasm", "rhwp.js"]); +// 3. Drop bundled sample documents (demo content; host drives file loading). +await removeIfExists(`${STUDIO}/public/samples`); +// 4. Install the studio's build deps from its committed lockfile. Run every +// build (not only when node_modules is absent) so a re-pin to a different +// upstream commit reconciles deps to that commit's lockfile instead of +// building against a stale tree; npm is a fast no-op when already in sync. +await run("npm", ["install", "--no-audit", "--no-fund"], STUDIO); + +// 5. Disable the PWA service worker for this build, then restore the config. +const cfgPath = `${STUDIO}/vite.config.ts`; +const cfgOrig = await Deno.readTextFile(cfgPath); +const cfgPatched = cfgOrig.replace( + "VitePWA({", + "VitePWA({\n disable: true, // openhwp: no service worker\n", +); +// String.replace is a no-op (not an error) when the literal is absent — guard +// against silently shipping a service worker if upstream reformats the config. +if (cfgPatched === cfgOrig && cfgOrig.includes("VitePWA(")) { + throw new Error( + "[build-studio] could not disable VitePWA — upstream vite.config format " + + "changed; update the patch in scripts/build-studio.ts", + ); +} +await Deno.writeTextFile(cfgPath, cfgPatched); +let buildError: unknown; +try { + await run("npx", ["vite", "build", "--base=/"], STUDIO); +} catch (err) { + buildError = err; +} +// Always restore the config, but never let a restore failure replace the build +// error (a plain finally would mask the real cause). +await Deno.writeTextFile(cfgPath, cfgOrig).catch((restoreErr) => { + console.error( + `[build-studio] WARNING: failed to restore ${cfgPath}: ${restoreErr}`, + ); +}); +if (buildError) throw buildError; + +// 6. Move the freshly built bundle to apps/studio-host/dist. +await removeIfExists(OUT); +await Deno.rename(`${STUDIO}/dist`, OUT); +console.log(`[build-studio] done → ${OUT.replace(ROOT, "")}`); diff --git a/scripts/setup-rhwp.ts b/scripts/setup-rhwp.ts new file mode 100644 index 0000000..c862199 --- /dev/null +++ b/scripts/setup-rhwp.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env -S deno run --allow-read --allow-run +// Materializes the upstream rhwp checkout at third_party/rhwp, pinned to the +// commit recorded in config/rhwp-studio-overrides.json. +// +// third_party/rhwp is NOT committed (it is gitignored). The full edwardkim/rhwp +// repo is ~1.1 GB (samples/, pdf/, mydocs/), so this uses a blob-filtered, +// cone-sparse checkout of only the paths the studio build needs (rhwp-studio + +// assets) — ~80 MB including the partial .git. Re-run after changing the pin. +// +// It is a disposable materialized checkout: build-studio.ts mutates it in place +// (copies pkg/, drops public/samples, npm rewrites the lock), so setup force- +// checks-out the pin on every run to discard that drift and reconcile the sparse +// paths — a plain checkout would refuse to re-pin over the dirty worktree. +// +// deno run --allow-read --allow-run scripts/setup-rhwp.ts + +const ROOT = `${import.meta.dirname}/../`; +const DEST = `${ROOT}third_party/rhwp`; + +interface Upstream { + repo: string; + tag: string; + commit: string; + sparsePaths: string[]; +} + +// The manifest is committed and PR-reviewed, but a hand-edit that drops a field +// or leaves an empty commit would otherwise flow `undefined`/`""` straight into +// git args (checking out the wrong thing, or an empty sparse set). Validate up +// front so a bad manifest fails with a clear message instead of a git error. +function parseManifest(raw: string): Upstream { + const manifest = JSON.parse(raw) as { upstream?: Partial }; + const u = manifest.upstream ?? {}; + const str = (k: "repo" | "tag" | "commit"): string => { + const v = u[k]; + if (typeof v !== "string" || v.length === 0) { + throw new Error(`manifest upstream.${k} must be a non-empty string`); + } + return v; + }; + const repo = str("repo"); + const tag = str("tag"); + const commit = str("commit"); + if (!/^[0-9a-f]{40}$/.test(commit)) { + throw new Error( + `manifest upstream.commit must be a full 40-char SHA, got "${commit}"`, + ); + } + const { sparsePaths } = u; + if ( + !Array.isArray(sparsePaths) || sparsePaths.length === 0 || + !sparsePaths.every((p) => typeof p === "string" && p.length > 0) + ) { + throw new Error("manifest upstream.sparsePaths must be a non-empty string[]"); + } + return { repo, tag, commit, sparsePaths }; +} + +const { repo, tag, commit, sparsePaths } = parseManifest( + await Deno.readTextFile(`${ROOT}config/rhwp-studio-overrides.json`), +); + +async function git(args: string[], cwd = ROOT): Promise { + const cmd = new Deno.Command("git", { + args, + cwd, + stdout: "piped", + stderr: "piped", + }); + const { code, stdout, stderr } = await cmd.output(); + if (code !== 0) { + throw new Error( + `git ${args.join(" ")} failed:\n${new TextDecoder().decode(stderr)}`, + ); + } + return new TextDecoder().decode(stdout).trim(); +} + +async function exists(path: string): Promise { + return await Deno.stat(path).then(() => true).catch(() => false); +} + +if (await exists(`${DEST}/.git`)) { + // Fetch the pinned commit, then force it (discarding any in-place build + // mutations — --force only touches tracked files, so node_modules/pkg + // survive) and reconcile the sparse paths. The partial clone only has history + // up to clone time, so a manifest bump to a newer upstream commit isn't local + // yet — fetch it first (blobless; GitHub serves a tag-reachable SHA directly). + // Idempotent: an already-present commit fetches as a fast no-op. + console.log(`[setup-rhwp] ensuring third_party/rhwp @ ${commit}`); + await git(["fetch", "--filter=blob:none", "origin", commit], DEST); + await git(["checkout", "--force", "--detach", commit], DEST); + await git(["sparse-checkout", "set", ...sparsePaths], DEST); +} else { + console.log(`[setup-rhwp] sparse partial clone ${repo} @ ${tag} (${commit})`); + await git([ + "clone", + "--filter=blob:none", + "--no-checkout", + "--quiet", + repo, + DEST, + ]); + await git(["sparse-checkout", "init", "--cone"], DEST); + await git(["sparse-checkout", "set", ...sparsePaths], DEST); + await git(["checkout", "--detach", commit], DEST); +} + +const head = await git(["rev-parse", "HEAD"], DEST); +if (head !== commit) { + console.error(`[setup-rhwp] FAILED: HEAD ${head} != pinned ${commit}`); + Deno.exit(1); +} +console.log( + `[setup-rhwp] ready: third_party/rhwp @ ${head} (sparse: ${sparsePaths.join(", ")})`, +); diff --git a/src/ui/app.js b/src/ui/app.js deleted file mode 100644 index 414469c..0000000 --- a/src/ui/app.js +++ /dev/null @@ -1,140 +0,0 @@ -// OpenHWP webview app (Phase 1: viewer). -// -// Runs inside the desktop window's Chromium (CEF) webview. It: -// 1. loads the rhwp WASM engine (@rhwp/core), -// 2. opens a .hwp/.hwpx file through the File System Access API, and -// 3. renders pages to SVG with HwpDocument.renderPageSvg(). -// -// Host (Deno) functions are exposed as `bindings.()`; native menu clicks -// are forwarded here by main.ts via `globalThis.openhwp.onMenu(id)`. - -const viewer = document.getElementById("viewer"); -const statusEl = document.getElementById("status"); -const pager = document.querySelector(".pager"); -const pageIndicator = document.getElementById("page-indicator"); -const prevBtn = document.getElementById("prev"); -const nextBtn = document.getElementById("next"); - -const state = { - doc: null, - handle: null, - page: 0, - pageCount: 0, -}; - -function setStatus(message) { - statusEl.textContent = message; -} - -// rhwp needs a text-measurement callback for layout. It must exist on -// globalThis before the engine initializes. -globalThis.measureTextWidth = (font, text) => { - const ctx = globalThis.__measureCtx ?? - (globalThis.__measureCtx = document.createElement("canvas").getContext("2d")); - ctx.font = font; - return ctx.measureText(text).width; -}; - -let enginePromise; -function loadEngine() { - enginePromise ??= (async () => { - // Vendored locally (src/ui/vendor/rhwp) — no CDN at runtime. `default()` - // loads rhwp_bg.wasm relative to rhwp.js, i.e. from the same vendor dir. - const mod = await import("./vendor/rhwp/rhwp.js"); - await mod.default(); // init WASM - return mod; - })(); - return enginePromise; -} - -async function openFile() { - if (!globalThis.showOpenFilePicker) { - setStatus("File System Access API is unavailable in this webview."); - return; - } - let handle; - try { - [handle] = await globalThis.showOpenFilePicker({ - types: [{ - description: "Hancom documents", - accept: { "application/octet-stream": [".hwp", ".hwpx"] }, - }], - }); - } catch (err) { - if (err?.name === "AbortError") return; // user cancelled the picker - throw err; - } - - setStatus("Loading engine…"); - const { HwpDocument } = await loadEngine(); - - setStatus("Opening document…"); - const file = await handle.getFile(); - const bytes = new Uint8Array(await file.arrayBuffer()); - const doc = new HwpDocument(bytes); - - state.doc = doc; - state.handle = handle; - state.pageCount = doc.pageCount(); - state.page = 0; - renderPage(); - setStatus(file.name); -} - -function renderPage() { - const { doc, page, pageCount } = state; - if (!doc) return; - // SECURITY: renderPageSvg() returns SVG built from an untrusted document, so - // this trusts the rhwp engine to escape document-derived content. TODO(Phase 2): - // sanitize before injection (or confirm rhwp's output is safe). The CSP in - // index.html is the defense-in-depth backstop. - viewer.innerHTML = doc.renderPageSvg(page); - pager.hidden = pageCount <= 1; - prevBtn.disabled = page <= 0; - nextBtn.disabled = page >= pageCount - 1; - // #page-indicator is an aria-live region (index.html) — give it explicit - // context so screen readers announce the page change. - pageIndicator.textContent = `Page ${page + 1} of ${pageCount}`; -} - -function turnPage(delta) { - if (!state.doc) return; - const next = Math.min(Math.max(state.page + delta, 0), state.pageCount - 1); - if (next === state.page) return; - state.page = next; - renderPage(); -} - -const reportError = (err) => setStatus(`Error: ${err?.message ?? err}`); - -document.getElementById("open").addEventListener("click", () => openFile().catch(reportError)); -prevBtn.addEventListener("click", () => turnPage(-1)); -nextBtn.addEventListener("click", () => turnPage(1)); - -// Bridge for the native application menu (main.ts forwards clicks here). -globalThis.openhwp = { - onMenu(id) { - switch (id) { - case "open": - openFile().catch(reportError); - break; - case "save": - case "save-as": - // TODO(Phase 2): write back through the retained FileSystemFileHandle. - setStatus("Saving is not implemented yet."); - break; - } - }, -}; - -// Show runtime info from the host binding, when available. The optional chain -// tolerates environments with no host bridge (e.g. the headless driver); once -// the binding exists, a rejection is a real failure, so log it instead of -// swallowing. NOTE: on Deno 2.9.3 this binding can reject due to an upstream -// desktop bug (denoland/deno#36033, fixed by #36065 — not yet in a stable -// release); the status line then just omits the runtime info, which is non-fatal. -if (globalThis.bindings?.hostInfo) { - globalThis.bindings.hostInfo() - .then((info) => setStatus(`OpenHWP · Deno ${info.denoVersion} · ${info.platform}`)) - .catch((err) => console.warn("hostInfo binding failed:", err)); -} diff --git a/src/ui/index.html b/src/ui/index.html deleted file mode 100644 index 3af0d82..0000000 --- a/src/ui/index.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - OpenHWP - - - -
- - - -
-
-

Open a Hancom document to view it.

-
- - - diff --git a/src/ui/styles.css b/src/ui/styles.css deleted file mode 100644 index bf62ae2..0000000 --- a/src/ui/styles.css +++ /dev/null @@ -1,69 +0,0 @@ -:root { - color-scheme: light dark; - --bg: Canvas; - --fg: CanvasText; - --border: color-mix(in srgb, CanvasText 15%, transparent); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - font: 14px/1.4 system-ui, sans-serif; - color: var(--fg); - background: var(--bg); - height: 100vh; - display: flex; - flex-direction: column; -} - -.toolbar { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 12px; - border-bottom: 1px solid var(--border); -} - -.toolbar button { - font: inherit; - padding: 4px 10px; - cursor: pointer; -} - -/* `:not([hidden])` so the `hidden` attribute (toggled from app.js) actually - hides the pager — a bare `.pager { display: flex }` would override the UA - `[hidden] { display: none }` rule. */ -.pager:not([hidden]) { - display: flex; - align-items: center; - gap: 6px; -} - -.status { - margin-left: auto; - opacity: 0.7; -} - -.viewer { - flex: 1; - overflow: auto; - display: flex; - justify-content: center; - padding: 24px; -} - -.viewer svg { - max-width: 100%; - height: auto; - box-shadow: 0 1px 8px rgb(0 0 0 / 15%); -} - -.placeholder { - margin: auto; - /* Opaque adaptive color that meets AA in both schemes — `opacity: 0.5` on - CanvasText composited to ~3.95:1, below the 4.5:1 minimum for body text. */ - color: color-mix(in srgb, CanvasText 60%, Canvas); -} diff --git a/src/ui/vendor/rhwp/README.md b/src/ui/vendor/rhwp/README.md deleted file mode 100644 index c3fc641..0000000 --- a/src/ui/vendor/rhwp/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Vendored `@rhwp/core` - -The rhwp document engine, vendored locally so the app runs offline and does not -fetch executable code or WASM from a third-party CDN at runtime. - -| | | -| --- | --- | -| Package | [`@rhwp/core`](https://www.npmjs.com/package/@rhwp/core) | -| Version | `0.7.19` | -| Source | npm registry tarball (`npm pack @rhwp/core@0.7.19`) | -| License | MIT (see `LICENSE`) | - -## Files - -- `rhwp.js` — wasm-bindgen `--target web` ESM glue. `default()` loads - `rhwp_bg.wasm` relative to its own URL, so the two files must stay together. -- `rhwp_bg.wasm` — the engine binary. -- `rhwp.d.ts`, `rhwp_bg.wasm.d.ts` — type declarations (reference only). -- `LICENSE` — upstream MIT license. - -## Updating - -```bash -npm pack @rhwp/core@ -tar xzf rhwp-core-.tgz -cp package/{rhwp.js,rhwp_bg.wasm,rhwp.d.ts,rhwp_bg.wasm.d.ts,LICENSE} src/ui/vendor/rhwp/ -``` - -Then bump the version in this file and re-run the `run-openhwp` driver to confirm -the engine still loads and renders. Do not hand-edit the vendored files.