Skip to content
Open
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
16 changes: 11 additions & 5 deletions cmd/webview/main_webview.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,17 +138,23 @@ func run(dataDir string) error {
}()

w.SetTitle(appName)
// The page otherwise cannot distinguish the small native WebKit shell from a
// browser tab. Terminal renderer selection uses this marker to avoid WebGL's
// delayed frame presentation in the Linux WebKitGTK/X11 path.
w.Init(fmt.Sprintf("window.__chartrNativePlatform=%q;", runtime.GOOS))
w.SetSize(1280, 840, webview.HintNone)
// Below this the cockpit's own panes (sidebar, terminal, docked star-map)
// can no longer all keep their individual min-widths — the layout starts
// squeezing rather than the window scrolling, so the platform enforces the
// floor instead of the CSS.
w.SetSize(800, 500, webview.HintMin)
installNativeMenu(appName)
initialPageZoom := installNativeMenu(appName, w.Window())
// The page otherwise cannot distinguish the small native shell from a
// browser tab. Terminal renderer selection uses the platform marker to avoid
// WebGL's delayed frame presentation in the Linux WebKitGTK/X11 path. macOS
// also receives the restored native page zoom at document start, before any
// title-bar geometry is measured; the navigation delegate publishes the same
// value again after the page finishes loading.
w.Init(fmt.Sprintf(
"window.__chartrNativePlatform=%q;window.__chartrPageZoom=%.17g;",
runtime.GOOS, initialPageZoom,
))
// After the window, because the NSApplication it dresses is created with it,
// and because the Linux path needs the GtkWindow that newWindow() just made.
applyAppIcon(w)
Expand Down
177 changes: 166 additions & 11 deletions cmd/webview/menu_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,143 @@ package main

/*
#cgo CFLAGS: -x objective-c -Wno-deprecated-declarations
#cgo LDFLAGS: -framework Cocoa
#cgo LDFLAGS: -framework Cocoa -framework WebKit

#import <Cocoa/Cocoa.h>
#import <WebKit/WebKit.h>
#include <math.h>

// wfItem appends one menu item. Every action here is a standard responder-chain
// selector with a target of nil, which is why the menu needs no callback into
// Go: NSApplication handles the app items, and WKWebView — the first responder
// inside the window — handles reload: and the edit items itself.
static NSString *const WFPageZoomDefaultsKey = @"chartr.pageZoom.v1";
static const CGFloat WFPageZoomDefault = 1.0;
static const CGFloat WFPageZoomMinimum = 0.5;
static const CGFloat WFPageZoomMaximum = 3.0;
static const CGFloat WFPageZoomStep = 1.2;

// WFPageZoomController owns the native View-menu actions and is also the
// WKWebView's navigation delegate. Keeping those roles together makes one zoom
// value authoritative across menu validation, reloads and page notifications.
// WKWebView keeps its navigation delegate weakly, so the process-global pointer
// below deliberately retains this controller for the window's lifetime.
@interface WFPageZoomController : NSObject <NSMenuItemValidation, WKNavigationDelegate>
@property(nonatomic, assign) WKWebView *webView;
@property(nonatomic) CGFloat desiredZoom;
- (instancetype)initWithWebView:(WKWebView *)webView;
- (void)zoomIn:(id)sender;
- (void)zoomOut:(id)sender;
- (void)actualSize:(id)sender;
@end

static WFPageZoomController *gWFPageZoomController = nil;

static BOOL wfReadPageZoom(id value, CGFloat *result) {
if (![value isKindOfClass:[NSNumber class]]) {
return NO;
}
double zoom = [(NSNumber *)value doubleValue];
if (!isfinite(zoom) || zoom < WFPageZoomMinimum ||
zoom > WFPageZoomMaximum) {
return NO;
}
*result = (CGFloat)zoom;
return YES;
}

@implementation WFPageZoomController

- (instancetype)initWithWebView:(WKWebView *)webView {
self = [super init];
if (self == nil) {
return nil;
}

self.webView = webView;
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
id stored = [defaults objectForKey:WFPageZoomDefaultsKey];
CGFloat zoom = WFPageZoomDefault;
if (stored != nil && !wfReadPageZoom(stored, &zoom)) {
// A corrupt or obsolete preference must not make the cockpit unreadable.
// Removing it also prevents every subsequent launch repeating the repair.
[defaults removeObjectForKey:WFPageZoomDefaultsKey];
zoom = WFPageZoomDefault;
}
self.desiredZoom = zoom;
[self.webView setPageZoom:zoom];
return self;
}

- (void)publishPageZoom {
if (self.webView == nil) {
return;
}
NSString *script = [NSString stringWithFormat:
@"window.__chartrPageZoom=%.17g;window.dispatchEvent(new CustomEvent('chartr:page-zoom',{detail:%.17g}));",
(double)self.desiredZoom, (double)self.desiredZoom];
[self.webView evaluateJavaScript:script completionHandler:nil];
}

- (void)applyPageZoom:(CGFloat)zoom persist:(BOOL)persist publish:(BOOL)publish {
if (!isfinite((double)zoom)) {
zoom = WFPageZoomDefault;
}
zoom = MIN(WFPageZoomMaximum, MAX(WFPageZoomMinimum, zoom));
self.desiredZoom = zoom;
[self.webView setPageZoom:zoom];
if (persist) {
[[NSUserDefaults standardUserDefaults] setDouble:zoom
forKey:WFPageZoomDefaultsKey];
}
if (publish) {
[self publishPageZoom];
}
}

- (void)zoomIn:(id)sender {
[self applyPageZoom:self.desiredZoom * WFPageZoomStep
persist:YES
publish:YES];
}

- (void)zoomOut:(id)sender {
[self applyPageZoom:self.desiredZoom / WFPageZoomStep
persist:YES
publish:YES];
}

- (void)actualSize:(id)sender {
[self applyPageZoom:WFPageZoomDefault persist:YES publish:YES];
}

- (BOOL)validateMenuItem:(NSMenuItem *)item {
if (self.webView == nil) {
return NO;
}
SEL action = [item action];
if (action == @selector(zoomIn:)) {
return self.desiredZoom < WFPageZoomMaximum;
}
if (action == @selector(zoomOut:)) {
return self.desiredZoom > WFPageZoomMinimum;
}
if (action == @selector(actualSize:)) {
return self.desiredZoom != WFPageZoomDefault;
}
return YES;
}

- (void)webView:(WKWebView *)webView
didFinishNavigation:(WKNavigation *)navigation {
// WebKit normally carries pageZoom through a reload. Re-applying it here also
// covers a future navigation that replaces the page, then tells that document
// the authoritative native value after its own listeners have been installed.
self.webView = webView;
[self applyPageZoom:self.desiredZoom persist:NO publish:YES];
}

@end

// wfItem appends one menu item. Most callers leave the target nil so AppKit or
// WKWebView handles its standard responder-chain selector; the zoom items set
// their retained native controller as an explicit target after creation.
static NSMenuItem *wfItem(NSMenu *menu, NSString *title, SEL action, NSString *key, NSUInteger mask) {
NSMenuItem *item = [menu addItemWithTitle:title action:action keyEquivalent:key];
[item setKeyEquivalentModifierMask:mask];
Expand All @@ -26,11 +155,25 @@ static NSMenu *wfSubmenu(NSMenu *bar, NSString *title) {
}

// wfInstallMenu gives the bare webview window back the OS affordances a browser
// tab had for free: Quit, Reload, and the edit items. Deliberately minimal —
// ADR 0013 declines a dock badge and a URL scheme, and this menu is the whole
// of the shell's native integration beyond the window itself.
static void wfInstallMenu(const char *cname) {
// tab had for free: Quit, whole-page zoom, Reload, and the edit items. The return
// value is the restored zoom factor so Go can seed the same value into the page
// at document start, before the navigation delegate publishes its ready event.
static double wfInstallMenu(const char *cname, void *ptr) {
NSString *name = [NSString stringWithUTF8String:cname];
NSWindow *win = (NSWindow *)ptr;
WKWebView *webView = nil;
if ([[win contentView] isKindOfClass:[WKWebView class]]) {
webView = (WKWebView *)[win contentView];
}

if (gWFPageZoomController != nil) {
[[gWFPageZoomController webView] setNavigationDelegate:nil];
[gWFPageZoomController release];
}
gWFPageZoomController =
[[WFPageZoomController alloc] initWithWebView:webView];
[webView setNavigationDelegate:gWFPageZoomController];

NSApplication *app = [NSApplication sharedApplication];
NSMenu *bar = [[NSMenu alloc] init];

Expand Down Expand Up @@ -58,9 +201,21 @@ static void wfInstallMenu(const char *cname) {
wfItem(editMenu, @"Select All", @selector(selectAll:), @"a", NSEventModifierFlagCommand);

NSMenu *viewMenu = wfSubmenu(bar, @"View");
NSMenuItem *zoomIn = wfItem(viewMenu, @"Zoom In", @selector(zoomIn:), @"+",
NSEventModifierFlagCommand);
[zoomIn setTarget:gWFPageZoomController];
NSMenuItem *zoomOut = wfItem(viewMenu, @"Zoom Out", @selector(zoomOut:), @"-",
NSEventModifierFlagCommand);
[zoomOut setTarget:gWFPageZoomController];
NSMenuItem *actualSize = wfItem(viewMenu, @"Actual Size",
@selector(actualSize:), @"0",
NSEventModifierFlagCommand);
[actualSize setTarget:gWFPageZoomController];
[viewMenu addItem:[NSMenuItem separatorItem]];
wfItem(viewMenu, @"Reload", @selector(reload:), @"r", NSEventModifierFlagCommand);

[app setMainMenu:bar];
return (double)[gWFPageZoomController desiredZoom];
}

// wfSetAppName names the process. Launched loose the shell is a bare binary with
Expand Down Expand Up @@ -110,10 +265,10 @@ func setAppName(name string) {
C.wfSetAppName(cname)
}

func installNativeMenu(name string) {
func installNativeMenu(name string, window unsafe.Pointer) float64 {
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
C.wfInstallMenu(cname)
return float64(C.wfInstallMenu(cname, window))
}

// raiseInstance brings the already-running shell forward. Reporting false is
Expand Down
2 changes: 1 addition & 1 deletion cmd/webview/menu_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ func setAppName(name string) {
C.wfSetAppName(cname)
}

func installNativeMenu(string) {}
func installNativeMenu(string, unsafe.Pointer) float64 { return 1 }

func raiseInstance(int) bool { return false }
7 changes: 5 additions & 2 deletions cmd/webview/menu_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

package main

import "runtime"
import (
"runtime"
"unsafe"
)

// missingRuntime names what a failed window creation means on this platform.
// The whole point of naming it is that a missing dependency is never papered
Expand All @@ -20,7 +23,7 @@ func setAppName(string) {}
// bare window losing the browser's menu bar; GTK and Win32 windows keep their
// own window controls, and inventing a menu bar for them is not this ticket's
// work (ADR 0013).
func installNativeMenu(string) {}
func installNativeMenu(string, unsafe.Pointer) float64 { return 1 }

// raiseInstance always reports false here: raising another process's window is
// exactly the "flaky" case the spec names, so these platforms take the
Expand Down
19 changes: 11 additions & 8 deletions cmd/webview/titlebar_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,11 @@ static WFDragView *gWFDragView = nil;

@end

// Replace the drag view's passthrough list from CSS-pixel rectangles measured
// from the viewport's top-left. NSView coordinates start at the bottom-left, so
// each y value is flipped inside the title strip as it is copied.
// Replace the drag view's passthrough list from AppKit-point rectangles measured
// from the viewport's top-left. The page has already applied WKWebView.pageZoom
// to its DOM rectangles before crossing the binding, so scaling them again here
// would double the offset. NSView coordinates start at the bottom-left, so each
// y value is flipped inside the title strip as it is copied.
static void wfSetTitleBarButtonRects(double *values, int count) {
WFDragView *drag = gWFDragView;
if (drag == nil) {
Expand Down Expand Up @@ -202,16 +204,17 @@ import (
webview "github.com/webview/webview_go"
)

// installTitleBar removes the native title bar and reports the height, in CSS
// pixels, of the strip the cockpit must fill in its place. Zero means the window
// keeps its native title bar and the cockpit renders no bar of its own.
// installTitleBar removes the native title bar and reports the height, in AppKit
// points, of the strip the cockpit must fill in its place. The page converts that
// fixed native height through the live page zoom. Zero means the window keeps its
// native title bar and the cockpit renders no bar of its own.
func installTitleBar(w webview.WebView) int {
h := float64(C.wfInstallTitleBar(unsafe.Pointer(w.Window())))
if h <= 0 {
return 0
}
// Points are CSS pixels on macOS whatever the backing scale, so this rounds
// rather than converts.
// The JS seam takes an integral native height and owns page-zoom conversion,
// so this rounds rather than changing coordinate systems here.
return int(math.Round(h))
}

Expand Down
6 changes: 3 additions & 3 deletions cmd/webview/titlebar_regions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
package main

// titleBarButtonRect is one live clickable rectangle in the page's top strip,
// measured in CSS pixels from the viewport's top-left corner. The page reports
// these whenever its header changes; the macOS drag overlay uses them as its
// exact passthrough regions.
// measured in AppKit points from the viewport's top-left corner. The page scales
// its CSS-pixel DOM rectangle by the live WKWebView page zoom before reporting
// it; the macOS drag overlay uses the result as its exact passthrough region.
type titleBarButtonRect struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0013-webview-shell-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The package is **split by build tag** so the cgo is invisible to every default b

**Single-instance is a lock file, not a window handle.** `<data-dir>/.chartr/shell.lock` records the live instance's pid and loopback URL; the claim is an `O_EXCL` create. Keying it to the data dir is what makes distinct `--data-dir` roots distinct instances *by construction* — there is no global lock to contend for. A second launch raises the running window and exits 0; where raising is not possible it refuses with the running URL rather than pretend (the operator can still open that URL in a browser). **The mechanism is a pid, not `webview.Window()`**: the planning map named the native handle, but a second launch is a *different process*, and a window handle is meaningless across the process boundary. macOS raises via `NSRunningApplication`; Linux and Windows take the refuse-with-message path. A lock whose pid is dead is **stale and taken over** — `⌘Q` routes through AppKit's `terminate:` and runs no deferred cleanup, so a lock outliving its process is the normal case, not the exceptional one, and it must never lock the operator out of their own cockpit.

**Native integration is a window, a dock icon, and a minimal menu** — Quit (⌘Q), Reload (⌘R), and the standard edit items, every one of them a responder-chain selector so the menu needs no callback into Go: `NSApplication` answers the app items and `WKWebView` answers `reload:` and the edit items itself. The menu is macOS-only; GTK and Win32 windows keep their own controls. Because a bare binary is not a `.app` bundle, the shell seeds `CFBundleName` before `NSApplication` exists, so the menu bar reads `chartr` rather than the executable's name. **Declined**: a dock badge for the "Needs you" queue, and a `chartr://` URL scheme — each returns only on a concrete trigger.
**Native integration is a window, a dock icon, and a minimal menu** — Quit (`⌘Q`), whole-page Zoom In/Out/Actual Size (`⌘+`, `⌘−`, and `⌘0`), Reload (`⌘R`), and the standard edit items. App and edit commands remain responder-chain selectors; a retained macOS controller owns zoom, persists it in `NSUserDefaults`, applies it through `WKWebView.pageZoom`, validates its menu bounds, and republishes it to each loaded page for zoom-aware native-title-bar hit testing. The menu is macOS-only; GTK and Win32 windows keep their own controls and stay at 100%. Because a bare binary is not a `.app` bundle, the shell seeds `CFBundleName` before `NSApplication` exists, so the menu bar reads `chartr` rather than the executable's name. **Declined**: a dock badge for the "Needs you" queue, and a `chartr://` URL scheme — each returns only on a concrete trigger.

**A missing native runtime is a hard error** that names what is missing and points at `chartr`. `webview_create` returns NULL on failure and the Go wrapper hands that NULL back inside a non-nil interface, so the shell reads the wrapper's handle field directly to detect it — the one unsafe corner, confined to one function. There is **no auto browser fallback and no `--browser`/`--shell` force flag**: the two binaries *are* the choice, and neither impersonates the other.

Expand Down
21 changes: 19 additions & 2 deletions web/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@
import { forgetSpace } from "./lib/mapstate";
import {
hasNativeTitleBar,
nativePageZoom,
nativeTitleBarHeight,
nativeTitleBarLayout,
trackPageZoom,
trackTitleBarButtons,
} from "./lib/titlebar";
import { visibleSpaces } from "./lib/spacevisibility";
Expand All @@ -61,6 +64,13 @@
// browser tab entirely.
const titleBarH = nativeTitleBarHeight();

// WKWebView page zoom is native state, mirrored into the page before boot and
// after every menu command. Keeping this one reactive copy compensates the
// custom top strip for that scaling (subject to the cockpit's 40px tier
// minimum) and keeps the real traffic-light buttons' clearance constant.
let pageZoom = $state(nativePageZoom());
const titleBarLayout = $derived(nativeTitleBarLayout(titleBarH, pageZoom));

// The other half of that same question: a window whose title bar stayed the
// OS's (the Linux and Windows shells). There the window already carries the
// application's name above the page, so the sidebar drops its wordmark and
Expand Down Expand Up @@ -99,6 +109,7 @@

onMount(() => {
control.connect();
const stopTrackingPageZoom = trackPageZoom((zoom) => (pageZoom = zoom));
const stopTrackingTitleBarButtons = trackTitleBarButtons(titleBarH);
// A deep link names its space (#s=<id>&…); select it up front so the linked
// star seats as soon as the space arrives over the socket (ticket 07). The
Expand All @@ -110,6 +121,7 @@
return () => {
window.removeEventListener("hashchange", onHash);
stopTrackingTitleBarButtons();
stopTrackingPageZoom();
control.close();
};
});
Expand Down Expand Up @@ -632,7 +644,7 @@
<div class="flex h-full min-h-0 flex-col">
<div
class="grid min-h-0 flex-1 grid-cols-[16rem_minmax(0,1fr)]"
style={titleBarH ? `--bar-h: ${Math.max(titleBarH, 40)}px` : undefined}
style={titleBarH ? `--bar-h: ${titleBarLayout.height}px` : undefined}
>
<aside
class="col-start-1 row-start-1 flex min-h-0 flex-col overflow-hidden border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
Expand All @@ -649,7 +661,12 @@
tall (pt-2 + h-8 = --bar-h), so the sidebar's top edge still lands on
the space header's. -->
{#if !nativeTitleBar}
<div class="brand-bar justify-start gap-2" class:pl-20={titleBarH > 0}>
<div
class="brand-bar justify-start gap-2"
style={titleBarH
? `padding-left: ${titleBarLayout.trafficLightClearance}px`
: undefined}
>
<img
src="/brandmark.svg"
alt=""
Expand Down
Loading