Skip to content

멀티윈도우

This content is not available in your language yet.

Suji는 하나의 프로세스에서 여러 브라우저 창을 띄울 수 있음. WindowManager가 창 라이프사이클을 관리하고, 프론트엔드가 __suji__.core IPC로 조작.

import { windows } from '@suji/api';
const { windowId } = await windows.create({
title: 'Second Window',
url: 'http://localhost:12300/secondary',
});

suji.json windows[]와 동일한 옵션 셋을 받는다 — frame/transparent/parent/x/y/alwaysOnTop/resizable/min·maxWidth/min·maxHeight/fullscreen/backgroundColor/titleBarStyle 모두 런타임 지정 가능.

@suji/api를 못 쓰는 환경(예: 레거시/테스트)에서는 직접 호출:

const w = await window.__suji__.core(JSON.stringify({
cmd: 'create_window',
title: 'Second Window',
url: 'http://localhost:12300/secondary',
}));
// w = { from: "zig-core", cmd: "create_window", windowId: 2 }
await windows.create({
title: 'Settings',
name: 'settings', // WM 등록 이름 — 이후 event.window.name으로 식별
url: 'http://localhost:12300/settings',
});

같은 name으로 재호출하면 기존 창이 반환됨 (singleton 패턴).

await windows.create({
title: 'HUD',
url: 'http://localhost:12300/overlay',
frame: false, // frameless
transparent: true, // 투명 배경 (HTML body도 transparent여야)
parent: 'main', // 부모 창 이름 — native parent/child attach
alwaysOnTop: true,
resizable: false,
minWidth: 200,
maxWidth: 600,
backgroundColor: '#00000000',
titleBarStyle: 'hiddenInset',
});
import { windows } from '@suji/api';
await windows.setTitle(2, 'New Title');
// { from: "zig-core", cmd: "set_title", windowId: 2, ok: true }
await windows.setBounds(2, { x: 100, y: 100, width: 1200, height: 800 });
  • macOS: Cocoa 좌표 자동 변환 (화면 좌상단 기준 Y 입력)
  • CEF Views 경로: macOS / Linux / Windows 모두 CefWindow.set_bounds로 적용
  • macOS legacy fallback: Cocoa 좌표 변환 후 NSWindow bounds 적용

webContents — 네비게이션 / JS 실행

섹션 제목: “webContents — 네비게이션 / JS 실행”

Electron webContents.loadURL/reload/executeJavaScript/getURL/isLoading 대응. 모두 windowId 기반 — 어느 창이든 조작 가능.

windows.loadURL(id, url) — 새 페이지 로드

섹션 제목: “windows.loadURL(id, url) — 새 페이지 로드”
import { windows } from '@suji/api';
await windows.loadURL(2, 'https://example.com/');

windows.reload(id, ignoreCache?) — 현재 페이지 reload

섹션 제목: “windows.reload(id, ignoreCache?) — 현재 페이지 reload”
await windows.reload(2); // 일반 reload
await windows.reload(2, true); // disk 캐시 무시 (Electron reloadIgnoringCache)

windows.executeJavaScript(id, code) — 렌더러에서 임의 JS 실행

섹션 제목: “windows.executeJavaScript(id, code) — 렌더러에서 임의 JS 실행”
await windows.executeJavaScript(2, 'document.title = "Hello"');

fire-and-forget — 응답에 결과(eval value)는 없음, ok만 반환. 결과가 필요하면 JS 측에서 suji.send(channel, value)로 회신.

windows.getURL(id) — 현재 main frame URL

섹션 제목: “windows.getURL(id) — 현재 main frame URL”
const r = await windows.getURL(2);
// r = { ok: true, url: "https://example.com/" } 또는
// { ok: false, url: null } (캐시 미스 — OnAddressChange 갱신 전)

windows.isLoading(id) — 로딩 중 여부

섹션 제목: “windows.isLoading(id) — 로딩 중 여부”
const r = await windows.isLoading(2);
// r = { ok: true, loading: true | false }

같은 4-A API가 백엔드 4언어 SDK에도 노출되어 있어, handler 안에서 다른 창을 조작할 수 있다 (예: 메인 창의 핸들러가 settings 창을 reload).

// Zig
_ = suji.windows.loadURL(2, "https://example.com/");
_ = suji.windows.executeJavaScript(2, "console.log('hi')");
// Rust
suji::windows::load_url(2, "https://example.com/");
suji::windows::execute_javascript(2, "console.log('hi')");
// Go
import "github.com/ohah/suji-go/windows"
windows.LoadURL(2, "https://example.com/")
windows.ExecuteJavaScript(2, "console.log('hi')")
// Node
import { windows } from '@suji/node';
await windows.loadURL(2, 'https://example.com/');
await windows.executeJavaScript(2, "console.log('hi')");

응답은 모두 {from:"zig-core", cmd, windowId, ok} 형태 (get_url/is_loading은 추가 필드). caller가 JSON 파싱.

Cross-platform: CEF Views 경로에서는 macOS/Linux/Windows 모두 같은 windowId 기반 webContents API를 사용한다. 일부 외형 옵션은 플랫폼별 native window 지원 범위가 다르며, 세부 사항은 아래 외형/속성 표를 따른다.

Electron webContents.{setZoomLevel, getZoomLevel, setZoomFactor, getZoomFactor} 대응. CEF는 zoom_level만 노출 — factor는 코어가 pow(1.2, level) 변환 (Electron 호환).

import { windows } from '@suji/api';
await windows.setZoomLevel(2, 1.5); // logarithmic — 0=100%, 1=120%, -1=1/1.2
await windows.setZoomFactor(2, 1.2); // linear — 1.0=100%, 1.5=150%
const r = await windows.getZoomLevel(2); // { ok, level }
const f = await windows.getZoomFactor(2); // { ok, factor }
_ = suji.windows.setZoomFactor(2, 1.5);
_ = suji.windows.getZoomLevel(2);

참고: CEF set_zoom_level은 navigation 시점에 적용 deferred — 즉시 호출한 getZoomLevel이 cache된 0 반환 가능.

Electron webContents.{openDevTools, closeDevTools, isDevToolsOpened, toggleDevTools} 대응.

await windows.openDevTools(2); // 이미 열려있으면 멱등 no-op
await windows.closeDevTools(2);
const r = await windows.isDevToolsOpened(2); // { ok, opened }
await windows.toggleDevTools(2); // F12 단축키와 동일
_ = suji.windows.toggleDevTools(2);

F12 / Cmd+Shift+I 단축키: 누른 창의 DevTools만 토글 (멀티 윈도우 sender 식별).

DevTools 안에서 reload 키 (F5 / Cmd+R / Cmd+Shift+R): inspectee(원래 창)을 reload. Electron 호환 — DevTools front-end self-reload 대신 개발자가 진짜 reload하고 싶은 페이지 대상으로. 멀티 윈도우 동시 DevTools도 정확히 매핑 (각 DevTools → 자기 inspectee).

Electron webContents.{undo, redo, cut, copy, paste, selectAll, findInPage, stopFindInPage} 대응. 편집 6은 main frame에 위임, find는 cef_browser_host_t.

await windows.undo(2);
await windows.copy(2);
await windows.paste(2);
await windows.selectAll(2);
// 텍스트 검색 — 첫 호출은 findNext=false, 이후 같은 단어는 true.
await windows.findInPage(2, 'hello', { forward: true, matchCase: false, findNext: false });
await windows.stopFindInPage(2, /* clearSelection */ true);
_ = suji.windows.copy(2);
_ = suji.windows.findInPage(2, "hello", .{ .forward = true });

find 결과 보고: cef_find_handler_t.OnFindResultwindow:find-result 이벤트({windowId, identifier, count, activeMatchOrdinal})로 발신한다. 현재는 finalUpdate=true 결과만 노출한다.

미노출: set_user_agent 동적 변경은 CEF가 창 settings에 한 번만 노출하므로 현재 미지원.

Electron webContents.printToPDF 대응. CEF는 콜백 기반 async — 코어가 즉시 ok 응답하고 완료 시 window:pdf-print-finished 이벤트({path, success}) 발화. SDK가 path 매칭으로 Promise resolve.

import { windows } from '@suji/api';
const { success } = await windows.printToPDF(2, '/tmp/report.pdf');
if (success) console.log('saved');
// Zig — fire-and-forget 후 listener
_ = suji.windows.printToPDF(2, "/tmp/report.pdf");
// 별도 핸들러에서 `on("window:pdf-print-finished", ...)` 등록
let _ = suji::windows::print_to_pdf(2, "/tmp/report.pdf");
windows.PrintToPDF(2, "/tmp/report.pdf")

capturePage는 CEF 직접 미노출 → CDP Page.captureScreenshot + dev-tools observer 로 구현 완료(file-path 방식, window:page-captured 이벤트).

Linux 주의: PDF 인쇄용 cef_print_handler_t.get_pdf_paper_size(U.S. Letter) 등록 완료. macOS/Windows는 네이티브 인쇄라 무영향. Linux에서도 실제 PDF 파일 생성이 동작한다.

어느 창에서 호출됐는지 식별 — __window wire 자동 태깅

섹션 제목: “어느 창에서 호출됐는지 식별 — __window wire 자동 태깅”

프론트엔드가 __suji__.invoke('save', data)를 호출하면 수신 코어가 sender browser를 식별해 request JSON에 자동으로 두 필드를 주입:

{
"cmd": "save",
"content": "...",
"__window": 2, // 항상 — sender 창의 WM id
"__window_name": "settings", // 창에 name이 설정된 경우만
"__window_url": "http://localhost:12300/", // sender 창의 main frame URL (있을 때만)
"__window_main_frame": true // sender frame이 main인지 (false면 iframe)
}

백엔드는 이를 통해 “어느 창에서 호출됐는지”, “어느 페이지에서 호출됐는지” 분기 가능.

__window_url은 Electron event.sender.url과 대응. 4개 SDK 모두 event.window.url 필드로 노출 (Zig: ?[]const u8, Rust: Option<String>, Go: string(빈 = 없음), Node: string | null).

fn save(req: suji.Request, event: suji.InvokeEvent) suji.Response {
std.debug.print("save from id={d} name={s} url={s}\n", .{
event.window.id,
event.window.name orelse "(anonymous)",
event.window.url orelse "(none)",
});
if (event.window.name) |n| {
if (std.mem.eql(u8, n, "settings")) {
// settings 창 전용 분기
}
}
return req.ok(.{});
}

Rust / Go / Node SDK — InvokeEvent 편의 래퍼

섹션 제목: “Rust / Go / Node SDK — InvokeEvent 편의 래퍼”

4개 언어 SDK 모두에 InvokeEvent 자동 주입이 지원된다.

// Rust — proc macro가 파라미터 타입이 InvokeEvent면 wire에서 자동 파생
#[suji::handle]
fn save(filename: String, event: suji::InvokeEvent) -> serde_json::Value {
serde_json::json!({ "from_window": event.window.id })
}
// Go — reflect 경로가 *suji.InvokeEvent 타입을 감지
func (a *App) Save(text string, event *suji.InvokeEvent) suji.M {
return suji.M{ "from_window": event.Window.ID }
}
// Node — handler.length >= 2 이면 event 전달
const { handle } = require('@suji/node');
handle('save', (data, event) => ({
from_window: event.window.id,
}));

wire로 직접 파싱도 여전히 가능 (req.__window / req.__window_name). 편의 래퍼와 동등.

기본: suji.send('event', data)모든 창으로 브로드캐스트.

특정 창 하나에만 보내려면 세 번째 옵션 {to: winId} — Electron webContents.send 대응:

// Frontend
suji.send('toast', { text: 'saved' }, { to: 2 });
// Zig 백엔드
suji.sendTo(2, "toast", "{\"text\":\"saved\"}");
// Rust
suji::send_to(event.window.id, "toast", r#"{"text":"saved"}"#);
// Go
suji.SendTo(event.Window.ID, "toast", `{"text":"saved"}`)
// Node
const { sendTo } = require('@suji/node');
sendTo(event.window.id, 'toast', { text: 'saved' });
  • to가 생략되거나 0이면 broadcast.
  • 대상 창이 이미 닫혔으면 silent no-op (Electron과 동일).
  • 백엔드 리스너는 target 무관하게 항상 수신 — 필터링은 JS dispatch 레이어에서만. 이 정책 덕분에 로깅/감사 플러그인이 모든 send 이벤트를 놓치지 않음.

현재 별도 API 없음. suji.windows.all() 또는 core({cmd:"list_windows"}) 추가 예정.

// 창 생성됨
window.__suji__.on('window:created', (data) => {
// data = { id: 2, name: "settings" }
});
// 창 닫힘 직전 (cancelable)
window.__suji__.on('window:close', (data) => {
// preventDefault는 아직 renderer 측 미지원 — 백엔드에서만 가능
});
// 창 닫힘 완료
window.__suji__.on('window:closed', (data) => {
// data = { id: 2 }
});
// 모든 창 닫힘
window.__suji__.on('window:all-closed', () => {
// Electron canonical: macOS 제외하면 quit
});

선언적 옵션 (시작 시점 결정, 런타임 변경 X):

{
"windows": [
{ "name": "main", "title": "App", "width": 1024, "height": 768 },
{
"name": "panel",
"title": "Floating Panel",
"width": 320,
"height": 480,
"frame": false,
"transparent": true,
"parent": "main"
}
]
}
옵션의미플랫폼
frame: false타이틀바/리사이즈 핸들 없음 (Electron frame: false). 사용자가 커스텀 타이틀바 구현 필요macOS, Linux
transparent: true네이티브 창/View와 CEF browser background를 투명으로 설정. HTML body도 background: transparent 필요macOS, Linux
parent: "<name>"부모 창과 시각 관계를 맺음. macOS는 NSWindow child, Linux는 CEF Views parent Window 사용. 부모 close는 자식을 재귀 close하지 않음macOS, Linux
x: <number> / y: <number>명시 위치. 둘 다 0이면 OS cascade 자동 분산 (cascadeTopLeftFromPoint:)macOS, Linux
alwaysOnTop: true항상 다른 일반 창 위. 위젯/툴 팔레트용macOS, Linux
resizable: false사용자가 창 크기 조절 불가. 고정 크기 다이얼로그/팔레트macOS, Linux
minWidth / minHeight / maxWidth / maxHeight콘텐츠 영역 크기 제한 (0이면 제한 없음)macOS, Linux
fullscreen: true시작 시 전체화면 진입macOS, Linux
backgroundColor: "#RRGGBB" 또는 "#RRGGBBAA"16진수 색. transparent와 함께 쓰면 transparent 우선macOS, Linux
titleBarStyle: "hidden" 또는 "hiddenInset"타이틀바 hide + traffic light 유지 (Electron 호환). content view가 타이틀바 영역까지 확장macOS

parent 순서 주의: 부모는 windows[] 배열에서 자식보다 앞에 와야 함 (wm.fromName lookup 시점에 이미 생성돼 있어야). 잘못된 이름이면 stderr에 warn 로그 + parent_id=null로 폴백 (자식은 정상 생성, 시각 관계만 빠짐).

Linux/Windows: Linux는 CEF Views 경로에서 frame:false, drag/no-drag region, transparent, parent, alwaysOnTop, resizable, min/max, fullscreen, backgroundColor를 지원한다. Windows frameless parity는 향후 추가 예정이다.

frameless 사용 시 — drag region (-webkit-app-region)

섹션 제목: “frameless 사용 시 — drag region (-webkit-app-region)”

타이틀바가 없으면 창 이동 불가. HTML 측에 drag region을 명시해야 함. Electron/Tauri와 동일한 표준 — Chromium의 -webkit-app-region CSS 속성.

기본 패턴:

/* 드래그 가능 영역 (마우스 down + 이동으로 창 이동) */
.titlebar {
-webkit-app-region: drag;
app-region: drag; /* 표준 spec, 향후 호환 */
}
/* drag 영역 안의 버튼/입력은 클릭 가능하게 — drag 상속 차단 */
.titlebar button,
.titlebar input {
-webkit-app-region: no-drag;
app-region: no-drag;
}

커스텀 타이틀바 + 종료 버튼 풀 데모:

<div class="titlebar">
<div class="title">⌘ My Frameless App</div>
<div class="controls">
<button id="quit">×</button>
</div>
</div>
<style>
html, body { margin: 0; padding: 0; height: 100%; }
.titlebar {
-webkit-app-region: drag;
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 8px;
background: #2c2c2e;
border-bottom: 1px solid #3a3a3c;
}
.title { font-size: 12px; font-weight: 600; padding-left: 4px; color: #f5f5f7; }
.controls button {
-webkit-app-region: no-drag;
width: 24px; height: 22px;
border: none; border-radius: 3px;
background: #3a3a3c; color: #f5f5f7;
cursor: pointer; font-size: 13px;
}
.controls button:hover { background: #48484a; }
</style>
<script>
document.getElementById('quit').addEventListener('click', () => {
window.__suji__.quit();
});
</script>

주의:

  • drag region은 마우스 down 시점의 hit-test 결과로 결정. 동적으로 z-index를 바꿔서 drag가 가려져도 OS는 원래 영역을 쓴다 → CSS 클래스 토글로 drag/no-drag 전환은 즉시 반영 안 될 수 있음.
  • <input>, <select>, <a>, <button>은 drag region 안에 있어도 자동으로 no-drag가 되지는 않음 — 명시 필수.
  • macOS 신호등(min/max/close) 흉내가 필요하면 직접 SVG 그리거나 traffic light HTML 컴포넌트.

전체 동작 예제: examples/window-styles/ — 메인/frameless panel/transparent HUD 3창이 함께 떠 있는 데모. cd examples/window-styles && suji dev로 실행.

  • HTML body { background: transparent }로 받쳐야 OS 윈도우까지 비침. 안 그러면 페이지 배경색이 보인다.
  • 컴포지터 비용 증가 — 큰 영역 투명은 GPU 사용량/배터리에 영향.
  • macOS에서 투명 영역 클릭은 OS가 hit-test 통과 → 뒤 창에 전달. 마우스 이벤트가 필요한 영역은 불투명 색이라도 채워야.
  • focus ring/그림자가 사라지므로 active/inactive 시각 피드백을 사용자가 직접 처리.
  • id는 monotonic + non-reusing — 창을 닫아도 id 재사용 안 됨
  • JSON-unsafe name은 wire 주입 skip", \, control char 포함된 name은 __window_name이 생략됨 (id만 주입). JSON 파서 깨짐 방지
  • Cross-hop 보존 — 이미 "__window" 있는 request는 재주입 안 함 (백엔드-백엔드 호출 시 원 sender 정보 유지)
  • 부모-자식 재귀 close X — 부모 close해도 자식은 살아있음. orphan 정리는 destroyAll만