From abcda28f42047fb87ae313cbb2d594a14fe930d2 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 29 Jul 2026 10:10:22 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(studio):=20make=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=E2=86=92=20=EC=9D=B8=EC=87=84=20work=20in=20the=20desktop=20ap?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Printing reported "팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요." and nothing happened. Upstream's file:print builds its preview into a popup: const printWin = window.open('', '_blank'); if (!printWin) { alert('팝업이 차단되었습니다. ...'); return; } but deno desktop's CEF backend never creates popup windows. Probed against the real host: window.open('', '_blank') returns null both with and without a user gesture, so it is not a popup blocker a gesture can satisfy, and Deno.BrowserWindow exposes no popup hook for the shell to opt in with. Printing itself is fine — the system print dialog opens and PrintCore is loaded — so the only missing piece is something for window.open() to return. Add apps/studio-host/shims/openhwp-popup.js: window.open() for a blank target now returns a same-origin about:blank iframe overlaid on the window, which supplies everything the caller uses — .document to build into, .print() to print just that document, .close() to dismiss. Popups with a real URL keep the host's behaviour so link targets never change silently. Upstream's print stylesheet already suits this: @media screen styles the document as a centred preview with a fixed toolbar, and @media print hides the toolbar. So the overlay reproduces the intended preview rather than approximating it. This is additive — the studio is still built unmodified and config/rhwp-studio-overrides.json stays empty. build-studio.ts copies the shim into dist/ and injects the script tag, throwing if is absent rather than silently shipping a bundle that shows the popup alert again. Verified in a real CEF build serving the actual studio bundle: window.open -> Window, overlay present, document writable, print is a function, close() removes the overlay; and the preview renders — overlay 1100x772 filling the window, toolbar 36px with both buttons, A4 pages at 794x1123 on white with the SVG painted. The injection guard was confirmed to throw (deno exits 1) when the anchor is missing. --- apps/studio-host/README.md | 8 +- apps/studio-host/shims/openhwp-popup.js | 101 ++++++++++++++++++++++++ scripts/build-studio.ts | 27 +++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 apps/studio-host/shims/openhwp-popup.js diff --git a/apps/studio-host/README.md b/apps/studio-host/README.md index de90aca..64eb6bc 100644 --- a/apps/studio-host/README.md +++ b/apps/studio-host/README.md @@ -6,14 +6,20 @@ 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 +(Desktop-native integration — native menu ↔ editor, PDF export — will arrive as overrides under `src/`, tracked in `config/rhwp-studio-overrides.json`.) +One host gap is patched additively, without touching upstream source: `shims/openhwp-popup.js` +replaces `window.open()`, because the CEF backend never creates popup windows and upstream's 파일 → +인쇄 builds its preview into one. `scripts/build-studio.ts` copies the shim into `dist/` and injects +a ``; const CORE_FILES = [ "rhwp.js", "rhwp_bg.wasm", @@ -116,4 +121,26 @@ if (buildError) throw buildError; // 6. Move the freshly built bundle to apps/studio-host/dist. await removeIfExists(OUT); await Deno.rename(`${STUDIO}/dist`, OUT); + +// 7. Install the window.open shim (see the shim for why the CEF host needs it). +// Done on the built output rather than on upstream's source, so the studio +// itself stays unmodified. A classic (non-module) script tag mirrors what +// upstream already does for theme-init.js and runs before the deferred module +// bundle, which is what the shim needs. A separate file rather than an inline +// script: it stays a real source file (linted, formatted, reviewable) and +// survives a script-src CSP if one is ever added. +await Deno.copyFile(SHIM_SRC, `${OUT}/${SHIM_NAME}`); +const htmlPath = `${OUT}/index.html`; +const htmlOrig = await Deno.readTextFile(htmlPath); +const htmlPatched = htmlOrig.replace("", ` ${SHIM_TAG}\n`); +// Fail loudly rather than shipping a bundle where 파일 → 인쇄 silently reports +// "팝업이 차단되었습니다" again. +if (htmlPatched === htmlOrig) { + throw new Error( + `[build-studio] could not inject ${SHIM_NAME} into index.html — no ` + + "found; update the injection in scripts/build-studio.ts", + ); +} +await Deno.writeTextFile(htmlPath, htmlPatched); + console.log(`[build-studio] done → ${OUT.replace(ROOT, "")}`); From 1ac16f766acb8b34381b009a5d28892b184e97ae Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 29 Jul 2026 10:25:44 +0900 Subject: [PATCH 2/5] fix(studio): shim same-origin popups too, not just blank ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream v0.8.x reworked printing. file:print no longer opens a blank popup and builds into it — it opens print.html: const previewWindow = hostWindow.open(surfaceUrl, '_blank'); if (!previewWindow) return Promise.reject(new PrintPreviewBlockedError()); That is a real same-origin URL, so a blank-only shim would pass it straight through to the host, get null back, and silently restore the exact bug this fixes the moment the pin moves to 0.8.x. Handle same-origin URLs as well, still passing cross-origin popups to the host so external link targets never change silently. A navigated frame needs more than the previous approach gave it. Both shortcuts that worked for a blank popup break once the frame loads a URL: an own `close` property set on the window is discarded along with that window, and a `load` listener registered on it never fires, because the document that finishes loading belongs to the *next* window. So return a façade instead — reads forward to whichever window the frame currently holds, and `load` is served by the frame element, which survives navigation. Verified in a real CEF build against a faithful replica of v0.8.2's createPrintPreviewSurface: the surface resolves (the load event does reach the caller through the façade), document is the loaded page, origin and href match, print is a function, fonts.ready + requestAnimationFrame work (the waitForPrintSurfaceReady contract), the closed flag transitions, and close() removes the overlay. The v0.7.19 blank path still passes unchanged, the 닫기 button dismisses through the façade, and a cross-origin popup still returns null with no overlay created. --- apps/studio-host/shims/openhwp-popup.js | 123 ++++++++++++++++-------- 1 file changed, 85 insertions(+), 38 deletions(-) diff --git a/apps/studio-host/shims/openhwp-popup.js b/apps/studio-host/shims/openhwp-popup.js index b1373d9..51edbfe 100644 --- a/apps/studio-host/shims/openhwp-popup.js +++ b/apps/studio-host/shims/openhwp-popup.js @@ -6,17 +6,22 @@ // deno 2.9.3's CEF backend). Nothing in `Deno.BrowserWindow` exposes a // popup/new-window hook either, so the host cannot opt in. // -// The studio's 파일 → 인쇄 (`file:print`) opens a blank popup and builds the -// rendered pages into it, so in the desktop app it only ever reached its +// The studio's 파일 → 인쇄 (`file:print`) opens a popup and builds the rendered +// pages into it, so in the desktop app it only ever reached its // "팝업이 차단되었습니다" fallback — printing was impossible. Printing itself // works fine here (the system print dialog opens as usual); the only missing // piece is something for window.open() to return. // -// So return a same-origin about:blank iframe overlaid on the window. It -// provides everything the caller uses — `.document` to build into, `.print()` -// to print just that document, `.close()` to dismiss — and upstream's print -// stylesheet is already written for this: `@media screen` styles it as a -// centred preview with a fixed toolbar, and `@media print` hides the toolbar. +// So return a same-origin iframe overlaid on the window. It provides everything +// the caller uses — a document to build into, print() to print just that +// document, close() to dismiss — and upstream's print stylesheet is already +// written for this: `@media screen` styles it as a centred preview with a fixed +// toolbar, and `@media print` hides the toolbar. +// +// Blank *and* same-origin URL popups are handled. The pinned studio (v0.7.19) +// opens a blank popup and builds into it; v0.8.x instead opens `print.html` — +// a real same-origin URL — so a blank-only shim would let a future upstream +// bump silently restore the original bug. // // This is additive, not an upstream source override: the studio is still built // unmodified, and `config/rhwp-studio-overrides.json` stays empty. @@ -25,16 +30,61 @@ const nativeOpen = window.open; - function isBlank(url) { - return url === undefined || url === null || url === "" || - String(url) === "about:blank"; + // Blank, or same-origin: both are ours to host. A cross-origin popup is left + // to the host, so this never silently changes where an external link goes — + // it still returns null there, which callers already have to handle. + function resolveShimmable(url) { + if (url === undefined || url === null || url === "") return ""; + const href = String(url); + if (href === "about:blank") return ""; + try { + const resolved = new URL(href, document.baseURI); + return resolved.origin === location.origin ? resolved.href : null; + } catch { + // Not a resolvable URL — leave it to the host rather than guessing. + return null; + } + } + + // The caller gets a façade rather than the iframe's own window, because both + // of the obvious shortcuts break once the frame navigates: an own `close` + // property set on the window is discarded with that window, and a `load` + // listener registered on it never fires, since the document that finishes + // loading belongs to the *next* window. Reads forward to whichever window the + // frame currently holds; `load` is served by the frame element, which does + // survive navigation. + function facade(frame, state) { + return new Proxy({}, { + get(_target, prop) { + if (prop === "close") return state.dismiss; + if (prop === "closed") return state.closed; + const win = frame.contentWindow; + if (!win) return undefined; + if (prop === "addEventListener" || prop === "removeEventListener") { + return function (type, listener, options) { + const target = type === "load" ? frame : win; + return target[prop](type, listener, options); + }; + } + const value = win[prop]; + // Window methods throw if called with the façade as `this`. + return typeof value === "function" ? value.bind(win) : value; + }, + set(_target, prop, value) { + const win = frame.contentWindow; + if (win) win[prop] = value; + return true; + }, + has(_target, prop) { + const win = frame.contentWindow; + return win ? prop in win : false; + }, + }); } window.open = function (url) { - // Only blank popups are shimmed. A popup with a real URL is left to the - // host so this never silently changes where a link goes — it still - // returns null there, which callers already have to handle. - if (!isBlank(url)) { + const href = resolveShimmable(url); + if (href === null) { return nativeOpen.apply(window, arguments); } @@ -54,10 +104,10 @@ "background:#fff", "z-index:2147483647", ].join(";"); + if (href) frame.src = href; mount.appendChild(frame); - const win = frame.contentWindow; - if (!win) { + if (!frame.contentWindow) { // Nothing usable to hand back. Returning null keeps the caller's own // "popup blocked" path intact rather than failing further along on a // half-built object. @@ -65,37 +115,34 @@ return null; } - let closed = false; - function dismiss() { - if (closed) return; - closed = true; + const state = { closed: false, dismiss: null }; + state.dismiss = function dismiss() { + if (state.closed) return; + state.closed = true; document.removeEventListener("keydown", onKeydown, true); frame.remove(); - } + }; // The overlay has no window chrome, so Escape is the only way out if the // caller never renders a close control of its own. Upstream's print view - // does render 닫기, which calls close() below. + // does render 닫기, which calls close(). function onKeydown(event) { - if (event.key === "Escape") dismiss(); + if (event.key === "Escape") state.dismiss(); } document.addEventListener("keydown", onKeydown, true); - try { - win.document.addEventListener("keydown", onKeydown, true); - } catch { - // A cross-origin document would throw; blank popups never are, so this - // is only belt-and-braces. - } - - // Shadow close() on the pseudo-window. Window.prototype.close() would be a - // no-op for an iframe, leaving the overlay stuck over the editor. - try { - win.close = dismiss; - } catch { - // If the assignment is refused the overlay is still dismissable with - // Escape, so this stays non-fatal. + // Same listener inside the frame, so Escape works while it has focus. A + // navigated frame replaces its document, so re-register on every load. + function bindFrameEscape() { + try { + frame.contentWindow.document.addEventListener("keydown", onKeydown, true); + } catch { + // A cross-origin document would throw; these never are, so this is + // only belt-and-braces. + } } + bindFrameEscape(); + frame.addEventListener("load", bindFrameEscape); - return win; + return facade(frame, state); }; })(); From ed66243f5b6d016870a7b09c4010df6af4fa977a Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 29 Jul 2026 12:30:08 +0900 Subject: [PATCH 3/5] chore: apply AI code review suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied: match the anchor case-insensitively and tolerate trailing whitespace, since HTML end tags allow both. Verified against , , "" and "" — all inject — while a document with no end tag still trips the guard and fails the build. Not applied: moving the frame's Escape listener from the document to the window. The premise does not hold in CEF — measured, neither survives a document.open()/write(): both listener counts stay flat afterwards. What already handles it is the re-bind on the frame's load event, which document.close() fires. Comment updated to say so, since the old wording credited only navigation. --- apps/studio-host/shims/openhwp-popup.js | 6 ++++-- scripts/build-studio.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/studio-host/shims/openhwp-popup.js b/apps/studio-host/shims/openhwp-popup.js index 51edbfe..f5865c1 100644 --- a/apps/studio-host/shims/openhwp-popup.js +++ b/apps/studio-host/shims/openhwp-popup.js @@ -130,8 +130,10 @@ if (event.key === "Escape") state.dismiss(); } document.addEventListener("keydown", onKeydown, true); - // Same listener inside the frame, so Escape works while it has focus. A - // navigated frame replaces its document, so re-register on every load. + // Same listener inside the frame, so Escape works while it has focus. + // Re-registered on every load: anything that swaps the frame's document — + // a navigation, or a caller using document.open()/write(), which fires + // load too — drops listeners held on the old one, window-bound included. function bindFrameEscape() { try { frame.contentWindow.document.addEventListener("keydown", onKeydown, true); diff --git a/scripts/build-studio.ts b/scripts/build-studio.ts index 2f3d63e..e0c880c 100644 --- a/scripts/build-studio.ts +++ b/scripts/build-studio.ts @@ -132,7 +132,9 @@ await Deno.rename(`${STUDIO}/dist`, OUT); await Deno.copyFile(SHIM_SRC, `${OUT}/${SHIM_NAME}`); const htmlPath = `${OUT}/index.html`; const htmlOrig = await Deno.readTextFile(htmlPath); -const htmlPatched = htmlOrig.replace("", ` ${SHIM_TAG}\n`); +// HTML end tags are case-insensitive and may carry trailing whitespace, so +// match that way rather than failing the build over upstream's formatting. +const htmlPatched = htmlOrig.replace(/<\/head\s*>/i, ` ${SHIM_TAG}\n$&`); // Fail loudly rather than shipping a bundle where 파일 → 인쇄 silently reports // "팝업이 차단되었습니다" again. if (htmlPatched === htmlOrig) { From 0eaf9ba5c116383cb15b3726de06a3f091b2bd91 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 29 Jul 2026 12:42:09 +0900 Subject: [PATCH 4/5] chore: apply AI code review suggestions --- apps/studio-host/shims/openhwp-popup.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/studio-host/shims/openhwp-popup.js b/apps/studio-host/shims/openhwp-popup.js index f5865c1..c08d76f 100644 --- a/apps/studio-host/shims/openhwp-popup.js +++ b/apps/studio-host/shims/openhwp-popup.js @@ -39,7 +39,15 @@ if (href === "about:blank") return ""; try { const resolved = new URL(href, document.baseURI); - return resolved.origin === location.origin ? resolved.href : null; + // Protocol as well as origin: an opaque origin serializes to the string + // "null", so under one (a file:// page, a sandboxed frame) a javascript: + // or data: URL compares equal to the page's own origin and would be + // loaded into the frame. The studio is always served over http from the + // host, so this is a guard on a context we don't ship, not a live hole. + return resolved.origin === location.origin && + resolved.protocol === location.protocol + ? resolved.href + : null; } catch { // Not a resolvable URL — leave it to the host rather than guessing. return null; @@ -55,9 +63,16 @@ // survive navigation. function facade(frame, state) { return new Proxy({}, { - get(_target, prop) { + get(_target, prop, receiver) { if (prop === "close") return state.dismiss; if (prop === "closed") return state.closed; + // On a real window these three are self-references. Forwarding them + // would hand out the frame's raw window and route around everything + // above it — `popup.self.close()` would reach the iframe's own close(), + // a no-op, and strand the overlay. + if (prop === "window" || prop === "self" || prop === "frames") { + return receiver; + } const win = frame.contentWindow; if (!win) return undefined; if (prop === "addEventListener" || prop === "removeEventListener") { From 8d0673c47ba9bfb7bb2be3a16ddffb5281220802 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 29 Jul 2026 13:18:07 +0900 Subject: [PATCH 5/5] chore: apply AI code review suggestions --- apps/studio-host/shims/openhwp-popup.js | 42 +++++++++++++++++++------ 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/studio-host/shims/openhwp-popup.js b/apps/studio-host/shims/openhwp-popup.js index c08d76f..cbc89ae 100644 --- a/apps/studio-host/shims/openhwp-popup.js +++ b/apps/studio-host/shims/openhwp-popup.js @@ -66,6 +66,10 @@ get(_target, prop, receiver) { if (prop === "close") return state.dismiss; if (prop === "closed") return state.closed; + // Same reason `load` is served by the frame element below: an onload + // handler parked on the current window is discarded when the frame + // navigates, so it would never fire for a URL popup. + if (prop === "onload") return frame.onload; // On a real window these three are self-references. Forwarding them // would hand out the frame's raw window and route around everything // above it — `popup.self.close()` would reach the iframe's own close(), @@ -86,6 +90,10 @@ return typeof value === "function" ? value.bind(win) : value; }, set(_target, prop, value) { + if (prop === "onload") { + frame.onload = value; + return true; + } const win = frame.contentWindow; if (win) win[prop] = value; return true; @@ -97,15 +105,7 @@ }); } - window.open = function (url) { - const href = resolveShimmable(url); - if (href === null) { - return nativeOpen.apply(window, arguments); - } - - const mount = document.body || document.documentElement; - if (!mount) return null; - + function createPopupIframe(href) { const frame = document.createElement("iframe"); frame.className = "openhwp-popup"; frame.setAttribute("title", "OpenHWP"); @@ -120,6 +120,30 @@ "z-index:2147483647", ].join(";"); if (href) frame.src = href; + return frame; + } + + // `_self`/`_parent`/`_top` navigate an existing window rather than opening + // one, so they are not what CEF blocks and must reach the real window.open. + // Anything else — no target, `_blank`, or a name — is a popup request, and a + // named one is still better served by the overlay than by the null the host + // would return. + function isPopupTarget(target) { + if (target === undefined || target === null) return true; + const name = String(target).toLowerCase(); + return name !== "_self" && name !== "_parent" && name !== "_top"; + } + + window.open = function (url, target) { + const href = resolveShimmable(url); + if (href === null || !isPopupTarget(target)) { + return nativeOpen.apply(window, arguments); + } + + const mount = document.body || document.documentElement; + if (!mount) return null; + + const frame = createPopupIframe(href); mount.appendChild(frame); if (!frame.contentWindow) {