Skip to content

Commit c27fb87

Browse files
committed
feat: Enhance BrowserWindow functionality and add new APIs
- Updated `index.js` in the hello-world example to include new window options such as position, visibility, and bounds manipulation. - Added context menu support with a custom HTML/CSS menu in the webview. - Implemented screenshot functionality that captures the window and saves it as a base64 PNG. - Introduced new window operations in `nanoframe-core` for setting bounds, getting bounds, and managing window states (maximize, unmaximize, restore). - Added support for minimum and maximum window sizes. - Enhanced IPC communication to handle webview messages and context menu events. - Updated TypeScript definitions in `nanoframe` to reflect new window options and methods.
1 parent 04b011d commit c27fb87

File tree

8 files changed

+796
-53
lines changed

8 files changed

+796
-53
lines changed

Cargo.lock

Lines changed: 524 additions & 38 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/hello-world/src/index.js

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,77 @@
11
import { app, BrowserWindow } from 'nanoframe';
2-
import { fileURLToPath } from "node:url";
3-
import { dirname } from "node:path";
2+
import { fileURLToPath } from 'node:url';
3+
import { dirname, join } from 'node:path';
4+
import { writeFile } from 'node:fs/promises';
45

56
const __filename = fileURLToPath(import.meta.url);
67
const __dirname = dirname(__filename);
78

89
async function main() {
10+
// Create hidden then show after we tweak bounds and sizes
911
const win = await BrowserWindow.create({
1012
title: 'Nanoframe Hello',
11-
width: 800,
12-
height: 600,
13-
url: "https://example.com",
13+
width: 1000,
14+
height: 700,
15+
x: 150,
16+
y: 100,
17+
show: false,
18+
minWidth: 800,
19+
minHeight: 500,
20+
url: 'https://example.com',
1421
});
1522

1623
await app.whenReady;
1724

1825
await win.setIcon(`${__dirname}/assets/logo.png`);
1926
await win.eval('console.log("Hello from nanoframe")');
20-
await win.openDevTools();
21-
await win.center();
2227
await win.setAlwaysOnTop(false);
28+
29+
// Demonstrate bounds API
30+
await win.setBounds({ width: 1024, height: 640 });
31+
const bounds = await win.getBounds();
32+
console.log('Bounds after resize:', bounds);
33+
34+
// Show it now
2335
await win.show();
36+
37+
// Enable a basic custom context menu hook
38+
await win.enableContextMenu();
39+
app.on('webviewIpc', async ({ windowId, payload }) => {
40+
if (windowId !== win.id) return;
41+
if (payload?.type === 'context-menu') {
42+
const { x, y } = payload.detail || {};
43+
// Inject a minimal in-page menu (pure HTML/CSS for demo). Click outside to dismiss.
44+
await win.eval(`(function(){
45+
const id='__nf_demo_menu';
46+
const old=document.getElementById(id); if (old) old.remove();
47+
const m=document.createElement('div');
48+
m.id=id; m.style.cssText='position:fixed; z-index:99999; background:#222; color:#eee; box-shadow:0 6px 24px rgba(0,0,0,.3); border:1px solid #444; font:14px system-ui;';
49+
m.style.left='${x}px'; m.style.top='${y}px';
50+
const item=(txt,cb)=>{ const it=document.createElement('div'); it.textContent=txt; it.style.cssText='padding:8px 14px; cursor:pointer; white-space:nowrap;'; it.onmouseenter=()=>it.style.background='#333'; it.onmouseleave=()=>it.style.background='transparent'; it.onclick=()=>{ cb(); cleanup(); }; return it; };
51+
const cleanup=()=>{ m.remove(); document.removeEventListener('click', cleanup, true); };
52+
m.appendChild(item('Say Hi', ()=>alert('Hello!')));
53+
m.appendChild(item('Open DevTools', ()=>window.open('about:blank')));
54+
document.body.appendChild(m);
55+
setTimeout(()=>document.addEventListener('click', cleanup, true), 0);
56+
})();`);
57+
}
58+
});
59+
60+
// Showcase maximize/unmaximize + restore
61+
await win.maximize();
62+
console.log('isMaximized:', await win.isMaximized());
63+
await win.unmaximize();
64+
await win.restore();
65+
66+
// Request user attention (Windows taskbar flash)
67+
await win.requestUserAttention(false);
68+
69+
// Take a screenshot (base64 PNG) and save to temp folder
70+
const { base64Png } = await win.screenshot();
71+
const { path: tempDir } = await app.getPath('temp');
72+
const out = join(tempDir, 'nanoframe-screenshot.png');
73+
await writeFile(out, Buffer.from(base64Png, 'base64'));
74+
console.log('Saved screenshot to', out);
2475
}
2576

2677
main().catch(err => {

packages/nanoframe-core/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ directories = "6"
2020
image = { version = "0.25", default-features = false, features = ["png", "ico"] }
2121
open = "5"
2222
arboard = "3"
23+
base64 = "0.22"
24+
screenshots = "0.8"
2325

2426
[features]
2527
default = []

packages/nanoframe-core/src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,22 @@ fn main() {
4949
"window.maximize" => window_ops::op_maximize(&mut app, req.params, id),
5050
"window.minimize" => window_ops::op_minimize(&mut app, req.params, id),
5151
"window.unminimize" => window_ops::op_unminimize(&mut app, req.params, id),
52+
"window.unmaximize" => window_ops::op_unmaximize(&mut app, req.params, id),
53+
"window.isMaximized" => window_ops::op_is_maximized(&mut app, req.params, id),
54+
"window.restore" => window_ops::op_restore(&mut app, req.params, id),
5255
"window.focus" => window_ops::op_focus(&mut app, req.params, id),
5356
"window.setTitle" => window_ops::op_set_title(&mut app, req.params, id),
5457
"window.setSize" => window_ops::op_set_size(&mut app, req.params, id),
5558
"window.getSize" => window_ops::op_get_size(&mut app, req.params, id),
59+
"window.setMinSize" => window_ops::op_set_min_size(&mut app, req.params, id),
60+
"window.setMaxSize" => window_ops::op_set_max_size(&mut app, req.params, id),
61+
"window.setBounds" => window_ops::op_set_bounds(&mut app, req.params, id),
62+
"window.getBounds" => window_ops::op_get_bounds(&mut app, req.params, id),
5663
"window.center" => window_ops::op_center(&mut app, req.params, id),
5764
"window.setAlwaysOnTop" => window_ops::op_set_always_on_top(&mut app, req.params, id),
5865
"window.setResizable" => window_ops::op_set_resizable(&mut app, req.params, id),
5966
"window.isVisible" => window_ops::op_is_visible(&mut app, req.params, id),
67+
"window.requestUserAttention" => window_ops::op_request_user_attention(&mut app, req.params, id),
6068
"window.setFullscreen" => window_ops::op_set_fullscreen(&mut app, req.params, id),
6169
"window.isFullscreen" => window_ops::op_is_fullscreen(&mut app, req.params, id),
6270
"window.setDecorations" => window_ops::op_set_decorations(&mut app, req.params, id),
@@ -65,6 +73,7 @@ fn main() {
6573
// Webview extras
6674
"webview.openDevtools" => window_ops::op_open_devtools(&mut app, req.params, id),
6775
"webview.postMessage" => window_ops::op_post_message(&mut app, req.params, id),
76+
"webview.screenshot" => window_ops::op_screenshot(&mut app, req.params, id),
6877
// Dialogs + app paths
6978
"dialog.open" => dialogs::op_open_dialog(&mut app, req.params, id),
7079
"dialog.save" => dialogs::op_save_dialog(&mut app, req.params, id),

packages/nanoframe-core/src/window_ops.rs

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,21 @@ struct CreateWindowParams {
1818
html: Option<String>,
1919
width: Option<u32>,
2020
height: Option<u32>,
21+
x: Option<i32>,
22+
y: Option<i32>,
23+
show: Option<bool>,
24+
min_width: Option<u32>,
25+
min_height: Option<u32>,
26+
max_width: Option<u32>,
27+
max_height: Option<u32>,
2128
icon_path: Option<String>,
2229
resizable: Option<bool>,
2330
always_on_top: Option<bool>,
2431
fullscreen: Option<bool>,
2532
decorations: Option<bool>,
2633
center: Option<bool>,
2734
preload: Option<String>,
35+
content_size: Option<bool>,
2836
}
2937

3038
pub fn create_window_with_target(
@@ -34,26 +42,54 @@ pub fn create_window_with_target(
3442
) -> Result<Value> {
3543
let p: CreateWindowParams = serde_json::from_value(params)?;
3644

45+
// Pre-generate window id so we can capture it in callbacks
46+
let id = Uuid::new_v4().to_string();
47+
3748
// Build tao window
3849
let mut wb = WindowBuilder::new();
3950
if let Some(title) = p.title { wb = wb.with_title(title); }
4051
if let (Some(w), Some(h)) = (p.width, p.height) { wb = wb.with_inner_size(tao::dpi::LogicalSize::new(w as f64, h as f64)); }
52+
if let (Some(x), Some(y)) = (p.x, p.y) { wb = wb.with_position(tao::dpi::LogicalPosition::new(x as f64, y as f64)); }
53+
// Combine min/max sizes if provided
54+
if p.min_width.is_some() || p.min_height.is_some() {
55+
let mw = p.min_width.unwrap_or(0) as f64;
56+
let mh = p.min_height.unwrap_or(0) as f64;
57+
wb = wb.with_min_inner_size(tao::dpi::LogicalSize::new(mw, mh));
58+
}
59+
if p.max_width.is_some() || p.max_height.is_some() {
60+
let mw = p.max_width.unwrap_or(u32::MAX) as f64;
61+
let mh = p.max_height.unwrap_or(u32::MAX) as f64;
62+
wb = wb.with_max_inner_size(tao::dpi::LogicalSize::new(mw, mh));
63+
}
4164
if let Some(icon_path) = p.icon_path.as_deref() { if let Ok(icon) = load_icon(icon_path) { wb = wb.with_window_icon(Some(icon)); } }
4265
if let Some(v) = p.resizable { wb = wb.with_resizable(v); }
4366
if let Some(v) = p.always_on_top { wb = wb.with_always_on_top(v); }
4467
if let Some(v) = p.fullscreen { if v { wb = wb.with_fullscreen(Some(tao::window::Fullscreen::Borderless(None))); } }
4568
if let Some(v) = p.decorations { wb = wb.with_decorations(v); }
4669

4770
let window = wb.build(target)?;
71+
if p.content_size.unwrap_or(false) {
72+
// noop placeholder: tao/wry works with inner size already
73+
}
4874

4975
// Build webview
5076
let mut wvb = WebViewBuilder::new();
5177
if let Some(script) = p.preload.as_deref() { wvb = wvb.with_initialization_script(script); }
5278
if let Some(url) = p.url { wvb = wvb.with_url(&url); }
5379
if let Some(html) = p.html { wvb = wvb.with_html(&html); }
54-
let webview = wvb.build(&window)?;
80+
let win_id_for_ipc = id.clone();
81+
let webview = wvb.with_ipc_handler({
82+
let tx = app.tx_out.clone();
83+
move |request: wry::http::Request<String>| {
84+
let body = request.body();
85+
let payload = serde_json::from_str::<serde_json::Value>(body).unwrap_or(json!({ "raw": body }));
86+
let _ = tx.send(RpcResponse::notify("webview.ipc", json!({ "windowId": win_id_for_ipc, "payload": payload })));
87+
}
88+
}).build(&window)?;
89+
90+
// Show window depending on flag (default true) BEFORE moving window
91+
if p.show.unwrap_or(true) { window.set_visible(true); } else { window.set_visible(false); }
5592

56-
let id = Uuid::new_v4().to_string();
5793
app.windows.insert(id.clone(), window);
5894
app.webviews.insert(id.clone(), webview);
5995

@@ -62,6 +98,8 @@ pub fn create_window_with_target(
6298
let _ = op_center(app, json!({"windowId": id.clone()}), RpcId::Null);
6399
}
64100

101+
// Visibility already set above
102+
65103
Ok(json!({ "windowId": id }))
66104
}
67105

@@ -232,6 +270,43 @@ pub fn op_get_position(app: &mut App, params: Value, id: RpcId) {
232270
}
233271
}
234272

273+
#[derive(Debug, Deserialize)]
274+
#[serde(rename_all = "camelCase")]
275+
struct BoundsParams { window_id: String, x: Option<i32>, y: Option<i32>, width: Option<u32>, height: Option<u32> }
276+
277+
pub fn op_set_bounds(app: &mut App, params: Value, id: RpcId) {
278+
match serde_json::from_value::<BoundsParams>(params) {
279+
Ok(p) => {
280+
if let Some(win) = app.windows.get(&p.window_id) {
281+
use tao::dpi::{PhysicalPosition, PhysicalSize};
282+
if let (Some(x), Some(y)) = (p.x, p.y) { win.set_outer_position(PhysicalPosition::new(x, y)); }
283+
if let (Some(w), Some(h)) = (p.width, p.height) { win.set_inner_size(PhysicalSize::new(w, h)); }
284+
let _ = app.tx_out.send(RpcResponse::result(id, json!(true)));
285+
} else { let _ = app.tx_out.send(RpcResponse::error(id, -32001, "Window not found".into())); }
286+
}
287+
Err(e) => { let _ = app.tx_out.send(RpcResponse::error(id, -32602, e.to_string())); }
288+
}
289+
}
290+
291+
pub fn op_get_bounds(app: &mut App, params: Value, id: RpcId) {
292+
match serde_json::from_value::<WithWindowIdOnly>(params) {
293+
Ok(p) => {
294+
if let Some(win) = app.windows.get(&p.window_id) {
295+
let pos = win.outer_position().ok();
296+
let size = win.inner_size();
297+
let res = json!({
298+
"x": pos.as_ref().map(|p| p.x),
299+
"y": pos.as_ref().map(|p| p.y),
300+
"width": size.width,
301+
"height": size.height,
302+
});
303+
let _ = app.tx_out.send(RpcResponse::result(id, res));
304+
} else { let _ = app.tx_out.send(RpcResponse::error(id, -32001, "Window not found".into())); }
305+
}
306+
Err(e) => { let _ = app.tx_out.send(RpcResponse::error(id, -32602, e.to_string())); }
307+
}
308+
}
309+
235310
#[derive(Debug, Deserialize)]
236311
#[serde(rename_all = "camelCase")]
237312
struct PostMessageParams { window_id: String, payload: serde_json::Value }
@@ -311,6 +386,9 @@ pub fn op_get_size(app: &mut App, params: Value, id: RpcId) {
311386
pub fn op_maximize(app: &mut App, params: Value, id: RpcId) { with_window(app, params, id, |w| { w.set_maximized(true); Ok(json!(true)) }); }
312387
pub fn op_minimize(app: &mut App, params: Value, id: RpcId) { with_window(app, params, id, |w| { w.set_minimized(true); Ok(json!(true)) }); }
313388
pub fn op_unminimize(app: &mut App, params: Value, id: RpcId) { with_window(app, params, id, |w| { w.set_minimized(false); Ok(json!(true)) }); }
389+
pub fn op_unmaximize(app: &mut App, params: Value, id: RpcId) { with_window(app, params, id, |w| { w.set_maximized(false); Ok(json!(true)) }); }
390+
pub fn op_is_maximized(app: &mut App, params: Value, id: RpcId) { with_window(app, params, id, |w| { Ok(json!(w.is_maximized())) }); }
391+
pub fn op_restore(app: &mut App, params: Value, id: RpcId) { with_window(app, params, id, |w| { w.set_minimized(false); w.set_maximized(false); Ok(json!(true)) }); }
314392
pub fn op_focus(app: &mut App, params: Value, id: RpcId) { with_window(app, params, id, |w| { w.set_focus(); Ok(json!(true)) }); }
315393
pub fn op_center(app: &mut App, params: Value, id: RpcId) { with_window(app, params, id, |w| {
316394
use tao::dpi::{PhysicalPosition};
@@ -328,6 +406,87 @@ pub fn op_set_always_on_top(app: &mut App, params: Value, id: RpcId) { with_wind
328406
pub fn op_set_resizable(app: &mut App, params: Value, id: RpcId) { with_window_bool(app, params, id, |w, v| { w.set_resizable(v); Ok(json!(true)) }); }
329407
pub fn op_is_visible(app: &mut App, params: Value, id: RpcId) { with_window(app, params, id, |w| { Ok(json!(w.is_visible())) }); }
330408

409+
#[derive(Debug, Deserialize)]
410+
#[serde(rename_all = "camelCase")]
411+
struct SizeOnlyParams { window_id: String, width: u32, height: u32 }
412+
413+
pub fn op_set_min_size(app: &mut App, params: Value, id: RpcId) {
414+
match serde_json::from_value::<SizeOnlyParams>(params) {
415+
Ok(p) => {
416+
if let Some(win) = app.windows.get(&p.window_id) {
417+
use tao::dpi::PhysicalSize;
418+
win.set_min_inner_size(Some(PhysicalSize::new(p.width, p.height)));
419+
let _ = app.tx_out.send(RpcResponse::result(id, json!(true)));
420+
} else { let _ = app.tx_out.send(RpcResponse::error(id, -32001, "Window not found".into())); }
421+
}
422+
Err(e) => { let _ = app.tx_out.send(RpcResponse::error(id, -32602, e.to_string())); }
423+
}
424+
}
425+
426+
pub fn op_set_max_size(app: &mut App, params: Value, id: RpcId) {
427+
match serde_json::from_value::<SizeOnlyParams>(params) {
428+
Ok(p) => {
429+
if let Some(win) = app.windows.get(&p.window_id) {
430+
use tao::dpi::PhysicalSize;
431+
win.set_max_inner_size(Some(PhysicalSize::new(p.width, p.height)));
432+
let _ = app.tx_out.send(RpcResponse::result(id, json!(true)));
433+
} else { let _ = app.tx_out.send(RpcResponse::error(id, -32001, "Window not found".into())); }
434+
}
435+
Err(e) => { let _ = app.tx_out.send(RpcResponse::error(id, -32602, e.to_string())); }
436+
}
437+
}
438+
439+
#[derive(Debug, Deserialize)]
440+
#[serde(rename_all = "camelCase")]
441+
struct AttentionParams { window_id: String, critical: Option<bool> }
442+
443+
pub fn op_request_user_attention(app: &mut App, params: Value, id: RpcId) {
444+
match serde_json::from_value::<AttentionParams>(params) {
445+
Ok(p) => {
446+
if let Some(win) = app.windows.get(&p.window_id) {
447+
let demand = if p.critical.unwrap_or(false) { tao::window::UserAttentionType::Critical } else { tao::window::UserAttentionType::Informational };
448+
win.request_user_attention(Some(demand));
449+
let _ = app.tx_out.send(RpcResponse::result(id, json!(true)));
450+
} else { let _ = app.tx_out.send(RpcResponse::error(id, -32001, "Window not found".into())); }
451+
}
452+
Err(e) => { let _ = app.tx_out.send(RpcResponse::error(id, -32602, e.to_string())); }
453+
}
454+
}
455+
456+
#[derive(Debug, Deserialize)]
457+
#[serde(rename_all = "camelCase")]
458+
struct ScreenshotParams { window_id: String }
459+
460+
pub fn op_screenshot(app: &mut App, params: Value, id: RpcId) {
461+
match serde_json::from_value::<ScreenshotParams>(params) {
462+
Ok(p) => {
463+
let r = (|| -> anyhow::Result<String> {
464+
// Best-effort: capture primary screen for now
465+
let screens: Vec<screenshots::Screen> = screenshots::Screen::all()?;
466+
let mut iter = screens.into_iter();
467+
let screen: screenshots::Screen = iter.next().ok_or(anyhow!("No screen"))?;
468+
let img = screen.capture()?; // ImageBuffer RGBA
469+
let (w, h) = (img.width(), img.height());
470+
let mut buf = Vec::new();
471+
{
472+
use image::codecs::png::PngEncoder;
473+
use image::ExtendedColorType;
474+
use image::ImageEncoder;
475+
let enc = PngEncoder::new(&mut buf);
476+
enc.write_image(img.as_raw(), w, h, ExtendedColorType::Rgba8)?;
477+
}
478+
use base64::Engine as _;
479+
Ok(base64::engine::general_purpose::STANDARD.encode(&buf))
480+
})();
481+
match r {
482+
Ok(b64) => { let _ = app.tx_out.send(RpcResponse::result(id, json!({"base64Png": b64}))); }
483+
Err(e) => { let _ = app.tx_out.send(RpcResponse::error(id, -33010, e.to_string())); }
484+
}
485+
}
486+
Err(e) => { let _ = app.tx_out.send(RpcResponse::error(id, -32602, e.to_string())); }
487+
}
488+
}
489+
331490
#[derive(Debug, Deserialize)]
332491
#[serde(rename_all = "camelCase")]
333492
struct WithWindowIdBool { window_id: String, value: bool }

packages/nanoframe/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
export { app, AppImpl as App } from './main/app'
2-
export { BrowserWindow } from './main/window'
1+
export { app, AppImpl as App } from './main/app.js'
2+
export { BrowserWindow } from './main/window.js'
3+
export type { BrowserWindowOptions } from './main/window.js'

packages/nanoframe/src/main/app.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { createNanoEvents } from 'nanoevents'
2-
import { RpcClient, withTimeout } from './core'
2+
import { RpcClient, withTimeout } from './core.js'
33

44
export class AppImpl {
55
#rpc!: RpcClient
6-
#emitter = createNanoEvents<{ ready: () => void, windowAllClosed: () => void }>()
6+
#emitter = createNanoEvents<{ ready: () => void, windowAllClosed: () => void, webviewIpc: (e: { windowId?: string, payload: any }) => void }>()
77
whenReady: Promise<void>
88
#stopKeepAlive?: () => void
99

@@ -16,13 +16,17 @@ export class AppImpl {
1616
this.#rpc.onNotify((method, params) => {
1717
if (method === 'window.closed') {
1818
this.#emitter.emit('windowAllClosed')
19+
} else if (method === 'webview.ipc') {
20+
this.#emitter.emit('webviewIpc', params)
1921
}
2022
})
2123
await withTimeout(this.#rpc.call('ping', {}), 10_000, new Error('nanoframe-core ping timeout'))
2224
this.#emitter.emit('ready')
2325
}
2426

25-
on(event: 'ready' | 'windowAllClosed', cb: () => void) { this.#emitter.on(event as any, cb as any) }
27+
on(event: 'ready' | 'windowAllClosed', cb: () => void): void
28+
on(event: 'webviewIpc', cb: (e: { windowId?: string, payload: any }) => void): void
29+
on(event: any, cb: any) { this.#emitter.on(event, cb) }
2630

2731
get rpc() { return this.#rpc }
2832

0 commit comments

Comments
 (0)