Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/studio-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script>` tag for it (step 7); the shim itself explains the reasoning.

## 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`. |
| `shims/` | yes | Host gap-fillers injected into the built bundle. Additive — not upstream source overrides. |
| `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`
Expand Down
189 changes: 189 additions & 0 deletions apps/studio-host/shims/openhwp-popup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// deno-lint-ignore-file no-window -- browser script, not a Deno module: `window` is the target.
// OpenHWP — window.open() shim for the deno-desktop (CEF) host.
//
// The CEF backend never creates popup windows: window.open() returns null
// unconditionally, with *and* without a user gesture (verified against
// 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 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 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.
(function () {
"use strict";

const nativeOpen = window.open;

// 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);
// 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 {
Comment thread
amondnet marked this conversation as resolved.
// 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, 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(),
// a no-op, and strand the overlay.
if (prop === "window" || prop === "self" || prop === "frames") {
return receiver;
}
const win = frame.contentWindow;
Comment thread
amondnet marked this conversation as resolved.
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);
};
}
Comment thread
amondnet marked this conversation as resolved.
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) {
if (prop === "onload") {
frame.onload = value;
return true;
}
const win = frame.contentWindow;
if (win) win[prop] = value;
return true;
},
has(_target, prop) {
const win = frame.contentWindow;
return win ? prop in win : false;
},
});
Comment thread
amondnet marked this conversation as resolved.
}

function createPopupIframe(href) {
const frame = document.createElement("iframe");
frame.className = "openhwp-popup";
frame.setAttribute("title", "OpenHWP");
frame.style.cssText = [
"position:fixed",
"inset:0",
"width:100%",
"height:100%",
"border:0",
"margin:0",
"background:#fff",
"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);
Comment thread
amondnet marked this conversation as resolved.

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.
frame.remove();
return null;
}

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().
function onKeydown(event) {
if (event.key === "Escape") state.dismiss();
}
Comment thread
amondnet marked this conversation as resolved.
document.addEventListener("keydown", onKeydown, true);
// 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);
} catch {
// A cross-origin document would throw; these never are, so this is
// only belt-and-braces.
}
}
bindFrameEscape();
frame.addEventListener("load", bindFrameEscape);

return facade(frame, state);
};
})();
29 changes: 29 additions & 0 deletions scripts/build-studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ 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`;
// The CEF host cannot open popup windows, so the built bundle gets a
// window.open shim injected (step 7).
const SHIM_NAME = "openhwp-popup.js";
const SHIM_SRC = `${ROOT}apps/studio-host/shims/${SHIM_NAME}`;
const SHIM_TAG = `<script src="/${SHIM_NAME}"></script>`;
const CORE_FILES = [
"rhwp.js",
"rhwp_bg.wasm",
Expand Down Expand Up @@ -116,4 +121,28 @@ 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);
// 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) {
throw new Error(
`[build-studio] could not inject ${SHIM_NAME} into index.html — no </head> ` +
"found; update the injection in scripts/build-studio.ts",
);
}
await Deno.writeTextFile(htmlPath, htmlPatched);

console.log(`[build-studio] done → ${OUT.replace(ROOT, "")}`);
Loading