Compare commits

...
4 Commits
26 changed files with 5575 additions and 457 deletions
+3 -3
View File
@@ -6,18 +6,18 @@
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1)
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1 libsoup-3.0)
CFLAGS += -I./nostr_core_lib
CFLAGS += -DNOSTR_ENABLE_NSIGNER_CLIENT
LDFLAGS ?=
LDLIBS += $(shell pkg-config --libs webkit2gtk-4.1)
LDLIBS += $(shell pkg-config --libs webkit2gtk-4.1 libsoup-3.0)
# nostr_core_lib static library + its dependencies
NOSTR_LIB = ./nostr_core_lib/libnostr_core_x64.a
NOSTR_DEPS = -lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm
BIN := sovereign_browser
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c
$(BIN): $(SRC) $(NOSTR_LIB)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS)
+108 -8
View File
@@ -41,8 +41,9 @@ because trust moves to the layer where it belongs: your keys.
## Current status
Working proof-of-concept: a C99 + WebKitGTK window with a URL bar that loads
real HTTPS pages. Verified loading `https://laantungir.net` cleanly. See
Working browser: a C99 + WebKitGTK window with multi-tab support, a URL bar
per tab, Nostr login, and `window.nostr` injection. Verified loading
`https://laantungir.net` cleanly. See
[`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the friction
report from the POC phase (and the Servo fallback exploration).
@@ -125,15 +126,114 @@ Supported methods:
See [`plans/nostr-login-integration.md`](plans/nostr-login-integration.md) for
the full implementation plan.
### Browser tabs
The browser supports multiple tabs via a `GtkNotebook`. Each tab has its own
toolbar (hamburger menu + URL entry) and `WebKitWebView`. All tabs share a
single `WebKitWebContext`, so the `sovereign://` Nostr bridge, security
settings, and TLS policy apply to every tab automatically.
**Tab features:**
- New tab: `Ctrl+T` or the `+` button at the end of the tab strip
- Close tab: `Ctrl+W`, per-tab close button, or middle-click on the tab label
- Switch tabs: `Ctrl+Tab` / `Ctrl+Shift+Tab`, `Ctrl+PageUp` / `Ctrl+PageDown`
- Focus URL bar: `Ctrl+L`
- Right-click tab context menu: New Tab, Close Tab, Close Other Tabs, Close
Tabs to the Right, Duplicate Tab, Reload Tab
- Drag tabs to reorder
- Tab titles update from page load
- Session save/restore: open tabs are saved on exit and restored on next
launch (configurable in Settings)
**Settings dialog** (hamburger menu → Settings…):
- Restore session on startup (default: on)
- New tab URL (default: `https://example.com`)
- Tab bar position: Top / Bottom / Left / Right (default: Top)
- Show tab close buttons (default: on)
- Middle-click to close tab (default: on)
- Ctrl+Tab to switch tabs (default: on)
- Maximum tabs (default: 50)
- Allow drag to reorder tabs (default: on)
Settings are persisted to `~/.sovereign_browser/settings.conf`. Session state
is saved to `~/.sovereign_browser/session.txt`.
See [`plans/browser-tabs.md`](plans/browser-tabs.md) for the full
implementation plan.
### Agent tools
The browser embeds a WebSocket server (using libsoup) that allows external
AI agents to control the browser programmatically. Connect to
`ws://localhost:17777/agent` and send JSON tool commands.
The server starts **before** the login dialog, so an agent can authenticate
without human interaction. If no agent logs in within a timeout (default
30s, configurable in Settings), the GTK login dialog appears as fallback.
**Login tools** (available before login):
- `login_status` — check if logged in
- `login` — authenticate with method: `local` (nsec), `seed` (BIP-39
mnemonic), `readonly` (npub), `nip46` (bunker:// URL), or `nsigner`
(hardware signer via serial/unix/tcp/qrexec transport)
- `logout` — clear signer and reset state
- `switch_identity` — change identity (same params as login)
**Browser tools** (available after login):
- Navigation: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
- Inspection: `snapshot` (accessibility tree with element refs), `get_text`,
`get_html`, `get_attr`, `eval` (run JavaScript)
- Interaction: `click`, `fill`, `type`, `press`, `scroll`, `hover`,
`focus`, `close`
- Tabs: `tab_list`, `tab_new`, `tab_switch`, `tab_close`
- Wait: `wait` (ms), `wait_for` (selector)
**Snapshot + ref workflow** (same pattern as agent-browser):
```bash
# Using websocat (cargo install websocat)
# 1. Login
echo '{"id":1,"tool":"login","params":{"method":"readonly","npub":"npub1..."}}' | websocat ws://localhost:17777/agent
# 2. Open a page
echo '{"id":2,"tool":"open","params":{"url":"https://example.com"}}' | websocat ws://localhost:17777/agent
# 3. Snapshot — get accessibility tree with refs
echo '{"id":3,"tool":"snapshot","params":{"interactive":true}}' | websocat ws://localhost:17777/agent
# 4. Click by ref
echo '{"id":4,"tool":"click","params":{"ref":"@e2"}}' | websocat ws://localhost:17777/agent
```
**HTTP status endpoint:** `curl http://localhost:17777/` returns server
status (running, port, client count, logged in).
**Agent settings** (in Settings dialog or `~/.sovereign_browser/settings.conf`):
- `agent_server_enabled` (default: true)
- `agent_server_port` (default: 17777)
- `agent_allowed_origins` (default: `*`)
- `agent_login_timeout_ms` (default: 30000)
See [`plans/agent-tools.md`](plans/agent-tools.md) for the full
implementation plan and tool comparison with agent-browser.
## Roadmap
1. ✅ Base browser (WebKitGTK + C99, loads pages)
2. ⏳ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
3. `window.nostr` injection (`sovereign://` URI scheme bridge)
4.Security strip (disable SOP/CORS, accept any cert, shared context)
5.FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
6.`nostr://` content scheme
7. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
2. ✅ Browser tabs (GtkNotebook, per-tab toolbar, session restore, settings)
3. ✅ Agent tools Phase 1 (WebSocket server, login tools, 31 browser automation tools)
4.Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
5.`window.nostr` injection (`sovereign://` URI scheme bridge)
6.Security strip (disable SOP/CORS, accept any cert, shared context)
7. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
8.`nostr://` content scheme
9. ⏳ Agent tools Phase 2 (refinements) and Phase 3 (extended tool catalog)
10. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
## License
+1 -1
View File
@@ -1 +1 @@
0.0.2
0.0.6
+5 -2
View File
@@ -275,8 +275,11 @@ flowchart LR
### Layers
1. **Host application (C99).** Owns the window, URL bar, tabs, key store, and
the request router. This is where the "reckless" policy lives.
1. **Host application (C99).** Owns the window, tab strip (`GtkNotebook`),
per-tab toolbars (URL bar + hamburger menu), key store, and the request
router. This is where the "reckless" policy lives. Tab management is in
`tab_manager.c`, user preferences in `settings.c`, and session save/restore
in `session.c`.
2. **Request router.** Inspects every outgoing URL:
- `http://` / `https://` → engine's normal network stack (with CORS /
+727
View File
@@ -0,0 +1,727 @@
# Agent Tools Implementation Plan
## Overview
Make sovereign_browser agentically capable by embedding a WebSocket server
in the browser process. External AI agents (LLM tools, scripts, other
processes) connect to `ws://localhost:PORT` and send JSON tool commands to
navigate, inspect, and interact with web pages.
The design is inspired by [agent-browser](https://github.com/vercel-labs/agent-browser),
which uses a **snapshot + ref** workflow: take a snapshot (accessibility tree
with element refs), then interact by ref (click `@e2`, fill `@e3`). This is
the optimal pattern for LLM-driven browser automation.
## Architecture
```mermaid
flowchart TB
subgraph AgentProcess
Agent[AI Agent / Script / CLI]
end
subgraph BrowserProcess
WSServer[WebSocket Server — SoupServer]
ToolDispatch[Tool Dispatcher]
Snapshot[Snapshot Engine]
Tools[Tool Implementations]
TabMgr[Tab Manager]
end
subgraph WebKitGTK
Webview[WebKitWebView — active tab]
JSEval[webkit_web_view_evaluate_javascript]
end
Agent -->|ws://localhost:PORT JSON| WSServer
WSServer --> ToolDispatch
ToolDispatch --> Tools
Tools --> Snapshot
Tools --> TabMgr
Tools -->|JS eval| JSEval
JSEval --> Webview
Snapshot -->|inject + extract| JSEval
TabMgr --> Webview
```
### Key design decisions
1. **WebSocket server via libsoup**`SoupServer` with
`soup_server_add_websocket_handler()` provides a production-ready
WebSocket server. libsoup is already linked (WebKitGTK dependency).
No new dependencies, no hand-rolling the WebSocket protocol.
2. **JSON protocol via cJSON** — cJSON is already vendored in
`nostr_core_lib/cjson/`. All tool requests and responses are JSON.
3. **Snapshot + ref pattern** — The core workflow:
- `snapshot` injects a JS script that walks the accessibility tree,
assigns sequential refs (`e1`, `e2`, ...) to interactive elements,
and returns a text representation of the tree.
- `click @e2` looks up the ref in a per-tab ref map, finds the DOM
element, and clicks it via `element.click()`.
- Refs are per-tab and persist until the next `snapshot` call.
4. **Per-tab ref storage** — Each `tab_info_t` gets a ref map (hash table
from ref string → element selector or DOM node reference). Since JS
execution is per-webview, refs are scoped to the tab's webview.
5. **Async JS execution**`webkit_web_view_evaluate_javascript()` is
async. For tools that need the return value (snapshot, get_text, eval),
we use a callback-based approach: the tool injects JS that stores the
result in a known global variable, then reads it back. Alternatively,
we can use a Promise-based approach with a polling mechanism.
6. **Simple HTTP fallback** — The WebSocket server also handles plain HTTP
GET requests to `http://localhost:PORT/` returning a status JSON
(server info, connected clients, tab count). This makes it easy to
discover the server with `curl` without a WebSocket client.
## WebSocket protocol
### Request format
```json
{
"id": 1,
"tool": "snapshot",
"params": {
"interactive": true,
"compact": true
}
}
```
### Response format
```json
{
"id": 1,
"success": true,
"data": {
"snapshot": "- heading \"Example Domain\" [ref=e1] [level=1]\n- link \"More information...\" [ref=e2]",
"refs": {
"e1": {"role": "heading", "name": "Example Domain", "level": 1},
"e2": {"role": "link", "name": "More information...", "href": "https://example.com"}
}
}
}
```
### Error response
```json
{
"id": 1,
"success": false,
"error": {
"code": "ELEMENT_NOT_FOUND",
"message": "No element with ref @e99"
}
}
```
### Push events (server → client)
```json
{
"type": "event",
"event": "load",
"data": {"url": "https://example.com", "title": "Example Domain", "tab": 0}
}
```
```json
{
"type": "event",
"event": "tab_changed",
"data": {"tab": 1, "url": "https://example.com", "title": "Example"}
}
```
## Tool catalog — full comparison with agent-browser
The table below lists every agent-browser CLI command and whether we plan
to implement it. "Phase 1" tools are in the initial implementation; "Phase 2"
tools are deferred but designed for; "N/A" tools don't apply to our
architecture (CDP-specific, cloud providers, etc.).
### Core commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `open <url>` | `open` | 1 | Navigate active tab |
| `click <sel>` | `click` | 1 | By ref or CSS selector |
| `dblclick <sel>` | `dblclick` | 2 | Double-click |
| `focus <sel>` | `focus` | 1 | Focus element |
| `type <sel> <text>` | `type` | 1 | Type into element |
| `fill <sel> <text>` | `fill` | 1 | Clear and fill |
| `press <key>` | `press` | 1 | Press keyboard key |
| `keyboard type <text>` | `keyboard_type` | 2 | Type with real keystrokes |
| `keyboard inserttext <text>` | `insert_text` | 2 | Insert text without key events |
| `keydown <key>` | `keydown` | 2 | Hold key down |
| `keyup <key>` | `keyup` | 2 | Release key |
| `hover <sel>` | `hover` | 1 | Hover element |
| `select <sel> <val>` | `select` | 2 | Select dropdown option |
| `check <sel>` | `check` | 2 | Check checkbox |
| `uncheck <sel>` | `uncheck` | 2 | Uncheck checkbox |
| `scroll <dir> [px]` | `scroll` | 1 | Scroll page |
| `scrollintoview <sel>` | `scroll_into_view` | 2 | Scroll element into view |
| `drag <src> <tgt>` | `drag` | 2 | Drag and drop |
| `upload <sel> <files>` | `upload` | 2 | Upload files |
| `screenshot [path]` | `screenshot` | 1 | Take screenshot |
| `screenshot --annotate` | `screenshot_annotated` | 2 | Annotated with element labels |
| `pdf <path>` | `pdf` | 2 | Save as PDF |
| `snapshot` | `snapshot` | 1 | Accessibility tree with refs |
| `eval <js>` | `eval` | 1 | Run JavaScript |
| `connect <port>` | N/A | — | CDP-specific, we are the server |
| `stream enable` | `stream_enable` | 2 | WebSocket viewport streaming |
| `stream status` | `stream_status` | 2 | Streaming state |
| `stream disable` | `stream_disable` | 2 | Stop streaming |
| `close` | `close` | 1 | Close active tab |
| `close --all` | `close_all` | 2 | Close all tabs |
### Get info commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `get text <sel>` | `get_text` | 1 | Get text content |
| `get html <sel>` | `get_html` | 1 | Get innerHTML |
| `get value <sel>` | `get_value` | 2 | Get input value |
| `get attr <sel> <attr>` | `get_attr` | 1 | Get attribute |
| `get title` | `get_title` | 1 | Get page title |
| `get url` | `get_url` | 1 | Get current URL |
| `get cdp-url` | N/A | — | CDP-specific |
| `get count <sel>` | `get_count` | 2 | Count matching elements |
| `get box <sel>` | `get_box` | 2 | Get bounding box |
| `get styles <sel>` | `get_styles` | 2 | Get computed styles |
### Check state commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `is visible <sel>` | `is_visible` | 2 | Check if visible |
| `is enabled <sel>` | `is_enabled` | 2 | Check if enabled |
| `is checked <sel>` | `is_checked` | 2 | Check if checked |
### Find elements (semantic locators)
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `find role <role> ...` | `find_role` | 2 | By ARIA role |
| `find text <text> ...` | `find_text` | 2 | By text content |
| `find label <label> ...` | `find_label` | 2 | By label |
| `find placeholder <ph> ...` | `find_placeholder` | 2 | By placeholder |
| `find alt <text> ...` | `find_alt` | 2 | By alt text |
| `find title <text> ...` | `find_title` | 2 | By title attr |
| `find testid <id> ...` | `find_testid` | 2 | By data-testid |
| `find first <sel> ...` | `find_first` | 2 | First match |
| `find last <sel> ...` | `find_last` | 2 | Last match |
| `find nth <n> <sel> ...` | `find_nth` | 2 | Nth match |
### Wait commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `wait <selector>` | `wait_for` | 1 | Wait for element visible |
| `wait <ms>` | `wait` | 1 | Wait for time |
| `wait --text "..."` | `wait_for_text` | 2 | Wait for text to appear |
| `wait --url "..."` | `wait_for_url` | 2 | Wait for URL pattern |
| `wait --load networkidle` | `wait_for_load` | 2 | Wait for load state |
| `wait --fn "..."` | `wait_for_fn` | 2 | Wait for JS condition |
### Batch execution
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `batch --json` | `batch` | 2 | Execute multiple commands |
### Clipboard
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `clipboard read` | `clipboard_read` | 2 | Read clipboard |
| `clipboard write "..."` | `clipboard_write` | 2 | Write clipboard |
| `clipboard copy` | `clipboard_copy` | 2 | Copy selection |
| `clipboard paste` | `clipboard_paste` | 2 | Paste from clipboard |
### Mouse control
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `mouse move <x> <y>` | `mouse_move` | 2 | Move mouse |
| `mouse down [button]` | `mouse_down` | 2 | Press button |
| `mouse up [button]` | `mouse_up` | 2 | Release button |
| `mouse wheel <dy> [dx]` | `mouse_wheel` | 2 | Scroll wheel |
### Browser settings
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `set viewport <w> <h>` | `set_viewport` | 2 | Set viewport size |
| `set device <name>` | `set_device` | N/A | Device emulation is CDP-specific |
| `set geo <lat> <lng>` | `set_geo` | N/A | Geolocation emulation |
| `set offline [on\|off]` | `set_offline` | 2 | Toggle offline |
| `set headers <json>` | `set_headers` | 2 | Extra HTTP headers |
| `set credentials <u> <p>` | `set_credentials` | 2 | HTTP basic auth |
| `set media [dark\|light]` | `set_media` | 2 | Emulate color scheme |
### Cookies and storage
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `cookies` | `cookies_get` | 2 | Get all cookies |
| `cookies set <name> <val>` | `cookies_set` | 2 | Set cookie |
| `cookies clear` | `cookies_clear` | 2 | Clear cookies |
| `storage local` | `storage_local_get` | 2 | Get localStorage |
| `storage local <key>` | `storage_local_get_key` | 2 | Get specific key |
| `storage local set <k> <v>` | `storage_local_set` | 2 | Set value |
| `storage local clear` | `storage_local_clear` | 2 | Clear all |
| `storage session` | `storage_session_*` | 2 | Same for sessionStorage |
### Network
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `network route <url>` | `network_route` | N/A | Request interception is CDP-specific |
| `network unroute` | `network_unroute` | N/A | CDP-specific |
| `network requests` | `network_requests` | N/A | CDP-specific |
| `network request <id>` | `network_request_detail` | N/A | CDP-specific |
| `network har start` | `har_start` | N/A | CDP-specific |
| `network har stop` | `har_stop` | N/A | CDP-specific |
### Tabs and windows
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `tab` | `tab_list` | 1 | List tabs |
| `tab new [url]` | `tab_new` | 1 | New tab |
| `tab <n>` | `tab_switch` | 1 | Switch to tab n |
| `tab close [n]` | `tab_close` | 1 | Close tab |
| `window new` | `window_new` | N/A | Multi-window is future roadmap |
### Frames
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `frame <sel>` | `frame_switch` | 2 | Switch to iframe |
| `frame main` | `frame_main` | 2 | Back to main frame |
### Dialogs
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `dialog accept [text]` | `dialog_accept` | 2 | Accept dialog |
| `dialog dismiss` | `dialog_dismiss` | 2 | Dismiss dialog |
| `dialog status` | `dialog_status` | 2 | Check if dialog open |
### Diff
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `diff snapshot` | `diff_snapshot` | N/A | Future, after snapshot is stable |
| `diff screenshot` | `diff_screenshot` | N/A | Future |
| `diff url` | `diff_url` | N/A | Future |
### Debug
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `trace start/stop` | N/A | — | Chrome trace, not available in WebKitGTK |
| `profiler start/stop` | N/A | — | Chrome DevTools profiler |
| `console` | `console` | 2 | View console messages |
| `errors` | `errors` | 2 | View page errors |
| `highlight <sel>` | `highlight` | 2 | Highlight element |
| `inspect` | N/A | — | Opens Chrome DevTools |
| `state save/load/list` | `state_save/load/list` | 2 | Save/restore auth state |
### Navigation
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `back` | `back` | 1 | Go back |
| `forward` | `forward` | 1 | Go forward |
| `reload` | `reload` | 1 | Reload page |
### Setup
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `install` | N/A | — | We bundle WebKitGTK, no install needed |
| `upgrade` | N/A | — | Package manager handles updates |
### Authentication
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `auth save` | N/A | — | We use Nostr identity, not credential vault |
| `auth login` | N/A | — | Nostr login is our native flow |
### Sessions
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `--session <name>` | N/A | — | We have one browser process, tabs instead |
| `--session-name <name>` | N/A | — | Session persistence is our session.c |
| `--profile <path>` | N/A | — | WebKitGTK handles profile data |
| `--state <path>` | `state_save/load` | 2 | Could implement via WebKitGTK data manager |
### Login tools (sovereign_browser-specific, not in agent-browser)
These tools are unique to sovereign_browser. They allow an agent to handle
the Nostr login flow programmatically — essential because the browser
shows a modal login dialog on startup that blocks all other tools until
the user (or agent) signs in.
The agent server starts **before** the login dialog, so the agent can
authenticate without any human interaction. The login tools call the same
`nostr_core_lib` functions that the GTK login dialog uses, bypassing the
dialog entirely.
| Tool | Params | Description |
|------|--------|-------------|
| `login_status` | `{}` | Check if logged in; return method, pubkey, readonly |
| `login` | `{"method": "local", "nsec": "nsec1..."}` | Sign in with local key |
| `login` | `{"method": "seed", "mnemonic": "word1 word2 ...", "passphrase": "..."}` | Sign in with BIP-39 seed phrase |
| `login` | `{"method": "readonly", "npub": "npub1..."}` | Sign in read-only with npub |
| `login` | `{"method": "nip46", "bunker_url": "bunker://..."}` | Sign in with NIP-46 remote signer |
| `login` | `{"method": "nsigner", "transport": "qrexec", "device": "nostr_signer", "service": "qubes.NsignerRpc", "index": 0}` | Sign in with n_signer hardware |
| `logout` | `{}` | Log out and clear signer |
| `switch_identity` | `{"method": "local", "nsec": "nsec1..."}` | Switch to a new identity (same params as login) |
**Login response:**
```json
{
"id": 1,
"success": true,
"data": {
"method": "local",
"pubkey": "a1b2c3...",
"npub": "npub1...",
"readonly": false
}
}
```
**Architecture change:** The agent server must start *before* the login
dialog. In `main.c`, the startup sequence becomes:
1. `gtk_init()` + load settings/history
2. Create the main window (but don't show it yet)
3. **Start the agent server** (so agents can connect)
4. If `agent_server_enabled` and an agent connects within a timeout,
wait for the agent to call `login`
5. Otherwise (no agent, or timeout), show the GTK login dialog as before
6. After login (via agent or dialog), create tabs and show the window
This means the agent server has two states:
- **Pre-login:** Only `login_status`, `login`, `logout` tools are available
- **Post-login:** All browser tools become available
### Summary
| Category | agent-browser commands | Our Phase 1 | Our Phase 2 | Our Phase 3 | N/A |
|----------|----------------------|-------------|-------------|-------------|-----|
| Login (unique) | 0 (8 new) | 8 | 0 | 0 | 0 |
| Core | 29 | 10 | 0 | 14 | 5 |
| Get info | 9 | 4 | 0 | 4 | 1 |
| Check state | 3 | 0 | 0 | 3 | 0 |
| Find elements | 10 | 0 | 0 | 10 | 0 |
| Wait | 6 | 2 | 0 | 4 | 0 |
| Batch | 1 | 0 | 0 | 1 | 0 |
| Clipboard | 4 | 0 | 0 | 4 | 0 |
| Mouse | 4 | 0 | 0 | 4 | 0 |
| Settings | 7 | 0 | 0 | 4 | 3 |
| Cookies/storage | 8 | 0 | 0 | 8 | 0 |
| Network | 6 | 0 | 0 | 0 | 6 |
| Tabs/windows | 5 | 4 | 0 | 0 | 1 |
| Frames | 2 | 0 | 0 | 2 | 0 |
| Dialogs | 3 | 0 | 0 | 3 | 0 |
| Diff | 3 | 0 | 0 | 0 | 3 |
| Debug | 8 | 0 | 0 | 4 | 4 |
| Navigation | 3 | 3 | 0 | 0 | 0 |
| Setup | 2 | 0 | 0 | 0 | 2 |
| Auth | 2 | 0 | 0 | 0 | 2 |
| Sessions | 4 | 0 | 0 | 2 | 2 |
| **Total** | **119 + 8** | **31** | **0** | **67** | **29** |
**Phase 1 (31 tools):** Login tools (8) + the minimal set for agent-driven
browser automation (23) — login, navigate, snapshot, inspect, interact,
and manage tabs. This is enough for an LLM to log in, browse, read, fill
forms, click buttons, and multi-tab. The agent server starts before login
so the agent can authenticate without human interaction.
**Phase 2 (0 tools):** Reserved for refinements to Phase 1 tools based on
real agent usage — better snapshot formatting, error handling, edge cases.
**Phase 3 (67 tools):** Everything else that has a WebKitGTK equivalent.
Deferred until Phase 1 is stable and tested with real agents.
**N/A (29 tools):** CDP-specific features (network interception, Chrome
DevTools profiler, trace), cloud provider integrations, and features that
don't map to our architecture (device emulation, multi-window). Some could
be revisited if WebKitGTK adds equivalent APIs.
## Snapshot engine
The snapshot is the heart of the agent system. It injects a JavaScript
script into the active tab's webview that:
1. Walks the DOM accessibility tree (using `TreeWalker` or recursive
`querySelectorAll`)
2. For each interactive element (links, buttons, inputs, selects, textareas,
elements with role/aria-label), assigns a sequential ref (`e1`, `e2`, ...)
3. Stores a mapping from ref → unique CSS selector (for later re-querying)
4. Returns a text representation of the tree:
```
- heading "Example Domain" [ref=e1] [level=1]
- paragraph "This domain is for use in..."
- link "More information..." [ref=e2]
- textbox "Search" [ref=e3]
- button "Submit" [ref=e4]
```
### Ref storage
The ref map is stored as a JSON string in a hidden `window.__agentRefs`
global on the page. When a tool like `click @e2` is called:
1. The tool injects JS: `var el = document.querySelector(window.__agentRefs['e2']); el.click();`
2. If the element is gone (page navigated), the tool returns an error
suggesting a re-snapshot.
### Interactive-only mode
When `interactive: true`, only elements that are focusable or have an
ARIA role are included. This dramatically reduces the snapshot size for
LLM context windows.
## New files
### `src/agent_server.h` / `src/agent_server.c`
```c
/*
* WebSocket server for agent tool commands.
* Uses libsoup SoupServer with a websocket handler at /agent.
*/
void agent_server_start(int port);
void agent_server_stop(void);
int agent_server_get_port(void);
gboolean agent_server_is_running(void);
/* Broadcast a push event to all connected clients. */
void agent_server_emit_event(const char *event_name, cJSON *data);
```
### `src/agent_tools.h` / `src/agent_tools.c`
```c
/*
* Tool dispatch: receives a JSON request, executes the tool,
* returns a JSON response.
*/
/* Process a tool request JSON and return response JSON.
* The caller frees the returned cJSON*. */
cJSON *agent_tools_dispatch(cJSON *request);
```
### `src/agent_snapshot.h` / `src/agent_snapshot.c`
```c
/*
* Accessibility tree snapshot via JS injection.
* Assigns refs to interactive elements and returns a text tree.
*/
/* The JS script injected for snapshot. Exposed for testing. */
extern const char *AGENT_SNAPSHOT_JS;
/* Parse a snapshot result JSON from the JS execution. */
cJSON *agent_snapshot_parse(const char *js_result);
```
## New files (login-specific)
### `src/agent_login.h` / `src/agent_login.c`
```c
/*
* Agent-driven Nostr login — calls nostr_core_lib directly,
* bypassing the GTK login dialog. Used by the agent server
* so an AI agent can authenticate without human interaction.
*/
/* Check current login state. Returns JSON response. */
cJSON *agent_login_status(void);
/* Perform login with the given method and params.
* params is a cJSON object with "method" and method-specific fields.
* Returns JSON response with pubkey/npub on success. */
cJSON *agent_login(cJSON *params);
/* Log out — clear signer, reset state. Returns JSON response. */
cJSON *agent_logout(void);
/* Switch identity — same as login but frees the old signer first. */
cJSON *agent_switch_identity(cJSON *params);
/* Returns TRUE if logged in (signer or readonly loaded). */
gboolean agent_is_logged_in(void);
```
## Modified files
### `src/settings.h` / `src/settings.c`
Add settings:
- `agent_server_enabled` (bool, default: true)
- `agent_server_port` (int, default: 17777)
- `agent_server_allowed_origins` (string, default: "*" for localhost)
- `agent_login_timeout_ms` (int, default: 30000) — how long to wait
for an agent to log in before falling back to the GTK dialog
### `src/tab_manager.h` / `src/tab_manager.c`
- Add `GHashTable *ref_map` to `tab_info_t` for per-tab element refs
- Emit events to `agent_server` on load, title change, tab switch
### `src/main.c`
Major change to startup sequence:
- Call `agent_server_start()` **before** `do_login()` (not after)
- After starting the server, wait for either:
- An agent to call `login` within `agent_login_timeout_ms`, OR
- The timeout to expire, then show the GTK login dialog
- After login (via agent or dialog), proceed to create tabs and show window
- Call `agent_server_stop()` in `on_window_destroy()`
### `Makefile`
Add `src/agent_server.c src/agent_tools.c src/agent_snapshot.c src/agent_login.c` to `SRC`.
Add `libsoup-3.0` to pkg-config if not already included.
## Implementation order
### Phase 1 — Login + core browser automation
1. **settings** — add agent server settings (port, enabled, allowed origins)
2. **agent_server** — WebSocket server using libsoup SoupServer, JSON protocol
3. **agent_login** — login tools (login_status, login, logout, switch_identity)
that call nostr_core_lib directly, bypassing the GTK dialog
4. **main.c integration (pre-login)** — start agent server before login dialog,
support agent-driven login with fallback to GTK dialog
5. **agent_snapshot** — the JS injection script + ref assignment
6. **agent_tools** — tool dispatch + core tools (open, snapshot, click, fill,
type, eval, get_text, get_html, get_attr, get_url, get_title)
7. **main.c integration (post-login)** — wire browser tools to tab_manager,
emit events (load, tab_changed)
8. **Navigation tools** — back, forward, reload, scroll, press, wait, wait_for
9. **Tab tools** — tab_list, tab_new, tab_switch, tab_close
10. **Interaction tools** — hover, focus, close
11. **Screenshot tool** — WebKitGTK snapshot API
12. **UI status indicator** — show server port in toolbar
13. **Makefile + docs** — build and documentation
### Phase 2 — Refinements
14. Polish Phase 1 tools based on real agent usage
15. Better snapshot formatting and error messages
16. Edge case handling (page navigation during interaction, stale refs)
### Phase 3 — Extended tool catalog
17. All remaining tools from the comparison table (67 tools)
18. Find elements, check state, clipboard, mouse control, cookies/storage,
frames, dialogs, console/errors, batch, state save/load, etc.
## Testing the tools
The agent server starts before login, so the first thing an agent does is
authenticate. Once logged in, all browser tools become available.
### Login flow
```bash
# Using websocat (install: cargo install websocat)
# Check login status (before logging in)
echo '{"id":1,"tool":"login_status","params":{}}' | websocat ws://localhost:17777/agent
# Login with local key (nsec)
echo '{"id":2,"tool":"login","params":{"method":"local","nsec":"nsec1..."}}' | websocat ws://localhost:17777/agent
# Login read-only with npub
echo '{"id":3,"tool":"login","params":{"method":"readonly","npub":"npub1..."}}' | websocat ws://localhost:17777/agent
# Login with seed phrase
echo '{"id":4,"tool":"login","params":{"method":"seed","mnemonic":"abandon abandon abandon ... abandon about"}}' | websocat ws://localhost:17777/agent
# Login with NIP-46 remote signer
echo '{"id":5,"tool":"login","params":{"method":"nip46","bunker_url":"bunker://..."}}' | websocat ws://localhost:17777/agent
# Login with n_signer via Other Qube (qrexec)
echo '{"id":6,"tool":"login","params":{"method":"nsigner","transport":"qrexec","device":"nostr_signer","service":"qubes.NsignerRpc","index":0}}' | websocat ws://localhost:17777/agent
```
### Browser automation flow (after login)
```bash
# Open a page
echo '{"id":10,"tool":"open","params":{"url":"https://example.com"}}' | websocat ws://localhost:17777/agent
# Snapshot — get accessibility tree with refs
echo '{"id":11,"tool":"snapshot","params":{"interactive":true}}' | websocat ws://localhost:17777/agent
# Click by ref
echo '{"id":12,"tool":"click","params":{"ref":"@e2"}}' | websocat ws://localhost:17777/agent
# Fill an input
echo '{"id":13,"tool":"fill","params":{"ref":"@e3","value":"test@example.com"}}' | websocat ws://localhost:17777/agent
# Get text
echo '{"id":14,"tool":"get_text","params":{"ref":"@e1"}}' | websocat ws://localhost:17777/agent
# List tabs
echo '{"id":15,"tool":"tab_list","params":{}}' | websocat ws://localhost:17777/agent
# Open new tab
echo '{"id":16,"tool":"tab_new","params":{"url":"https://example.org"}}' | websocat ws://localhost:17777/agent
```
### Python example (persistent connection)
```python
import websocket, json
ws = websocket.create_connection("ws://localhost:17777/agent")
# Login first
ws.send(json.dumps({"id": 1, "tool": "login", "params": {
"method": "readonly", "npub": "npub1..."
}}))
print(json.loads(ws.recv()))
# Then browse
ws.send(json.dumps({"id": 2, "tool": "open", "params": {
"url": "https://example.com"
}}))
print(json.loads(ws.recv()))
ws.send(json.dumps({"id": 3, "tool": "snapshot", "params": {
"interactive": True
}}))
print(json.loads(ws.recv()))
```
## Future: Nostr relay integration
Since the WebSocket server is already in the browser process, a future
enhancement could expose Nostr relay connections through the same server.
An agent could subscribe to Nostr events and react to them — e.g., a
remote agent controlling the browser via Nostr DMs. This is a natural
extension of the "sovereign identity" thesis: your Nostr identity controls
your browser, not just your signing.
+249
View File
@@ -0,0 +1,249 @@
# Browser Tabs Implementation Plan
## Overview
Add full browser tab support to sovereign_browser. Each tab gets its own
toolbar (URL bar + hamburger menu) and WebKitWebView. All tabs share a single
`WebKitWebContext`, so the `sovereign://` Nostr bridge, security settings, and
TLS policy apply automatically to every tab. Tab behavior is configurable via
a new settings module.
## Architecture
```mermaid
flowchart TB
subgraph Window
Notebook[GtkNotebook — tab strip]
NewTabBtn[New Tab Button +]
end
subgraph TabPage
Toolbar[Toolbar: hamburger + URL entry]
Webview[WebKitWebView]
end
Notebook -->|page 0| TabPage
Notebook -->|page 1| TabPage
Notebook -->|page N| TabPage
TabPage -->|shared| Ctx[WebKitWebContext — sovereign bridge, security, TLS]
Ctx --> NostrBridge[nostr_bridge.c]
Ctx --> NostrInject[nostr_inject.c — per-webview]
TabManager[tab_manager.c] -->|manages| Notebook
Settings[settings.c] -->|configures| TabManager
Session[session.c] -->|save/restore| TabManager
```
### Key design decisions
1. **Per-tab toolbar.** Each notebook page is a vertical `GtkBox` containing
the toolbar (hamburger + URL entry) on top and the `WebKitWebView` below.
This is simpler than a shared toolbar that has to swap signal handlers on
tab switch, and it matches the user's preference.
2. **Shared `WebKitWebContext`.** All `WebKitWebView` instances are created
from the same context. The `sovereign://` URI scheme registration, security
manager flags, and TLS error policy are set once on the context and apply
to all tabs. No per-tab re-registration needed.
3. **Per-webview `nostr_inject_setup()`.** The `window.nostr` injection uses
`WebKitUserContentManager`, which is per-webview. Each new tab calls
`nostr_inject_setup()` on its own webview.
4. **`tab_manager.c` owns the `GtkNotebook`.** It provides functions to
create, close, switch, and query tabs. Each tab is tracked in a struct
holding the webview, URL entry, hamburger button, and tab label widget.
5. **Settings module.** A new `settings.c`/`settings.h` persists user
preferences to `~/.sovereign_browser/settings.conf` (simple key=value
text file). Configurable options:
| Setting | Default | Description |
|---------|---------|-------------|
| `restore_session` | `true` | Restore open tabs on next launch |
| `new_tab_url` | `https://example.com` | URL loaded in new tabs |
| `tab_bar_position` | `top` | `top` / `bottom` / `left` / `right` |
| `show_tab_close_buttons` | `true` | Show per-tab close button |
| `middle_click_close` | `true` | Middle-click on tab closes it |
| `ctrl_tab_switch` | `true` | Ctrl+Tab cycles tabs |
| `max_tabs` | `50` | Maximum simultaneous tabs |
| `tab_drag_reorder` | `true` | Allow drag to reorder tabs |
6. **Session module.** `session.c`/`session.h` saves the list of open tab URLs
(one per line) to `~/.sovereign_browser/session.txt` on window destroy, and
restores them on startup if `settings.restore_session` is true.
## New files
### `src/settings.h` / `src/settings.c`
```c
typedef struct {
gboolean restore_session;
char new_tab_url[512];
int tab_bar_position; /* GTK_POS_TOP, etc. */
gboolean show_tab_close_buttons;
gboolean middle_click_close;
gboolean ctrl_tab_switch;
int max_tabs;
gboolean tab_drag_reorder;
} browser_settings_t;
void settings_load(browser_settings_t *out);
void settings_save(const browser_settings_t *s);
const browser_settings_t *settings_get(void); /* global singleton */
```
### `src/tab_manager.h` / `src/tab_manager.c`
```c
typedef struct {
WebKitWebView *webview;
GtkWidget *url_entry;
GtkWidget *hamburger;
GtkWidget *tab_label; /* composite: icon + title + close btn */
char current_url[2048];
char title[256];
} tab_info_t;
void tab_manager_init(GtkContainer *parent, WebKitWebContext *ctx);
int tab_manager_new_tab(const char *url);
void tab_manager_close_tab(int index);
void tab_manager_close_active(void);
int tab_manager_get_active_index(void);
tab_info_t *tab_manager_get_active(void);
tab_info_t *tab_manager_get(int index);
int tab_manager_count(void);
void tab_manager_set_title(int index, const char *title);
```
### `src/session.h` / `src/session.c`
```c
void session_save(void); /* called on window destroy */
int session_restore(void); /* called at startup, returns tab count created */
```
## Modified files
### `src/main.c` — major refactor
- Remove the single `webview` / `url_entry` / `toolbar` creation.
- Create `WebKitWebContext` once, configure security + `sovereign://` bridge
on it (existing code moves here, unchanged logic).
- Call `tab_manager_init()` with the window's vbox and the shared context.
- The hamburger menu builder moves into `tab_manager.c` (or a shared helper)
since it now needs to reference the per-tab webview, not a global.
- All menu callbacks (`on_menu_reload`, `on_menu_stop`, `on_menu_inspector`,
`on_menu_open_file`, `on_menu_history_item`) fetch the active tab's webview
via `tab_manager_get_active()` instead of receiving it as a parameter.
- `on_load_changed` and `on_load_failed` are connected per-tab inside
`tab_manager_new_tab()` and update the per-tab URL entry and tab title.
- `on_url_activate` loads the URL into the active tab's webview.
- Keyboard shortcuts: a `key-press-event` handler on the main window checks
for Ctrl+T / Ctrl+W / Ctrl+Tab / Ctrl+Shift+Tab / Ctrl+PageUp / Ctrl+PageDown.
- `on_window_destroy` calls `session_save()` before cleanup.
### `src/nostr_inject.c` — no changes
`nostr_inject_setup()` already takes a `WebKitWebView*` and is called
per-webview. The tab manager calls it for each new tab.
### `src/nostr_bridge.c` — no changes
The bridge is registered on the shared `WebKitWebContext`. All webviews on
that context automatically get the `sovereign://` scheme handler.
### `src/history.c` — no changes
History is already global. All tabs contribute to and read from the same
history list.
### `Makefile`
Add `src/settings.c src/tab_manager.c src/session.c` to `SRC`.
## Tab UI layout
```
┌──────────────────────────────────────────────────────┐
│ [Tab 1 ×] [Tab 2 ×] [Tab 3 ×] [+] │ ← GtkNotebook tab strip
├──────────────────────────────────────────────────────┤
│ [☰] https://example.com____________ │ ← per-tab toolbar
├──────────────────────────────────────────────────────┤
│ │
│ WebKitWebView │ ← per-tab webview
│ │
└──────────────────────────────────────────────────────┘
```
### Tab label widget (composite)
Each tab label is a horizontal `GtkBox`:
```
[favicon] [Title text…] [×]
```
- Favicon: placeholder icon for now (can be wired to WebKit favicon later)
- Title: `GtkLabel` with ellipsize end, max width ~150px
- Close button: `GtkButton` with `window-close-symbolic` icon, only shown if
`settings.show_tab_close_buttons` is true
### Right-click context menu on tab label
- New Tab
- Close Tab
- Close Other Tabs
- Close Tabs to the Right
- Duplicate Tab
- Reload Tab
### Drag reordering
`GtkNotebook` supports drag reordering natively via
`gtk_notebook_set_tab_reorderable(notebook, child, TRUE)`. Enabled when
`settings.tab_drag_reorder` is true.
## Keyboard shortcuts
| Shortcut | Action |
|----------|--------|
| Ctrl+T | New tab |
| Ctrl+W | Close active tab |
| Ctrl+Tab | Next tab |
| Ctrl+Shift+Tab | Previous tab |
| Ctrl+PageUp | Previous tab |
| Ctrl+PageDown | Next tab |
| Ctrl+L | Focus URL bar of active tab |
## Session restore
On startup, if `settings.restore_session` is true and
`~/.sovereign_browser/session.txt` exists and is non-empty, each line is
loaded as a tab URL. If the file is missing or empty, a single tab is opened
with the start URL (from command-line arg or `settings.new_tab_url`).
On window destroy, before `gtk_main_quit()`, the list of open tab URLs is
written to `~/.sovereign_browser/session.txt`.
## Settings UI
A new menu item "Settings…" in the hamburger menu opens a native GTK dialog
(not a web page, to keep it simple and avoid bootstrapping a
`sovereign://settings` renderer). The dialog has checkboxes and a text entry
for each configurable option. On "OK", `settings_save()` is called and
applicable changes (like tab bar position) are applied live.
## Implementation order
1. **settings.c/h** — foundation, no UI yet, just load/save + global singleton
2. **tab_manager.c/h** — core notebook management, per-tab page creation,
tab label widget, new/close/switch
3. **session.c/h** — save/restore using tab_manager API
4. **main.c refactor** — wire up tab_manager, move context setup, rewire
callbacks to use active tab
5. **Tab interactions** — middle-click close, right-click menu, drag reorder
6. **Keyboard shortcuts** — window-level key-press handler
7. **Session save/restore** — integrate into startup and destroy
8. **Settings dialog** — GTK dialog wired to settings module
9. **Makefile + docs** — build and documentation updates
+425
View File
@@ -0,0 +1,425 @@
/*
* agent_login.c — agent-driven Nostr login for sovereign_browser
*
* Calls nostr_core_lib directly to authenticate, bypassing the GTK
* login dialog. The agent provides credentials as JSON params and
* gets back the pubkey/npub on success.
*/
#include "agent_login.h"
#include "key_store.h"
#include "nostr_bridge.h"
#include "nostr_core/nostr_core.h"
#include <string.h>
#include <stdlib.h>
#include <signal.h>
/* ── Global login state (shared with main.c) ──────────────────────── *
* main.c owns the app_state_t (signer, pubkey, method, readonly).
* We access it via extern functions that main.c provides.
*/
extern void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
key_store_method_t method, gboolean readonly);
extern void app_clear_signer(void);
extern nostr_signer_t *app_get_signer(void);
extern const char *app_get_pubkey_hex(void);
extern key_store_method_t app_get_method(void);
extern gboolean app_get_readonly(void);
/* ── Helper functions (same logic as login_dialog.c) ──────────────── */
static int derive_pubkey_hex(const unsigned char privkey[32], char pubkey_hex[65]) {
unsigned char pubkey[32];
if (nostr_ec_public_key_from_private_key(privkey, pubkey) != 0) return -1;
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
return 0;
}
static int hex_to_bytes32(const char *hex, unsigned char out[32]) {
if (strlen(hex) != 64) return -1;
for (int i = 0; i < 32; i++) {
unsigned int byte;
if (sscanf(hex + i * 2, "%02x", &byte) != 1) return -1;
out[i] = (unsigned char)byte;
}
return 0;
}
static void pubkey_hex_to_npub(const char pubkey_hex[65], char npub[128]) {
unsigned char pubkey[32];
for (int i = 0; i < 32; i++) {
unsigned int b;
sscanf(pubkey_hex + i * 2, "%02x", &b);
pubkey[i] = (unsigned char)b;
}
if (nostr_key_to_bech32(pubkey, "npub", npub) != 0) {
npub[0] = '\0';
}
}
/* ── JSON response helpers ────────────────────────────────────────── */
static cJSON *make_success(cJSON *data) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
if (data) {
cJSON_AddItemToObject(resp, "data", data);
}
return resp;
}
static cJSON *make_error(const char *code, const char *message) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", FALSE);
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "code", code);
cJSON_AddStringToObject(err, "message", message);
cJSON_AddItemToObject(resp, "error", err);
return resp;
}
static cJSON *make_login_data(const char *method_str, const char *pubkey_hex,
gboolean readonly) {
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "method", method_str);
cJSON_AddStringToObject(data, "pubkey", pubkey_hex);
char npub[128];
pubkey_hex_to_npub(pubkey_hex, npub);
cJSON_AddStringToObject(data, "npub", npub);
cJSON_AddBoolToObject(data, "readonly", readonly);
return data;
}
/* ── Login method implementations ─────────────────────────────────── */
static cJSON *login_local(cJSON *params) {
const char *nsec = cJSON_GetStringValue(cJSON_GetObjectItem(params, "nsec"));
const char *privkey_hex = cJSON_GetStringValue(cJSON_GetObjectItem(params, "privkey_hex"));
unsigned char privkey[32];
if (nsec && nsec[0]) {
if (strncmp(nsec, "nsec1", 5) == 0) {
if (nostr_decode_nsec(nsec, privkey) != 0) {
return make_error("INVALID_NSEC", "Failed to decode nsec string");
}
} else if (strlen(nsec) == 64) {
if (hex_to_bytes32(nsec, privkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex private key");
}
} else {
return make_error("INVALID_INPUT", "Enter an nsec1... or 64-char hex key");
}
} else if (privkey_hex && privkey_hex[0]) {
if (hex_to_bytes32(privkey_hex, privkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex private key");
}
} else {
return make_error("MISSING_PARAM", "Provide 'nsec' or 'privkey_hex'");
}
char pubkey_hex[65];
if (derive_pubkey_hex(privkey, pubkey_hex) != 0) {
return make_error("DERIVE_FAILED", "Failed to derive public key");
}
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Local key: pubkey=%s\n", pubkey_hex);
return make_success(make_login_data("local", pubkey_hex, FALSE));
}
static cJSON *login_seed(cJSON *params) {
const char *mnemonic = cJSON_GetStringValue(cJSON_GetObjectItem(params, "mnemonic"));
cJSON *account_json = cJSON_GetObjectItem(params, "account");
int account = (account_json && cJSON_IsNumber(account_json))
? account_json->valueint : 0;
if (!mnemonic || !mnemonic[0]) {
return make_error("MISSING_PARAM", "Provide 'mnemonic' (12 or 24 BIP-39 words)");
}
unsigned char privkey[32], pubkey[32];
if (nostr_derive_keys_from_mnemonic(mnemonic, account, privkey, pubkey) != 0) {
return make_error("INVALID_MNEMONIC", "Invalid seed phrase. Check the words and try again.");
}
char pubkey_hex[65];
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_SEED, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Seed phrase: pubkey=%s\n", pubkey_hex);
return make_success(make_login_data("seed", pubkey_hex, FALSE));
}
static cJSON *login_readonly(cJSON *params) {
const char *npub = cJSON_GetStringValue(cJSON_GetObjectItem(params, "npub"));
const char *pubkey_hex_in = cJSON_GetStringValue(cJSON_GetObjectItem(params, "pubkey_hex"));
unsigned char pubkey[32];
char pubkey_hex[65];
if (npub && npub[0]) {
if (strncmp(npub, "npub1", 5) == 0) {
if (nostr_decode_npub(npub, pubkey) != 0) {
return make_error("INVALID_NPUB", "Failed to decode npub string");
}
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
} else if (strlen(npub) == 64) {
if (hex_to_bytes32(npub, pubkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex pubkey");
}
memcpy(pubkey_hex, npub, 64);
pubkey_hex[64] = '\0';
} else {
return make_error("INVALID_INPUT", "Enter an npub1... or 64-char hex pubkey");
}
} else if (pubkey_hex_in && pubkey_hex_in[0]) {
if (hex_to_bytes32(pubkey_hex_in, pubkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex pubkey");
}
memcpy(pubkey_hex, pubkey_hex_in, 64);
pubkey_hex[64] = '\0';
} else {
return make_error("MISSING_PARAM", "Provide 'npub' or 'pubkey_hex'");
}
/* Read-only: no signer created. */
app_set_signer(NULL, pubkey_hex, KEY_STORE_METHOD_READONLY, TRUE);
nostr_bridge_set_signer(NULL, pubkey_hex, TRUE);
g_print("[agent-login] Read-only: pubkey=%s\n", pubkey_hex);
return make_success(make_login_data("readonly", pubkey_hex, TRUE));
}
static cJSON *login_nip46(cJSON *params) {
const char *bunker_url = cJSON_GetStringValue(cJSON_GetObjectItem(params, "bunker_url"));
if (!bunker_url || !bunker_url[0]) {
return make_error("MISSING_PARAM", "Provide 'bunker_url' (bunker://<pubkey>?relay=...&secret=...)");
}
nostr_nip46_bunker_url_t bunker;
memset(&bunker, 0, sizeof(bunker));
if (nostr_nip46_parse_bunker_url(bunker_url, &bunker) != 0) {
return make_error("INVALID_BUNKER", "Invalid bunker:// URL. Format: bunker://<pubkey>?relay=wss://...&secret=...");
}
/* Generate a client keypair for NIP-46 encryption. */
unsigned char client_privkey[32], client_pubkey[32];
if (nostr_generate_keypair(client_privkey, client_pubkey) != 0) {
return make_error("KEYGEN_FAILED", "Failed to generate client keypair");
}
char pubkey_hex[65];
strncpy(pubkey_hex, bunker.remote_signer_pubkey, 64);
pubkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(client_privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create client signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NIP46, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] NIP-46: remote signer pubkey=%s\n", pubkey_hex);
return make_success(make_login_data("nip46", pubkey_hex, FALSE));
}
static cJSON *login_nsigner(cJSON *params) {
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
const char *transport = cJSON_GetStringValue(cJSON_GetObjectItem(params, "transport"));
const char *device = cJSON_GetStringValue(cJSON_GetObjectItem(params, "device"));
const char *service = cJSON_GetStringValue(cJSON_GetObjectItem(params, "service"));
cJSON *index_json = cJSON_GetObjectItem(params, "index");
int index = (index_json && cJSON_IsNumber(index_json)) ? index_json->valueint : 0;
if (!transport || !transport[0]) {
return make_error("MISSING_PARAM", "Provide 'transport' (serial/unix/tcp/qrexec)");
}
if (!device || !device[0]) {
return make_error("MISSING_PARAM", "Provide 'device' (path, socket name, host:port, or qube name)");
}
/* Block SIGCHLD during n_signer calls (qrexec spawns children). */
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGCHLD);
sigprocmask(SIG_BLOCK, &block_set, &old_set);
nostr_signer_t *signer = NULL;
const char *transport_name = "unknown";
if (strcmp(transport, "serial") == 0) {
signer = nostr_signer_nsigner_serial(device, NULL, 15000);
transport_name = "serial";
} else if (strcmp(transport, "unix") == 0) {
signer = nostr_signer_nsigner_unix(device, NULL, 15000);
transport_name = "unix";
} else if (strcmp(transport, "tcp") == 0) {
char host[256] = {0};
int port = 7777;
const char *sep = strchr(device, ':');
if (sep) {
size_t hlen = sep - device;
if (hlen < sizeof(host)) {
memcpy(host, device, hlen);
host[hlen] = '\0';
port = atoi(sep + 1);
}
} else {
strncpy(host, device, sizeof(host) - 1);
}
signer = nostr_signer_nsigner_tcp(host, port, NULL, 15000);
transport_name = "tcp";
} else if (strcmp(transport, "qrexec") == 0) {
const char *svc = (service && service[0]) ? service : "qubes.NsignerRpc";
signer = nostr_signer_nsigner_qrexec(device, svc, NULL, 30000);
transport_name = "qrexec";
} else {
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("INVALID_TRANSPORT", "Unknown transport. Use: serial, unix, tcp, or qrexec");
}
if (signer == NULL) {
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("NSIGNER_CONNECT", "Failed to connect to n_signer. Check the device/path/qube.");
}
int rc = nostr_signer_nsigner_set_nostr_index(signer, index);
if (rc != NOSTR_SUCCESS) {
nostr_signer_free(signer);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("NSIGNER_INDEX", "Failed to set nostr_index on n_signer.");
}
char pubkey_hex[65];
rc = nostr_signer_get_public_key(signer, pubkey_hex);
if (rc != NOSTR_SUCCESS) {
nostr_signer_free(signer);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("NSIGNER_PUBKEY", "Failed to get pubkey from n_signer.");
}
sigprocmask(SIG_SETMASK, &old_set, NULL);
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NSIGNER, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] n_signer: transport=%s index=%d pubkey=%s\n",
transport_name, index, pubkey_hex);
return make_success(make_login_data("nsigner", pubkey_hex, FALSE));
#else
(void)params;
return make_error("NOT_COMPILED", "n_signer client not compiled in (NOSTR_ENABLE_NSIGNER_CLIENT)");
#endif
}
/* ── Public API ───────────────────────────────────────────────────── */
cJSON *agent_login_status(void) {
gboolean logged_in = agent_is_logged_in();
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "logged_in", logged_in);
if (logged_in) {
const char *method_str = "none";
switch (app_get_method()) {
case KEY_STORE_METHOD_LOCAL: method_str = "local"; break;
case KEY_STORE_METHOD_SEED: method_str = "seed"; break;
case KEY_STORE_METHOD_READONLY: method_str = "readonly"; break;
case KEY_STORE_METHOD_NIP46: method_str = "nip46"; break;
case KEY_STORE_METHOD_NSIGNER: method_str = "nsigner"; break;
default: break;
}
cJSON_AddStringToObject(data, "method", method_str);
cJSON_AddStringToObject(data, "pubkey", app_get_pubkey_hex());
cJSON_AddBoolToObject(data, "readonly", app_get_readonly());
}
return make_success(data);
}
cJSON *agent_login(cJSON *params) {
if (params == NULL) {
return make_error("MISSING_PARAMS", "No parameters provided");
}
if (agent_is_logged_in()) {
return make_error("ALREADY_LOGGED_IN", "Already logged in. Use switch_identity to change.");
}
const char *method = cJSON_GetStringValue(cJSON_GetObjectItem(params, "method"));
if (!method || !method[0]) {
return make_error("MISSING_METHOD", "Provide 'method' (local/seed/readonly/nip46/nsigner)");
}
/* Initialize nostr_core_lib if not already done. */
static gboolean nostr_initialized = FALSE;
if (!nostr_initialized) {
if (nostr_init() != NOSTR_SUCCESS) {
return make_error("INIT_FAILED", "Failed to initialize nostr_core_lib");
}
nostr_initialized = TRUE;
}
if (strcmp(method, "local") == 0) {
return login_local(params);
} else if (strcmp(method, "seed") == 0) {
return login_seed(params);
} else if (strcmp(method, "readonly") == 0) {
return login_readonly(params);
} else if (strcmp(method, "nip46") == 0) {
return login_nip46(params);
} else if (strcmp(method, "nsigner") == 0) {
return login_nsigner(params);
} else {
return make_error("UNKNOWN_METHOD", "Unknown method. Use: local, seed, readonly, nip46, or nsigner");
}
}
cJSON *agent_logout(void) {
app_clear_signer();
nostr_bridge_set_signer(NULL, "", TRUE);
key_store_clear();
g_print("[agent-login] Logged out\n");
return make_success(NULL);
}
cJSON *agent_switch_identity(cJSON *params) {
/* Free the old signer first. */
app_clear_signer();
/* Then login with the new identity. */
return agent_login(params);
}
gboolean agent_is_logged_in(void) {
return (app_get_signer() != NULL) ||
(app_get_method() == KEY_STORE_METHOD_READONLY && app_get_pubkey_hex()[0] != '\0');
}
+60
View File
@@ -0,0 +1,60 @@
/*
* agent_login.h — agent-driven Nostr login for sovereign_browser
*
* Calls nostr_core_lib directly, bypassing the GTK login dialog.
* Used by the agent server so an AI agent can authenticate without
* human interaction.
*/
#ifndef AGENT_LOGIN_H
#define AGENT_LOGIN_H
#include <glib.h>
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Check current login state. Returns a JSON response (caller frees).
* Response: {"success":true,"data":{"logged_in":true/false,"method":"local","pubkey":"...","readonly":false}}
*/
cJSON *agent_login_status(void);
/*
* Perform login with the given method and params.
* params is a cJSON object with "method" and method-specific fields:
* - local: {"method":"local","nsec":"nsec1..."} or {"method":"local","privkey_hex":"..."}
* - seed: {"method":"seed","mnemonic":"word1 word2 ...","account":0}
* - readonly: {"method":"readonly","npub":"npub1..."} or {"method":"readonly","pubkey_hex":"..."}
* - nip46: {"method":"nip46","bunker_url":"bunker://..."}
* - nsigner: {"method":"nsigner","transport":"qrexec","device":"nostr_signer","service":"qubes.NsignerRpc","index":0}
*
* Returns JSON response (caller frees).
* On success: {"success":true,"data":{"method":"local","pubkey":"...","npub":"...","readonly":false}}
* On failure: {"success":false,"error":{"code":"...","message":"..."}}
*/
cJSON *agent_login(cJSON *params);
/*
* Log out — clear signer, reset state. Returns JSON response (caller frees).
*/
cJSON *agent_logout(void);
/*
* Switch identity — same as login but frees the old signer first.
* Same params as agent_login. Returns JSON response (caller frees).
*/
cJSON *agent_switch_identity(cJSON *params);
/*
* Returns TRUE if logged in (signer or readonly loaded).
*/
gboolean agent_is_logged_in(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_LOGIN_H */
+294
View File
@@ -0,0 +1,294 @@
/*
* agent_server.c — WebSocket server for agent tool commands
*
* Uses libsoup's SoupServer to provide a WebSocket endpoint at /agent.
* External AI agents connect, send JSON tool commands, and receive
* JSON responses. The server also supports plain HTTP GET to / for
* status discovery (curl-testable without a WebSocket client).
*/
#include "agent_server.h"
#include "settings.h"
#include <libsoup/soup.h>
#include <string.h>
#include <stdlib.h>
/* Forward declarations from agent_tools.c (created next) */
extern cJSON *agent_tools_dispatch(cJSON *request);
/* ── Static state ─────────────────────────────────────────────────── */
static SoupServer *g_server = NULL;
static int g_port = 0;
static gboolean g_running = FALSE;
static GPtrArray *g_clients = NULL; /* array of SoupWebsocketConnection* */
static agent_login_callback_t g_login_cb = NULL;
/* ── Client management ────────────────────────────────────────────── */
static void on_ws_message(SoupWebsocketConnection *conn,
SoupWebsocketDataType type,
GBytes *message,
gpointer user_data);
static void on_ws_closed(SoupWebsocketConnection *conn, gpointer user_data);
static void on_ws_error(SoupWebsocketConnection *conn, gpointer user_data);
/* Forward declarations */
static void on_websocket_handler(SoupServer *server,
SoupServerMessage *msg,
const char *path,
SoupWebsocketConnection *conn,
gpointer user_data);
static void track_client(SoupWebsocketConnection *conn) {
if (g_clients == NULL) {
g_clients = g_ptr_array_new();
}
g_ptr_array_add(g_clients, conn);
g_signal_connect(conn, "message", G_CALLBACK(on_ws_message), NULL);
g_signal_connect(conn, "closed", G_CALLBACK(on_ws_closed), NULL);
g_signal_connect(conn, "error", G_CALLBACK(on_ws_error), NULL);
g_print("[agent] Client connected (%d total)\n", g_clients->len);
}
static void untrack_client(SoupWebsocketConnection *conn) {
if (g_clients == NULL) return;
g_ptr_array_remove_fast(g_clients, conn);
g_print("[agent] Client disconnected (%d remaining)\n", g_clients->len);
}
/* ── Send helpers ─────────────────────────────────────────────────── */
static void send_json_to_client(SoupWebsocketConnection *conn, const char *json_str) {
if (soup_websocket_connection_get_state(conn) != SOUP_WEBSOCKET_STATE_OPEN) {
return;
}
soup_websocket_connection_send_text(conn, json_str);
}
static void send_json_to_all(const char *json_str) {
if (g_clients == NULL) return;
for (guint i = 0; i < g_clients->len; i++) {
SoupWebsocketConnection *conn = g_ptr_array_index(g_clients, i);
send_json_to_client(conn, json_str);
}
}
/* ── WebSocket callbacks ──────────────────────────────────────────── */
static void on_ws_message(SoupWebsocketConnection *conn,
SoupWebsocketDataType type,
GBytes *message,
gpointer user_data) {
(void)user_data;
(void)type;
gsize size = 0;
const gchar *data = g_bytes_get_data(message, &size);
if (data == NULL || size == 0) return;
/* Parse the JSON request. */
cJSON *request = cJSON_ParseWithLength(data, size);
if (request == NULL) {
const char *err_json = "{\"success\":false,\"error\":{\"code\":\"INVALID_JSON\","
"\"message\":\"Failed to parse JSON request\"}}";
send_json_to_client(conn, err_json);
return;
}
/* Dispatch the tool. */
cJSON *response = agent_tools_dispatch(request);
cJSON_Delete(request);
if (response == NULL) {
const char *err_json = "{\"success\":false,\"error\":{\"code\":\"INTERNAL_ERROR\","
"\"message\":\"Tool dispatch returned NULL\"}}";
send_json_to_client(conn, err_json);
return;
}
/* Send the response. */
char *response_str = cJSON_PrintUnformatted(response);
if (response_str) {
send_json_to_client(conn, response_str);
free(response_str);
}
cJSON_Delete(response);
}
static void on_ws_closed(SoupWebsocketConnection *conn, gpointer user_data) {
(void)user_data;
untrack_client(conn);
}
static void on_ws_error(SoupWebsocketConnection *conn, gpointer user_data) {
(void)user_data;
g_printerr("[agent] WebSocket error on client\n");
untrack_client(conn);
}
/* ── WebSocket handler (called by SoupServer on new connection) ───── */
static void on_websocket_handler(SoupServer *server,
SoupServerMessage *msg,
const char *path,
SoupWebsocketConnection *conn,
gpointer user_data) {
(void)server;
(void)path;
(void)msg;
(void)user_data;
track_client(conn);
}
/* ── HTTP handler for status (GET /) ──────────────────────────────── */
static void on_http_handler(SoupServer *server,
SoupServerMessage *msg,
const char *path,
GHashTable *query,
gpointer user_data) {
(void)server;
(void)query;
(void)user_data;
if (g_strcmp0(path, "/") != 0) {
soup_server_message_set_status(msg, 404, NULL);
return;
}
/* Build a status JSON response. */
cJSON *status = cJSON_CreateObject();
cJSON_AddStringToObject(status, "name", "sovereign_browser agent server");
cJSON_AddBoolToObject(status, "running", g_running);
cJSON_AddNumberToObject(status, "port", g_port);
cJSON_AddNumberToObject(status, "clients", g_clients ? g_clients->len : 0);
/* Check login state via agent_tools (delegates to agent_login). */
extern gboolean agent_is_logged_in(void);
cJSON_AddBoolToObject(status, "logged_in", agent_is_logged_in());
char *json_str = cJSON_PrintUnformatted(status);
soup_server_message_set_status(msg, 200, NULL);
soup_server_message_set_response(msg, "application/json",
SOUP_MEMORY_TAKE, json_str, strlen(json_str));
cJSON_Delete(status);
}
/* ── Public API ───────────────────────────────────────────────────── */
int agent_server_start(int port) {
if (g_running) {
g_print("[agent] Server already running on port %d\n", g_port);
return 0;
}
GError *error = NULL;
g_server = soup_server_new("server-header", "sovereign_browser-agent", NULL);
if (g_server == NULL) {
g_printerr("[agent] Failed to create SoupServer\n");
return -1;
}
/* Add HTTP handler for status at /. */
soup_server_add_handler(g_server, "/", on_http_handler, NULL, NULL);
/* Add WebSocket handler at /agent. */
soup_server_add_websocket_handler(g_server, "/agent", NULL, NULL,
on_websocket_handler, NULL, NULL);
/* Listen on the specified port (0 = auto-assign). */
soup_server_listen_local(g_server, port, 0, &error);
if (error != NULL) {
g_printerr("[agent] Failed to listen on port %d: %s\n", port, error->message);
g_error_free(error);
g_object_unref(g_server);
g_server = NULL;
return -1;
}
/* Get the actual bound port. */
GSList *uris = soup_server_get_uris(g_server);
if (uris != NULL) {
GUri *uri = uris->data;
g_port = g_uri_get_port(uri);
g_slist_free(uris);
} else {
g_port = port;
}
g_running = TRUE;
g_print("[agent] WebSocket server listening on ws://localhost:%d/agent\n", g_port);
g_print("[agent] Status endpoint: http://localhost:%d/\n", g_port);
return 0;
}
void agent_server_stop(void) {
if (!g_running) return;
/* Close all client connections. */
if (g_clients != NULL) {
for (guint i = 0; i < g_clients->len; i++) {
SoupWebsocketConnection *conn = g_ptr_array_index(g_clients, i);
soup_websocket_connection_close(conn, SOUP_WEBSOCKET_CLOSE_GOING_AWAY, "server shutting down");
}
g_ptr_array_free(g_clients, TRUE);
g_clients = NULL;
}
if (g_server != NULL) {
g_object_unref(g_server);
g_server = NULL;
}
g_running = FALSE;
g_port = 0;
g_print("[agent] Server stopped\n");
}
int agent_server_get_port(void) {
return g_port;
}
gboolean agent_server_is_running(void) {
return g_running;
}
int agent_server_get_client_count(void) {
return g_clients ? g_clients->len : 0;
}
void agent_server_emit_event(const char *event_name, cJSON *data) {
if (!g_running || g_clients == NULL || g_clients->len == 0) {
if (data) cJSON_Delete(data);
return;
}
cJSON *event = cJSON_CreateObject();
cJSON_AddStringToObject(event, "type", "event");
cJSON_AddStringToObject(event, "event", event_name);
if (data) {
cJSON_AddItemToObject(event, "data", data);
}
char *json_str = cJSON_PrintUnformatted(event);
if (json_str) {
send_json_to_all(json_str);
free(json_str);
}
cJSON_Delete(event);
}
void agent_server_set_login_callback(agent_login_callback_t cb) {
g_login_cb = cb;
}
/* Called by agent_login.c when login succeeds — notifies main.c. */
void agent_server_notify_login(void) {
if (g_login_cb) {
g_login_cb();
}
}
+74
View File
@@ -0,0 +1,74 @@
/*
* agent_server.h — WebSocket server for agent tool commands
*
* Embeds a WebSocket server in the browser process using libsoup's
* SoupServer. External AI agents connect to ws://localhost:PORT/agent
* and send JSON tool commands (navigate, snapshot, click, etc.).
*
* The server starts before the login dialog so agents can authenticate
* without human interaction. Before login, only login tools are available;
* after login, all browser tools become available.
*/
#ifndef AGENT_SERVER_H
#define AGENT_SERVER_H
#include <glib.h>
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Start the agent WebSocket server on the given port.
* Returns 0 on success, -1 on failure.
*/
int agent_server_start(int port);
/*
* Stop the agent server and close all connections.
*/
void agent_server_stop(void);
/*
* Get the port the server is listening on.
* Returns 0 if not running.
*/
int agent_server_get_port(void);
/*
* Check if the server is running.
*/
gboolean agent_server_is_running(void);
/*
* Get the number of connected clients.
*/
int agent_server_get_client_count(void);
/*
* Broadcast a push event to all connected clients.
* The event JSON is: {"type":"event","event":<name>,"data":<data>}
* Takes ownership of data (frees it).
*/
void agent_server_emit_event(const char *event_name, cJSON *data);
/*
* Set a callback that is called when a login tool succeeds.
* This allows main.c to proceed past the login wait loop.
*/
typedef void (*agent_login_callback_t)(void);
void agent_server_set_login_callback(agent_login_callback_t cb);
/*
* Called by agent_login.c when login succeeds — notifies main.c
* via the callback set by agent_server_set_login_callback().
*/
void agent_server_notify_login(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_SERVER_H */
+291
View File
@@ -0,0 +1,291 @@
/*
* agent_snapshot.c — accessibility tree snapshot for agent tools
*
* Injects a JS script that walks the DOM, assigns refs to interactive
* elements, and returns a text tree. The ref map is stored in
* window.__agentRefs for later use by click/fill/type tools.
*/
#include "agent_snapshot.h"
#include <string.h>
#include <stdlib.h>
/* ── Synchronous JS evaluation ─────────────────────────────────────── *
* WebKitGTK's evaluate_javascript is async. We use a nested GMainLoop
* to wait for the result synchronously.
*/
typedef struct {
GMainLoop *loop;
char *result;
gboolean done;
} js_eval_ctx_t;
static void on_js_finished(GObject *source, GAsyncResult *res, gpointer user_data) {
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
WebKitWebView *webview = WEBKIT_WEB_VIEW(source);
JSCValue *value = webkit_web_view_evaluate_javascript_finish(webview, res, NULL);
if (value != NULL) {
/* Convert the JSCValue to a string. */
char *str = jsc_value_to_string(value);
if (str != NULL) {
ctx->result = g_strdup(str);
free(str);
}
g_object_unref(value);
}
ctx->done = TRUE;
if (g_main_loop_is_running(ctx->loop)) {
g_main_loop_quit(ctx->loop);
}
}
char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
int timeout_ms) {
if (webview == NULL || script == NULL) return NULL;
js_eval_ctx_t ctx = {0};
ctx.loop = g_main_loop_new(NULL, FALSE);
/* Start async evaluation. */
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
on_js_finished, &ctx);
/* Run a nested main loop with a timeout. */
if (!ctx.done) {
GSource *timeout_src = g_timeout_source_new(timeout_ms);
g_source_set_callback(timeout_src, (GSourceFunc)g_main_loop_quit, ctx.loop, NULL);
g_source_attach(timeout_src, g_main_context_get_thread_default());
g_source_unref(timeout_src);
g_main_loop_run(ctx.loop);
g_source_destroy(timeout_src);
}
g_main_loop_unref(ctx.loop);
if (!ctx.done) {
g_printerr("[agent] JS evaluation timed out (%d ms)\n", timeout_ms);
return NULL;
}
return ctx.result;
}
/* ── Snapshot JS script ───────────────────────────────────────────── *
* This script walks the DOM, assigns refs to interactive elements,
* stores the ref map in window.__agentRefs, and returns a JSON string
* with the tree text and ref metadata.
*/
const char *AGENT_SNAPSHOT_JS =
"(function() {\n"
" 'use strict';\n"
"\n"
" var interactiveOnly = arguments[0] || false;\n"
" var compact = arguments[1] || false;\n"
"\n"
" var refMap = {};\n"
" var refCount = 0;\n"
" var treeLines = [];\n"
"\n"
" /* Check if an element is interactive. */\n"
" function isInteractive(el) {\n"
" var tag = el.tagName.toLowerCase();\n"
" if (tag === 'a' && el.href) return true;\n"
" if (tag === 'button') return true;\n"
" if (tag === 'input' && el.type !== 'hidden') return true;\n"
" if (tag === 'select') return true;\n"
" if (tag === 'textarea') return true;\n"
" if (tag === 'summary') return true;\n"
" if (tag === 'details') return true;\n"
" if (el.hasAttribute('role')) {\n"
" var role = el.getAttribute('role');\n"
" if (['button','link','checkbox','radio','textbox','searchbox',\n"
" 'slider','spinbutton','tab','tablist','menuitem','combobox',\n"
" 'option','switch','treeitem'].indexOf(role) >= 0) return true;\n"
" }\n"
" if (el.hasAttribute('tabindex') && el.tabIndex >= 0) return true;\n"
" if (el.hasAttribute('onclick')) return true;\n"
" return false;\n"
" }\n"
"\n"
" /* Get the accessible name for an element. */\n"
" function getName(el) {\n"
" if (el.hasAttribute('aria-label')) return el.getAttribute('aria-label');\n"
" if (el.hasAttribute('aria-labelledby')) {\n"
" var lbl = document.getElementById(el.getAttribute('aria-labelledby'));\n"
" if (lbl) return lbl.textContent.trim();\n"
" }\n"
" if (el.tagName.toLowerCase() === 'input' || el.tagName.toLowerCase() === 'textarea') {\n"
" if (el.hasAttribute('placeholder')) return el.placeholder;\n"
" var lbl2 = document.querySelector('label[for=\"' + el.id + '\"]');\n"
" if (lbl2) return lbl2.textContent.trim();\n"
" }\n"
" if (el.tagName.toLowerCase() === 'select') {\n"
" if (el.options.length > 0) return el.options[el.selectedIndex].text;\n"
" }\n"
" var text = el.textContent.trim();\n"
" if (text.length > 80) text = text.substring(0, 77) + '...';\n"
" return text;\n"
" }\n"
"\n"
" /* Get the role for an element. */\n"
" function getRole(el) {\n"
" if (el.hasAttribute('role')) return el.getAttribute('role');\n"
" var tag = el.tagName.toLowerCase();\n"
" var map = {\n"
" 'a': 'link', 'button': 'button', 'input': 'textbox',\n"
" 'select': 'combobox', 'textarea': 'textbox', 'img': 'image',\n"
" 'h1': 'heading', 'h2': 'heading', 'h3': 'heading',\n"
" 'h4': 'heading', 'h5': 'heading', 'h6': 'heading',\n"
" 'nav': 'navigation', 'main': 'main', 'article': 'article',\n"
" 'section': 'region', 'form': 'form', 'ul': 'list',\n"
" 'ol': 'list', 'li': 'listitem', 'table': 'table',\n"
" 'label': 'label', 'p': 'paragraph', 'summary': 'button'\n"
" };\n"
" if (tag === 'input') {\n"
" var type = (el.type || 'text').toLowerCase();\n"
" if (type === 'checkbox') return 'checkbox';\n"
" if (type === 'radio') return 'radio';\n"
" if (type === 'button' || type === 'submit' || type === 'reset') return 'button';\n"
" if (type === 'search') return 'searchbox';\n"
" if (type === 'range') return 'slider';\n"
" if (type === 'number') return 'spinbutton';\n"
" return 'textbox';\n"
" }\n"
" return map[tag] || 'generic';\n"
" }\n"
"\n"
" /* Generate a unique CSS selector for an element. */\n"
" function getSelector(el) {\n"
" if (el.id) return '#' + el.id;\n"
" var path = [];\n"
" while (el && el.nodeType === 1 && el !== document.body) {\n"
" var part = el.tagName.toLowerCase();\n"
" if (el.className && typeof el.className === 'string') {\n"
" var classes = el.className.trim().split(/\\s+/).slice(0, 2);\n"
" if (classes.length > 0 && classes[0]) part += '.' + classes.join('.');\n"
" }\n"
" var parent = el.parentNode;\n"
" if (parent) {\n"
" var siblings = Array.prototype.filter.call(parent.children,\n"
" function(c) { return c.tagName === el.tagName; });\n"
" if (siblings.length > 1) {\n"
" part += ':nth-of-type(' + (siblings.indexOf(el) + 1) + ')';\n"
" }\n"
" }\n"
" path.unshift(part);\n"
" el = parent;\n"
" }\n"
" return path.length > 0 ? path.join(' > ') : 'body';\n"
" }\n"
"\n"
" /* Walk the DOM. */\n"
" function walk(el, depth) {\n"
" if (!el || el.nodeType !== 1) return;\n"
"\n"
" var role = getRole(el);\n"
" var name = getName(el);\n"
" var interactive = isInteractive(el);\n"
"\n"
" /* Skip non-interactive elements in interactive-only mode. */\n"
" if (interactiveOnly && !interactive) {\n"
" /* Still walk children — they might be interactive. */\n"
" for (var i = 0; i < el.children.length; i++) {\n"
" walk(el.children[i], depth);\n"
" }\n"
" return;\n"
" }\n"
"\n"
" /* Skip empty elements in compact mode. */\n"
" if (compact && !interactive && !name && el.children.length === 0) return;\n"
"\n"
" var indent = '';\n"
" for (var d = 0; d < depth; d++) indent += ' ';\n"
"\n"
" var line = indent + '- ' + role;\n"
" if (name) line += ' \"' + name + '\"';\n"
"\n"
" if (interactive) {\n"
" refCount++;\n"
" var ref = 'e' + refCount;\n"
" var selector = getSelector(el);\n"
" refMap[ref] = {\n"
" role: role,\n"
" name: name,\n"
" selector: selector,\n"
" tag: el.tagName.toLowerCase(),\n"
" href: el.href || '',\n"
" type: el.type || '',\n"
" value: el.value || ''\n"
" };\n"
" line += ' [ref=' + ref + ']';\n"
" if (el.tagName.toLowerCase() === 'input' && el.type) {\n"
" line += ' [type=' + el.type + ']';\n"
" }\n"
" }\n"
"\n"
" if (el.tagName && el.tagName.match(/^H[1-6]$/)) {\n"
" line += ' [level=' + el.tagName[1] + ']';\n"
" }\n"
"\n"
" treeLines.push(line);\n"
"\n"
" for (var i = 0; i < el.children.length; i++) {\n"
" walk(el.children[i], depth + 1);\n"
" }\n"
" }\n"
"\n"
" /* Start from body. */\n"
" walk(document.body, 0);\n"
"\n"
" /* Store ref map globally for later use by click/fill tools. */\n"
" window.__agentRefs = refMap;\n"
"\n"
" /* Return JSON result. */\n"
" return JSON.stringify({\n"
" snapshot: treeLines.join('\\n'),\n"
" refs: refMap,\n"
" refCount: refCount\n"
" });\n"
"})();\n";
/* ── Snapshot API ─────────────────────────────────────────────────── */
cJSON *agent_snapshot_take(WebKitWebView *webview,
gboolean interactive,
gboolean compact) {
if (webview == NULL) return NULL;
/* Build the JS call with arguments. */
char script[512];
snprintf(script, sizeof(script),
"AGENT_SNAPSHOT_JS_WRAPPER = (%s); AGENT_SNAPSHOT_JS_WRAPPER(%s, %s);",
AGENT_SNAPSHOT_JS,
interactive ? "true" : "false",
compact ? "true" : "false");
/* Actually, we need to call the IIFE with arguments. Let's use a
* different approach — wrap the function and call it. */
char *full_script = g_strdup_printf(
"(%s)(%s, %s)",
AGENT_SNAPSHOT_JS,
interactive ? "true" : "false",
compact ? "true" : "false");
char *result = agent_js_eval_sync(webview, full_script, 10000);
g_free(full_script);
if (result == NULL) {
return NULL;
}
cJSON *json = cJSON_Parse(result);
g_free(result);
return json;
}
+56
View File
@@ -0,0 +1,56 @@
/*
* agent_snapshot.h — accessibility tree snapshot for agent tools
*
* Injects a JavaScript script into the active tab's webview that walks
* the DOM, assigns sequential refs (e1, e2, ...) to interactive elements,
* and returns a text representation of the accessibility tree.
*/
#ifndef AGENT_SNAPSHOT_H
#define AGENT_SNAPSHOT_H
#include <webkit2/webkit2.h>
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* The JS script injected for snapshot. It walks the DOM, assigns refs,
* stores the ref map in window.__agentRefs, and returns a JSON string
* with the tree text and ref metadata.
*/
extern const char *AGENT_SNAPSHOT_JS;
/*
* Take a snapshot of the given webview. This injects the snapshot JS
* and returns the result as a cJSON object:
* {"snapshot": "- heading ... [ref=e1]\n- link ... [ref=e2]",
* "refs": {"e1": {"role":"heading","name":"..."}, ...}}
*
* Returns NULL on failure. Caller frees the result.
*
* interactive: if TRUE, only include interactive elements
* compact: if TRUE, remove empty structural elements
*/
cJSON *agent_snapshot_take(WebKitWebView *webview,
gboolean interactive,
gboolean compact);
/*
* Execute JavaScript in the webview and wait for the result.
* This is a synchronous wrapper around the async evaluate_javascript API.
* Returns the result string (caller must free) or NULL on failure/timeout.
*
* script: the JavaScript to execute (must return a string)
* timeout_ms: how long to wait for the result
*/
char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
int timeout_ms);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_SNAPSHOT_H */
+782
View File
@@ -0,0 +1,782 @@
/*
* agent_tools.c — tool dispatch for agent browser automation
*
* Routes JSON tool requests to the appropriate implementation. Tools
* fall into two categories:
* - Login tools (available before login): login_status, login, logout, switch_identity
* - Browser tools (available after login): open, snapshot, click, fill, etc.
*/
#include "agent_tools.h"
#include "agent_login.h"
#include "agent_snapshot.h"
#include "agent_server.h"
#include "tab_manager.h"
#include "settings.h"
#include <string.h>
#include <stdlib.h>
/* ── JSON helpers ─────────────────────────────────────────────────── */
static cJSON *make_success(cJSON *data) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
if (data) {
cJSON_AddItemToObject(resp, "data", data);
}
return resp;
}
static cJSON *make_error(const char *code, const char *message) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", FALSE);
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "code", code);
cJSON_AddStringToObject(err, "message", message);
cJSON_AddItemToObject(resp, "error", err);
return resp;
}
static const char *get_string_param(cJSON *params, const char *key) {
return cJSON_GetStringValue(cJSON_GetObjectItem(params, key));
}
static int get_int_param(cJSON *params, const char *key, int fallback) {
cJSON *item = cJSON_GetObjectItem(params, key);
if (item && cJSON_IsNumber(item)) return item->valueint;
return fallback;
}
static gboolean get_bool_param(cJSON *params, const char *key, gboolean fallback) {
cJSON *item = cJSON_GetObjectItem(params, key);
if (item && cJSON_IsBool(item)) return cJSON_IsTrue(item);
return fallback;
}
/* ── Normalize URL (same logic as tab_manager.c) ──────────────────── */
static char *normalize_url(const char *input) {
if (input == NULL || input[0] == '\0') return NULL;
if (strstr(input, "://") != NULL) return g_strdup(input);
return g_strdup_printf("https://%s", input);
}
/* ── Get active webview (or NULL if not logged in / no tabs) ──────── */
static WebKitWebView *get_active_webview(void) {
tab_info_t *tab = tab_manager_get_active();
return tab ? tab->webview : NULL;
}
/* ── Resolve a ref or selector to a JS element query ──────────────── */
static char *resolve_ref_to_selector(const char *ref) {
if (ref == NULL || ref[0] == '\0') return NULL;
/* If it starts with @, it's a ref from snapshot. */
if (ref[0] == '@') {
const char *ref_id = ref + 1;
/* Query the page's __agentRefs for the selector. */
char script[256];
snprintf(script, sizeof(script),
"(window.__agentRefs && window.__agentRefs['%s']) ? "
"window.__agentRefs['%s'].selector : null",
ref_id, ref_id);
WebKitWebView *wv = get_active_webview();
if (wv == NULL) return NULL;
char *result = agent_js_eval_sync(wv, script, 5000);
if (result == NULL || result[0] == '\0' || strcmp(result, "null") == 0) {
g_free(result);
return NULL;
}
return result;
}
/* Otherwise treat as a CSS selector. */
return g_strdup(ref);
}
/* ── Login tools ──────────────────────────────────────────────────── */
static cJSON *tool_login_status(cJSON *params) {
(void)params;
return agent_login_status();
}
static cJSON *tool_login(cJSON *params) {
cJSON *result = agent_login(params);
/* If login succeeded, notify the server so main.c can proceed. */
if (cJSON_IsTrue(cJSON_GetObjectItem(result, "success"))) {
agent_server_notify_login();
}
return result;
}
static cJSON *tool_logout(cJSON *params) {
(void)params;
return agent_logout();
}
static cJSON *tool_switch_identity(cJSON *params) {
cJSON *result = agent_switch_identity(params);
if (cJSON_IsTrue(cJSON_GetObjectItem(result, "success"))) {
agent_server_notify_login();
}
return result;
}
/* ── Navigation tools ─────────────────────────────────────────────── */
static cJSON *tool_open(cJSON *params) {
const char *url = get_string_param(params, "url");
if (!url || !url[0]) return make_error("MISSING_PARAM", "Provide 'url'");
char *normalized = normalize_url(url);
if (!normalized) return make_error("INVALID_URL", "Invalid URL");
tab_info_t *tab = tab_manager_get_active();
if (!tab) {
g_free(normalized);
return make_error("NO_TAB", "No active tab");
}
webkit_web_view_load_uri(tab->webview, normalized);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "url", normalized);
g_free(normalized);
return make_success(data);
}
static cJSON *tool_back(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
webkit_web_view_go_back(wv);
return make_success(NULL);
}
static cJSON *tool_forward(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
webkit_web_view_go_forward(wv);
return make_success(NULL);
}
static cJSON *tool_reload(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
webkit_web_view_reload_bypass_cache(wv);
return make_success(NULL);
}
static cJSON *tool_get_url(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
const gchar *uri = webkit_web_view_get_uri(wv);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "url", uri ? uri : "");
return make_success(data);
}
static cJSON *tool_get_title(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
const gchar *title = webkit_web_view_get_title(wv);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "title", title ? title : "");
return make_success(data);
}
/* ── Snapshot & inspection tools ──────────────────────────────────── */
static cJSON *tool_snapshot(cJSON *params) {
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
gboolean interactive = get_bool_param(params, "interactive", FALSE);
gboolean compact = get_bool_param(params, "compact", FALSE);
cJSON *result = agent_snapshot_take(wv, interactive, compact);
if (!result) return make_error("SNAPSHOT_FAILED", "Failed to take snapshot");
return make_success(result);
}
static cJSON *tool_get_text(cJSON *params) {
const char *ref = get_string_param(params, "ref");
const char *selector = get_string_param(params, "selector");
char *sel = NULL;
if (ref && ref[0]) {
sel = resolve_ref_to_selector(ref);
if (!sel) return make_error("REF_NOT_FOUND", "No element with that ref. Take a new snapshot.");
} else if (selector && selector[0]) {
sel = g_strdup(selector);
} else {
return make_error("MISSING_PARAM", "Provide 'ref' or 'selector'");
}
WebKitWebView *wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
char *script = g_strdup_printf(
"(function() { var el = document.querySelector(%s); "
"return el ? el.textContent.trim() : null; })();",
sel);
char *result = agent_js_eval_sync(wv, script, 5000);
g_free(script);
g_free(sel);
if (!result || strcmp(result, "null") == 0) {
g_free(result);
return make_error("ELEMENT_NOT_FOUND", "No element matching selector");
}
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "text", result);
g_free(result);
return make_success(data);
}
static cJSON *tool_get_html(cJSON *params) {
const char *ref = get_string_param(params, "ref");
const char *selector = get_string_param(params, "selector");
char *sel = NULL;
if (ref && ref[0]) {
sel = resolve_ref_to_selector(ref);
if (!sel) return make_error("REF_NOT_FOUND", "No element with that ref. Take a new snapshot.");
} else if (selector && selector[0]) {
sel = g_strdup(selector);
} else {
return make_error("MISSING_PARAM", "Provide 'ref' or 'selector'");
}
WebKitWebView *wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
char *script = g_strdup_printf(
"(function() { var el = document.querySelector(%s); "
"return el ? el.innerHTML : null; })();",
sel);
char *result = agent_js_eval_sync(wv, script, 5000);
g_free(script);
g_free(sel);
if (!result || strcmp(result, "null") == 0) {
g_free(result);
return make_error("ELEMENT_NOT_FOUND", "No element matching selector");
}
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "html", result);
g_free(result);
return make_success(data);
}
static cJSON *tool_get_attr(cJSON *params) {
const char *ref = get_string_param(params, "ref");
const char *selector = get_string_param(params, "selector");
const char *attr = get_string_param(params, "attr");
if (!attr || !attr[0]) return make_error("MISSING_PARAM", "Provide 'attr'");
char *sel = NULL;
if (ref && ref[0]) {
sel = resolve_ref_to_selector(ref);
if (!sel) return make_error("REF_NOT_FOUND", "No element with that ref. Take a new snapshot.");
} else if (selector && selector[0]) {
sel = g_strdup(selector);
} else {
return make_error("MISSING_PARAM", "Provide 'ref' or 'selector'");
}
WebKitWebView *wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
char *script = g_strdup_printf(
"(function() { var el = document.querySelector(%s); "
"return el ? (el.getAttribute('%s') || '') : null; })();",
sel, attr);
char *result = agent_js_eval_sync(wv, script, 5000);
g_free(script);
g_free(sel);
if (!result || strcmp(result, "null") == 0) {
g_free(result);
return make_error("ELEMENT_NOT_FOUND", "No element matching selector");
}
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "attr", attr);
cJSON_AddStringToObject(data, "value", result);
g_free(result);
return make_success(data);
}
static cJSON *tool_eval(cJSON *params) {
const char *script_param = get_string_param(params, "script");
if (!script_param || !script_param[0]) return make_error("MISSING_PARAM", "Provide 'script'");
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
char *result = agent_js_eval_sync(wv, script_param, 10000);
if (!result) return make_error("EVAL_FAILED", "JavaScript evaluation failed or timed out");
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "result", result);
g_free(result);
return make_success(data);
}
/* ── Interaction tools ────────────────────────────────────────────── */
static cJSON *tool_click(cJSON *params) {
const char *ref = get_string_param(params, "ref");
const char *selector = get_string_param(params, "selector");
char *sel = NULL;
if (ref && ref[0]) {
sel = resolve_ref_to_selector(ref);
if (!sel) return make_error("REF_NOT_FOUND", "No element with that ref. Take a new snapshot.");
} else if (selector && selector[0]) {
sel = g_strdup(selector);
} else {
return make_error("MISSING_PARAM", "Provide 'ref' or 'selector'");
}
WebKitWebView *wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
char *script = g_strdup_printf(
"(function() { var el = document.querySelector(%s); "
"if (el) { el.click(); return 'ok'; } return null; })();",
sel);
char *result = agent_js_eval_sync(wv, script, 5000);
g_free(script);
g_free(sel);
if (!result || strcmp(result, "null") == 0) {
g_free(result);
return make_error("ELEMENT_NOT_FOUND", "No element matching selector");
}
g_free(result);
return make_success(NULL);
}
static cJSON *tool_fill(cJSON *params) {
const char *ref = get_string_param(params, "ref");
const char *selector = get_string_param(params, "selector");
const char *value = get_string_param(params, "value");
if (!value) return make_error("MISSING_PARAM", "Provide 'value'");
char *sel = NULL;
if (ref && ref[0]) {
sel = resolve_ref_to_selector(ref);
if (!sel) return make_error("REF_NOT_FOUND", "No element with that ref. Take a new snapshot.");
} else if (selector && selector[0]) {
sel = g_strdup(selector);
} else {
return make_error("MISSING_PARAM", "Provide 'ref' or 'selector'");
}
WebKitWebView *wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
/* Escape the value for JS string. */
char *escaped = g_strescape(value, NULL);
char *script = g_strdup_printf(
"(function() { var el = document.querySelector(%s); "
"if (el) { el.value = \"%s\"; el.dispatchEvent(new Event('input', {bubbles:true})); "
"el.dispatchEvent(new Event('change', {bubbles:true})); return 'ok'; } return null; })();",
sel, escaped);
char *result = agent_js_eval_sync(wv, script, 5000);
g_free(script);
g_free(escaped);
g_free(sel);
if (!result || strcmp(result, "null") == 0) {
g_free(result);
return make_error("ELEMENT_NOT_FOUND", "No element matching selector");
}
g_free(result);
return make_success(NULL);
}
static cJSON *tool_type(cJSON *params) {
const char *ref = get_string_param(params, "ref");
const char *selector = get_string_param(params, "selector");
const char *value = get_string_param(params, "value");
if (!value) return make_error("MISSING_PARAM", "Provide 'value'");
char *sel = NULL;
if (ref && ref[0]) {
sel = resolve_ref_to_selector(ref);
if (!sel) return make_error("REF_NOT_FOUND", "No element with that ref. Take a new snapshot.");
} else if (selector && selector[0]) {
sel = g_strdup(selector);
} else {
return make_error("MISSING_PARAM", "Provide 'ref' or 'selector'");
}
WebKitWebView *wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
char *escaped = g_strescape(value, NULL);
char *script = g_strdup_printf(
"(function() { var el = document.querySelector(%s); "
"if (el) { el.value += \"%s\"; el.dispatchEvent(new Event('input', {bubbles:true})); "
"return 'ok'; } return null; })();",
sel, escaped);
char *result = agent_js_eval_sync(wv, script, 5000);
g_free(script);
g_free(escaped);
g_free(sel);
if (!result || strcmp(result, "null") == 0) {
g_free(result);
return make_error("ELEMENT_NOT_FOUND", "No element matching selector");
}
g_free(result);
return make_success(NULL);
}
static cJSON *tool_press(cJSON *params) {
const char *key = get_string_param(params, "key");
if (!key || !key[0]) return make_error("MISSING_PARAM", "Provide 'key' (e.g. 'Enter', 'Tab', 'Escape')");
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
/* Map common key names to key codes. */
char *script = g_strdup_printf(
"(function() { var key = '%s'; "
"var ev = new KeyboardEvent('keydown', {key: key, bubbles: true}); "
"document.dispatchEvent(ev); "
"ev = new KeyboardEvent('keyup', {key: key, bubbles: true}); "
"document.dispatchEvent(ev); return 'ok'; })();",
key);
char *result = agent_js_eval_sync(wv, script, 5000);
g_free(script);
if (!result) return make_error("PRESS_FAILED", "Failed to press key");
g_free(result);
return make_success(NULL);
}
static cJSON *tool_scroll(cJSON *params) {
const char *direction = get_string_param(params, "direction");
int amount = get_int_param(params, "amount", 500);
if (!direction || !direction[0]) return make_error("MISSING_PARAM", "Provide 'direction' (up/down/left/right)");
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
int dx = 0, dy = 0;
if (strcmp(direction, "down") == 0) dy = amount;
else if (strcmp(direction, "up") == 0) dy = -amount;
else if (strcmp(direction, "right") == 0) dx = amount;
else if (strcmp(direction, "left") == 0) dx = -amount;
else return make_error("INVALID_DIRECTION", "Use: up, down, left, right");
char *script = g_strdup_printf("window.scrollBy(%d, %d); 'ok';", dx, dy);
char *result = agent_js_eval_sync(wv, script, 5000);
g_free(script);
if (!result) return make_error("SCROLL_FAILED", "Failed to scroll");
g_free(result);
return make_success(NULL);
}
static cJSON *tool_hover(cJSON *params) {
const char *ref = get_string_param(params, "ref");
const char *selector = get_string_param(params, "selector");
char *sel = NULL;
if (ref && ref[0]) {
sel = resolve_ref_to_selector(ref);
if (!sel) return make_error("REF_NOT_FOUND", "No element with that ref. Take a new snapshot.");
} else if (selector && selector[0]) {
sel = g_strdup(selector);
} else {
return make_error("MISSING_PARAM", "Provide 'ref' or 'selector'");
}
WebKitWebView *wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
char *script = g_strdup_printf(
"(function() { var el = document.querySelector(%s); "
"if (el) { el.dispatchEvent(new MouseEvent('mouseover', {bubbles:true})); "
"el.dispatchEvent(new MouseEvent('mouseenter', {bubbles:true})); return 'ok'; } "
"return null; })();",
sel);
char *result = agent_js_eval_sync(wv, script, 5000);
g_free(script);
g_free(sel);
if (!result || strcmp(result, "null") == 0) {
g_free(result);
return make_error("ELEMENT_NOT_FOUND", "No element matching selector");
}
g_free(result);
return make_success(NULL);
}
static cJSON *tool_focus(cJSON *params) {
const char *ref = get_string_param(params, "ref");
const char *selector = get_string_param(params, "selector");
char *sel = NULL;
if (ref && ref[0]) {
sel = resolve_ref_to_selector(ref);
if (!sel) return make_error("REF_NOT_FOUND", "No element with that ref. Take a new snapshot.");
} else if (selector && selector[0]) {
sel = g_strdup(selector);
} else {
return make_error("MISSING_PARAM", "Provide 'ref' or 'selector'");
}
WebKitWebView *wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
char *script = g_strdup_printf(
"(function() { var el = document.querySelector(%s); "
"if (el) { el.focus(); return 'ok'; } return null; })();",
sel);
char *result = agent_js_eval_sync(wv, script, 5000);
g_free(script);
g_free(sel);
if (!result || strcmp(result, "null") == 0) {
g_free(result);
return make_error("ELEMENT_NOT_FOUND", "No element matching selector");
}
g_free(result);
return make_success(NULL);
}
/* ── Tab tools ────────────────────────────────────────────────────── */
static cJSON *tool_tab_list(cJSON *params) {
(void)params;
int count = tab_manager_count();
cJSON *tabs = cJSON_CreateArray();
for (int i = 0; i < count; i++) {
tab_info_t *tab = tab_manager_get(i);
if (tab) {
cJSON *t = cJSON_CreateObject();
cJSON_AddNumberToObject(t, "index", i);
cJSON_AddStringToObject(t, "url", tab->current_url);
cJSON_AddStringToObject(t, "title", tab->title);
cJSON_AddItemToArray(tabs, t);
}
}
cJSON *data = cJSON_CreateObject();
cJSON_AddItemToObject(data, "tabs", tabs);
return make_success(data);
}
static cJSON *tool_tab_new(cJSON *params) {
const char *url = get_string_param(params, "url");
int index = tab_manager_new_tab(url);
if (index < 0) return make_error("TAB_FAILED", "Failed to create tab (max tabs reached?)");
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "index", index);
return make_success(data);
}
static cJSON *tool_tab_switch(cJSON *params) {
int index = get_int_param(params, "index", -1);
if (index < 0) return make_error("MISSING_PARAM", "Provide 'index'");
if (index >= tab_manager_count()) return make_error("INVALID_INDEX", "Tab index out of range");
tab_manager_switch_to(index);
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "index", index);
return make_success(data);
}
static cJSON *tool_tab_close(cJSON *params) {
int index = get_int_param(params, "index", -1);
if (index < 0) {
/* Close active tab if no index specified. */
index = tab_manager_get_active_index();
if (index < 0) return make_error("NO_TAB", "No active tab");
}
if (index >= tab_manager_count()) return make_error("INVALID_INDEX", "Tab index out of range");
tab_manager_close_tab(index);
return make_success(NULL);
}
static cJSON *tool_close(cJSON *params) {
(void)params;
tab_manager_close_active();
return make_success(NULL);
}
/* ── Wait tools ───────────────────────────────────────────────────── */
static cJSON *tool_wait(cJSON *params) {
int ms = get_int_param(params, "ms", 1000);
if (ms < 0) ms = 0;
if (ms > 30000) ms = 30000; /* cap at 30s */
g_usleep(ms * 1000);
return make_success(NULL);
}
static cJSON *tool_wait_for(cJSON *params) {
const char *selector = get_string_param(params, "selector");
int timeout_ms = get_int_param(params, "timeout", 10000);
if (timeout_ms > 30000) timeout_ms = 30000;
if (!selector || !selector[0]) return make_error("MISSING_PARAM", "Provide 'selector'");
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
/* Poll for the element to appear. */
int elapsed = 0;
int interval = 200;
while (elapsed < timeout_ms) {
char *script = g_strdup_printf(
"document.querySelector('%s') ? 'found' : 'not_found';",
selector);
char *result = agent_js_eval_sync(wv, script, 2000);
g_free(script);
if (result && strcmp(result, "found") == 0) {
g_free(result);
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "waited_ms", elapsed);
return make_success(data);
}
g_free(result);
g_usleep(interval * 1000);
elapsed += interval;
}
return make_error("TIMEOUT", "Element did not appear within timeout");
}
/* ── Tool dispatch table ──────────────────────────────────────────── */
typedef cJSON *(*tool_func_t)(cJSON *params);
typedef struct {
const char *name;
tool_func_t func;
gboolean requires_login; /* TRUE if only available after login */
} tool_entry_t;
static tool_entry_t tool_table[] = {
/* Login tools (available before login) */
{"login_status", tool_login_status, FALSE},
{"login", tool_login, FALSE},
{"logout", tool_logout, FALSE},
{"switch_identity", tool_switch_identity, FALSE},
/* Navigation tools */
{"open", tool_open, TRUE},
{"back", tool_back, TRUE},
{"forward", tool_forward, TRUE},
{"reload", tool_reload, TRUE},
{"get_url", tool_get_url, TRUE},
{"get_title", tool_get_title, TRUE},
/* Snapshot & inspection tools */
{"snapshot", tool_snapshot, TRUE},
{"get_text", tool_get_text, TRUE},
{"get_html", tool_get_html, TRUE},
{"get_attr", tool_get_attr, TRUE},
{"eval", tool_eval, TRUE},
/* Interaction tools */
{"click", tool_click, TRUE},
{"fill", tool_fill, TRUE},
{"type", tool_type, TRUE},
{"press", tool_press, TRUE},
{"scroll", tool_scroll, TRUE},
{"hover", tool_hover, TRUE},
{"focus", tool_focus, TRUE},
{"close", tool_close, TRUE},
/* Tab tools */
{"tab_list", tool_tab_list, TRUE},
{"tab_new", tool_tab_new, TRUE},
{"tab_switch", tool_tab_switch, TRUE},
{"tab_close", tool_tab_close, TRUE},
/* Wait tools */
{"wait", tool_wait, TRUE},
{"wait_for", tool_wait_for, TRUE},
};
static int tool_table_count = sizeof(tool_table) / sizeof(tool_table[0]);
/* ── Main dispatch function ───────────────────────────────────────── */
cJSON *agent_tools_dispatch(cJSON *request) {
if (request == NULL) {
return make_error("INVALID_REQUEST", "Request is NULL");
}
/* Extract tool name and params. */
const char *tool_name = cJSON_GetStringValue(cJSON_GetObjectItem(request, "tool"));
cJSON *params = cJSON_GetObjectItem(request, "params");
cJSON *id = cJSON_GetObjectItem(request, "id");
if (!tool_name || !tool_name[0]) {
return make_error("MISSING_TOOL", "No 'tool' field in request");
}
/* Find the tool. */
tool_func_t func = NULL;
gboolean requires_login = FALSE;
for (int i = 0; i < tool_table_count; i++) {
if (strcmp(tool_table[i].name, tool_name) == 0) {
func = tool_table[i].func;
requires_login = tool_table[i].requires_login;
break;
}
}
if (func == NULL) {
return make_error("UNKNOWN_TOOL", "Unknown tool name");
}
/* Check login requirement. */
if (requires_login && !agent_is_logged_in()) {
return make_error("NOT_LOGGED_IN",
"This tool requires login. Use the 'login' tool first.");
}
/* Execute the tool. */
cJSON *response = func(params ? params : cJSON_CreateObject());
/* Copy the request id into the response. */
if (id && response) {
if (cJSON_IsNumber(id)) {
cJSON_AddNumberToObject(response, "id", id->valuedouble);
} else if (cJSON_IsString(id)) {
cJSON_AddStringToObject(response, "id", id->valuestring);
}
}
return response;
}
+31
View File
@@ -0,0 +1,31 @@
/*
* agent_tools.h — tool dispatch for agent browser automation
*
* Receives a JSON tool request and dispatches it to the appropriate
* tool implementation. Returns a JSON response.
*/
#ifndef AGENT_TOOLS_H
#define AGENT_TOOLS_H
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Process a tool request JSON and return response JSON.
* The caller frees the returned cJSON*.
*
* Request format: {"id":1,"tool":"snapshot","params":{...}}
* Response format: {"id":1,"success":true,"data":{...}}
* or: {"id":1,"success":false,"error":{"code":"...","message":"..."}}
*/
cJSON *agent_tools_dispatch(cJSON *request);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_TOOLS_H */
+160
View File
@@ -0,0 +1,160 @@
/*
* history.c — URL history tracking for sovereign_browser
*/
#include "history.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
/* ── In-memory history ─────────────────────────────────────────── */
static char g_history[HISTORY_MAX_ENTRIES][HISTORY_URL_MAX_LEN];
static int g_history_count = 0;
/* ── Path helper ───────────────────────────────────────────────── */
static int history_path(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
return -1;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
return -1;
}
n = snprintf(out, out_sz, "%s/history.txt", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Persistence ───────────────────────────────────────────────── */
void history_load(void) {
char path[512];
if (history_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
return;
}
g_history_count = 0;
char line[HISTORY_URL_MAX_LEN];
while (g_history_count < HISTORY_MAX_ENTRIES &&
fgets(line, sizeof(line), f) != NULL) {
/* Strip newline. */
size_t len = strlen(line);
if (len > 0 && line[len - 1] == '\n') {
line[len - 1] = '\0';
len--;
}
if (len > 0) {
snprintf(g_history[g_history_count], HISTORY_URL_MAX_LEN, "%s", line);
g_history_count++;
}
}
fclose(f);
}
static void history_save(void) {
char path[512];
if (history_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
return;
}
fchmod(fileno(f), 0600);
/* Write in reverse order (most recent first) — same as in-memory order. */
for (int i = 0; i < g_history_count; i++) {
fprintf(f, "%s\n", g_history[i]);
}
fclose(f);
}
/* ── API ───────────────────────────────────────────────────────── */
void history_add(const char *url) {
if (url == NULL || url[0] == '\0') {
return;
}
/* Skip sovereign:// internal pages. */
if (strncmp(url, "sovereign://", 12) == 0) {
return;
}
/* Check if the URL is already in the history. */
int found_idx = -1;
for (int i = 0; i < g_history_count; i++) {
if (strcmp(g_history[i], url) == 0) {
found_idx = i;
break;
}
}
if (found_idx >= 0) {
/* URL already exists — move it to the top. */
if (found_idx == 0) {
return; /* already at the top, nothing to do */
}
/* Shift items 0..found_idx-1 down by one. */
char tmp[HISTORY_URL_MAX_LEN];
snprintf(tmp, HISTORY_URL_MAX_LEN, "%s", g_history[found_idx]);
for (int i = found_idx; i > 0; i--) {
snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]);
}
snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", tmp);
} else {
/* New URL — shift everything down by one to make room at the top. */
if (g_history_count >= HISTORY_MAX_ENTRIES) {
g_history_count = HISTORY_MAX_ENTRIES - 1;
}
for (int i = g_history_count; i > 0; i--) {
snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]);
}
snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", url);
g_history_count++;
}
history_save();
}
int history_count(void) {
return g_history_count;
}
const char *history_get(int index) {
if (index < 0 || index >= g_history_count) {
return NULL;
}
return g_history[index];
}
void history_clear(void) {
g_history_count = 0;
char path[512];
if (history_path(path, sizeof(path)) == 0) {
unlink(path);
}
}
+53
View File
@@ -0,0 +1,53 @@
/*
* history.h — URL history tracking for sovereign_browser
*
* Keeps a list of recently visited URLs (most recent first) and
* provides them for the History submenu.
*/
#ifndef HISTORY_H
#define HISTORY_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define HISTORY_MAX_ENTRIES 50
#define HISTORY_URL_MAX_LEN 2048
/*
* Add a URL to the history. If the URL is already the most recent
* entry, it's not duplicated. The list is capped at HISTORY_MAX_ENTRIES.
* Also persists to ~/.sovereign_browser/history.txt.
*/
void history_add(const char *url);
/*
* Get the number of history entries.
*/
int history_count(void);
/*
* Get a history entry by index (0 = most recent).
* Returns NULL if index is out of range.
*/
const char *history_get(int index);
/*
* Load history from ~/.sovereign_browser/history.txt.
* Called once at startup.
*/
void history_load(void);
/*
* Clear all history entries and delete the file.
*/
void history_clear(void);
#ifdef __cplusplus
}
#endif
#endif /* HISTORY_H */
+440 -96
View File
@@ -20,6 +20,8 @@
#include "nostr_core/nip019.h"
#include "nostr_core/nip046.h"
#include "nostr_core/nsigner_transport.h"
#include "nostr_core/utils.h"
#include <signal.h>
/* ── Helpers ──────────────────────────────────────────────────── */
@@ -72,12 +74,34 @@ static void privkey_to_hex(const unsigned char privkey[32], char hex[65]) {
typedef struct {
GtkWidget *dialog;
GtkWidget *content_area;
GtkWidget *stack; /* GtkStack for method switching */
GtkWidget *notebook; /* GtkNotebook for method tabs */
GtkWidget *status_label; /* error/status display */
login_result_t *result; /* output */
gboolean done; /* dialog completed */
} login_ctx_t;
/* Helper: get the method name of the currently selected tab. */
static const char *get_current_method(login_ctx_t *ctx) {
gint page = gtk_notebook_get_current_page(GTK_NOTEBOOK(ctx->notebook));
if (page < 0) return NULL;
GtkWidget *child = gtk_notebook_get_nth_page(GTK_NOTEBOOK(ctx->notebook), page);
if (child == NULL) return NULL;
return (const char *)g_object_get_data(G_OBJECT(child), "method-name");
}
/* Helper: get the page widget for a given method name. */
static GtkWidget *get_method_page(login_ctx_t *ctx, const char *method) {
gint n = gtk_notebook_get_n_pages(GTK_NOTEBOOK(ctx->notebook));
for (gint i = 0; i < n; i++) {
GtkWidget *child = gtk_notebook_get_nth_page(GTK_NOTEBOOK(ctx->notebook), i);
const char *name = (const char *)g_object_get_data(G_OBJECT(child), "method-name");
if (name && strcmp(name, method) == 0) {
return child;
}
}
return NULL;
}
/* ── Forward declarations ─────────────────────────────────────── */
static GtkWidget *create_local_screen(login_ctx_t *ctx);
@@ -109,26 +133,41 @@ static void on_generate_key(GtkWidget *btn, gpointer user_data) {
gtk_entry_set_text(GTK_ENTRY(entry), nsec);
}
#define SEED_WORD_COUNT 12
static void on_generate_mnemonic(GtkWidget *btn, gpointer user_data) {
(void)btn;
GtkWidget *entry = GTK_WIDGET(user_data);
GtkWidget *box = GTK_WIDGET(user_data);
char mnemonic[256] = {0};
unsigned char privkey[32], pubkey[32];
if (nostr_generate_mnemonic_and_keys(mnemonic, sizeof(mnemonic), 0,
privkey, pubkey) != 0) {
gtk_entry_set_text(GTK_ENTRY(entry), "(generation failed)");
return;
}
gtk_entry_set_text(GTK_ENTRY(entry), mnemonic);
}
/* Method selection button: switch the stack to the named screen. */
static void on_method_button_clicked(GtkWidget *btn, gpointer user_data) {
GtkStack *stack = GTK_STACK(user_data);
const char *name = (const char *)g_object_get_data(G_OBJECT(btn), "stack-name");
if (name) {
gtk_stack_set_visible_child_name(stack, name);
/* Split the mnemonic into words and fill the 12 entry boxes. */
char *words[SEED_WORD_COUNT];
int n = 0;
char *tok = strtok(mnemonic, " ");
while (tok != NULL && n < SEED_WORD_COUNT) {
words[n++] = tok;
tok = strtok(NULL, " ");
}
for (int i = 0; i < n && i < SEED_WORD_COUNT; i++) {
char key[16];
snprintf(key, sizeof(key), "word-%d", i);
GtkWidget *entry = g_object_get_data(G_OBJECT(box), key);
if (entry) {
gtk_entry_set_text(GTK_ENTRY(entry), words[i]);
}
}
/* Focus the first word box. */
GtkWidget *first = g_object_get_data(G_OBJECT(box), "word-0");
if (first) {
gtk_widget_grab_focus(first);
}
}
@@ -138,7 +177,7 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
login_ctx_t *ctx = (login_ctx_t *)user_data;
(void)btn;
const char *current = gtk_stack_get_visible_child_name(GTK_STACK(ctx->stack));
const char *current = get_current_method(ctx);
if (current == NULL) {
gtk_label_set_text(GTK_LABEL(ctx->status_label), "No method selected.");
return;
@@ -148,7 +187,7 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
/* ── Local key ─────────────────────────────────────────── */
if (strcmp(current, "local") == 0) {
GtkWidget *screen = gtk_stack_get_child_by_name(GTK_STACK(ctx->stack), "local");
GtkWidget *screen = get_method_page(ctx, "local");
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), "nsec-entry");
const char *input = gtk_entry_get_text(GTK_ENTRY(entry));
@@ -207,18 +246,42 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
/* ── Seed phrase ───────────────────────────────────────── */
if (strcmp(current, "seed") == 0) {
GtkWidget *screen = gtk_stack_get_child_by_name(GTK_STACK(ctx->stack), "seed");
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), "mnemonic-entry");
const char *mnemonic = gtk_entry_get_text(GTK_ENTRY(entry));
GtkWidget *screen = get_method_page(ctx, "seed");
GtkWidget *acct_spin = g_object_get_data(G_OBJECT(screen), "account-spin");
int account = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(acct_spin));
if (mnemonic[0] == '\0') {
/* Concatenate the 12 word entries into a mnemonic string. */
char mnemonic[512] = {0};
int words_filled = 0;
for (int i = 0; i < SEED_WORD_COUNT; i++) {
char key[16];
snprintf(key, sizeof(key), "word-%d", i);
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), key);
if (entry) {
const char *word = gtk_entry_get_text(GTK_ENTRY(entry));
if (word && word[0]) {
if (words_filled > 0) strcat(mnemonic, " ");
strcat(mnemonic, word);
words_filled++;
}
}
}
if (words_filled == 0) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Enter a seed phrase or click Generate.");
"Enter your seed phrase or click Generate.");
return;
}
if (words_filled != SEED_WORD_COUNT) {
char msg[64];
snprintf(msg, sizeof(msg),
"Expected 12 words, got %d.", words_filled);
gtk_label_set_text(GTK_LABEL(ctx->status_label), msg);
return;
}
unsigned char privkey[32], pubkey[32];
if (nostr_derive_keys_from_mnemonic(mnemonic, 0, privkey, pubkey) != 0) {
if (nostr_derive_keys_from_mnemonic(mnemonic, account, privkey, pubkey) != 0) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Invalid seed phrase. Check the words and try again.");
return;
@@ -247,8 +310,7 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
memcpy(ctx->result->identity.pubkey_hex, pubkey_hex, 64);
ctx->result->identity.pubkey_hex[64] = '\0';
privkey_to_hex(privkey, ctx->result->identity.privkey_hex);
strncpy(ctx->result->identity.mnemonic, mnemonic, sizeof(ctx->result->identity.mnemonic) - 1);
ctx->result->identity.mnemonic[sizeof(ctx->result->identity.mnemonic) - 1] = '\0';
snprintf(ctx->result->identity.mnemonic, sizeof(ctx->result->identity.mnemonic), "%s", mnemonic);
ctx->done = TRUE;
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
@@ -257,7 +319,7 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
/* ── Read-only (npub) ──────────────────────────────────── */
if (strcmp(current, "readonly") == 0) {
GtkWidget *screen = gtk_stack_get_child_by_name(GTK_STACK(ctx->stack), "readonly");
GtkWidget *screen = get_method_page(ctx, "readonly");
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), "npub-entry");
const char *input = gtk_entry_get_text(GTK_ENTRY(entry));
@@ -310,7 +372,7 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
/* ── NIP-46 remote signer ──────────────────────────────── */
if (strcmp(current, "nip46") == 0) {
GtkWidget *screen = gtk_stack_get_child_by_name(GTK_STACK(ctx->stack), "nip46");
GtkWidget *screen = get_method_page(ctx, "nip46");
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), "bunker-entry");
const char *bunker_url = gtk_entry_get_text(GTK_ENTRY(entry));
@@ -385,7 +447,7 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
/* ── n_signer hardware ─────────────────────────────────── */
if (strcmp(current, "nsigner") == 0) {
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
GtkWidget *screen = gtk_stack_get_child_by_name(GTK_STACK(ctx->stack), "nsigner");
GtkWidget *screen = get_method_page(ctx, "nsigner");
GtkWidget *transport_combo = g_object_get_data(G_OBJECT(screen), "transport-combo");
GtkWidget *device_entry = g_object_get_data(G_OBJECT(screen), "device-entry");
GtkWidget *index_spin = g_object_get_data(G_OBJECT(screen), "index-spin");
@@ -394,6 +456,14 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
int nostr_index = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(index_spin));
gint transport_idx = gtk_combo_box_get_active(GTK_COMBO_BOX(transport_combo));
/* Block SIGCHLD during n_signer calls — the qrexec transport spawns
* child processes, and SIGCHLD can interrupt gtk_dialog_run's
* internal main loop, causing the dialog to close prematurely. */
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGCHLD);
sigprocmask(SIG_BLOCK, &block_set, &old_set);
if (device[0] == '\0') {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Enter a device path or select one.");
@@ -411,7 +481,6 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
signer = nostr_signer_nsigner_unix(device, NULL, 15000);
transport_name = "unix";
} else if (transport_idx == 2) {
/* device is "host:port" */
char host[256];
int port = 0;
const char *sep = strrchr(device, ':');
@@ -426,33 +495,53 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
}
}
} else if (transport_idx == 3) {
signer = nostr_signer_nsigner_qrexec(device, "qubes.NsignerRpc", NULL, 30000);
GtkWidget *service_entry = g_object_get_data(G_OBJECT(screen), "service-entry");
const char *service = service_entry ? gtk_entry_get_text(GTK_ENTRY(service_entry)) : "qubes.NsignerRpc";
if (service[0] == '\0') service = "qubes.NsignerRpc";
signer = nostr_signer_nsigner_qrexec(device, service, NULL, 30000);
transport_name = "qrexec";
}
if (signer == NULL) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Failed to connect to n_signer. Check the device/path.");
"Failed to connect to n_signer. Check the device/path/qube.");
sigprocmask(SIG_SETMASK, &old_set, NULL);
return;
}
/* Set the nostr_index. */
if (nostr_signer_nsigner_set_nostr_index(signer, nostr_index) != NOSTR_SUCCESS) {
int rc_idx = nostr_signer_nsigner_set_nostr_index(signer, nostr_index);
if (rc_idx != NOSTR_SUCCESS) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Failed to set nostr_index on n_signer.");
nostr_signer_free(signer);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return;
}
/* Get the pubkey to verify the connection. */
char pubkey_hex[65];
if (nostr_signer_get_public_key(signer, pubkey_hex) != NOSTR_SUCCESS) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Failed to get pubkey from n_signer. Is the device unlocked?");
nostr_signer_free(signer);
int rc_pk = nostr_signer_get_public_key(signer, pubkey_hex);
if (rc_pk != NOSTR_SUCCESS) {
char errmsg[256];
const char *desc = "unknown error";
switch (rc_pk) {
case -5: desc = "I/O failed — signer may have denied the request or disconnected"; break;
case -2001: desc = "policy denied — caller not approved at signer terminal"; break;
case -2002: desc = "index not in signer's whitelist"; break;
case -3: desc = "crypto operation failed"; break;
default: break;
}
snprintf(errmsg, sizeof(errmsg),
"n_signer error: %s (code %d). Try a different key index.",
desc, rc_pk);
gtk_label_set_text(GTK_LABEL(ctx->status_label), errmsg);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return;
}
/* Unblock SIGCHLD — n_signer calls are done. */
sigprocmask(SIG_SETMASK, &old_set, NULL);
char npub[128];
pubkey_to_npub(pubkey_hex, npub);
g_print("[login] n_signer: transport=%s index=%d pubkey=%s npub=%s\n",
@@ -495,6 +584,169 @@ static void on_cancel_clicked(GtkWidget *btn, gpointer user_data) {
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_CANCEL);
}
/* ── BIP-39 inline auto-complete ──────────────────────────────── */
/* State to prevent recursive signal handling during auto-fill. */
static int g_autofill_suppress = 0;
/* Track the last key pressed to detect deletions (skip auto-fill on delete). */
static int g_last_key_was_delete = 0;
/* Idle callback to set cursor position after auto-fill (deferred so GTK
* doesn't override our cursor placement). */
static gboolean deferred_set_cursor(gpointer data) {
GtkEditable *editable = GTK_EDITABLE(data);
gpointer pos_ptr = g_object_get_data(G_OBJECT(editable), "target-pos");
if (pos_ptr) {
gtk_editable_set_position(editable, GPOINTER_TO_INT(pos_ptr));
g_object_set_data(G_OBJECT(editable), "target-pos", NULL);
}
g_object_unref(editable);
return FALSE; /* one-shot */
}
/* Called when a word entry changes — auto-fills if unique match found. */
static void on_word_changed(GtkEditable *editable, gpointer user_data) {
(void)user_data;
if (g_autofill_suppress) return;
if (g_last_key_was_delete) {
g_last_key_was_delete = 0;
return;
}
const char *text = gtk_entry_get_text(GTK_ENTRY(editable));
if (text[0] == '\0') return;
size_t text_len = strlen(text);
/* Search the BIP-39 wordlist for matches. */
int word_count = 0;
const char * const *words = nostr_bip39_get_wordlist(&word_count);
const char *match = NULL;
int match_count = 0;
for (int i = 0; i < word_count; i++) {
if (strncmp(words[i], text, text_len) == 0) {
match = words[i];
match_count++;
if (match_count > 1) break; /* no unique match */
}
}
/* If exactly one match and it's longer than what's typed, auto-fill. */
if (match_count == 1 && match && strlen(match) > text_len) {
g_autofill_suppress = 1;
/* Set the full word. */
char full[32];
snprintf(full, sizeof(full), "%s", match);
gtk_entry_set_text(GTK_ENTRY(editable), full);
/* Place cursor at the end — deferred so GTK doesn't override it. */
gint end_pos = (gint)strlen(full);
g_object_set_data(G_OBJECT(editable), "target-pos",
GINT_TO_POINTER(end_pos));
g_idle_add_full(G_PRIORITY_HIGH_IDLE, deferred_set_cursor,
g_object_ref(editable), NULL);
g_autofill_suppress = 0;
} else if (match_count == 1 && match && strlen(match) == text_len) {
/* Exact match (e.g. selected from dropdown) — cursor to end. */
gtk_editable_set_position(editable, (gint)text_len);
}
}
/* Called on key press in a word entry — Tab/Enter moves to next box,
* and tracks BackSpace/Delete to skip auto-fill. */
static gboolean on_word_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer user_data) {
GtkWidget **entries = (GtkWidget **)user_data;
/* Track deletions so on_word_changed skips auto-fill. */
if (event->keyval == GDK_KEY_BackSpace || event->keyval == GDK_KEY_Delete) {
g_last_key_was_delete = 1;
}
if (event->keyval == GDK_KEY_Tab || event->keyval == GDK_KEY_Return ||
event->keyval == GDK_KEY_KP_Enter) {
/* Find the current entry index and focus the next one. */
int i;
for (i = 0; i < SEED_WORD_COUNT; i++) {
if (entries[i] == widget) break;
}
if (i < SEED_WORD_COUNT - 1) {
gtk_widget_grab_focus(entries[i + 1]);
}
return TRUE; /* stop propagation */
}
return FALSE;
}
/* Update the derivation path hint when the account number changes. */
static void on_account_changed(GtkSpinButton *spin, gpointer user_data) {
GtkWidget *hint = GTK_WIDGET(user_data);
int account = gtk_spin_button_get_value_as_int(spin);
char text[64];
snprintf(text, sizeof(text),
"Key derivation: m/44'/1237'/%d'/0/0", account);
gtk_label_set_text(GTK_LABEL(hint), text);
}
/* Update the n_signer UI when the transport type changes. */
static void on_transport_changed(GtkComboBox *combo, gpointer user_data) {
/* user_data is the box; we retrieve the widgets from it. */
GtkWidget *box = GTK_WIDGET(user_data);
GtkWidget *device_label = g_object_get_data(G_OBJECT(box), "device-label");
GtkWidget *device_entry = g_object_get_data(G_OBJECT(box), "device-entry");
GtkWidget *enum_btn = g_object_get_data(G_OBJECT(box), "enum-btn");
GtkWidget *service_box = g_object_get_data(G_OBJECT(box), "service-box");
gint idx = gtk_combo_box_get_active(combo);
switch (idx) {
case 0: /* USB Serial */
gtk_label_set_text(GTK_LABEL(device_label), "Device Path:");
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "/dev/ttyACM0");
/* Clear any default text from qrexec mode. */
if (gtk_entry_get_text_length(GTK_ENTRY(device_entry)) > 0 &&
strcmp(gtk_entry_get_text(GTK_ENTRY(device_entry)), "nostr_signer") == 0) {
gtk_entry_set_text(GTK_ENTRY(device_entry), "");
}
gtk_widget_show(enum_btn);
gtk_widget_hide(service_box);
break;
case 1: /* UNIX Socket */
gtk_label_set_text(GTK_LABEL(device_label), "Socket Name:");
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nsigner");
if (gtk_entry_get_text_length(GTK_ENTRY(device_entry)) > 0 &&
strcmp(gtk_entry_get_text(GTK_ENTRY(device_entry)), "nostr_signer") == 0) {
gtk_entry_set_text(GTK_ENTRY(device_entry), "");
}
gtk_widget_hide(enum_btn);
gtk_widget_hide(service_box);
break;
case 2: /* TCP */
gtk_label_set_text(GTK_LABEL(device_label), "Host:Port:");
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "127.0.0.1:7777");
if (gtk_entry_get_text_length(GTK_ENTRY(device_entry)) > 0 &&
strcmp(gtk_entry_get_text(GTK_ENTRY(device_entry)), "nostr_signer") == 0) {
gtk_entry_set_text(GTK_ENTRY(device_entry), "");
}
gtk_widget_hide(enum_btn);
gtk_widget_hide(service_box);
break;
case 3: /* Other Qube (Qubes qrexec) */
gtk_label_set_text(GTK_LABEL(device_label), "Target Qube:");
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nostr_signer");
/* Set default value so the user doesn't need to type it. */
if (gtk_entry_get_text_length(GTK_ENTRY(device_entry)) == 0) {
gtk_entry_set_text(GTK_ENTRY(device_entry), "nostr_signer");
}
gtk_widget_hide(enum_btn);
gtk_widget_show(service_box);
break;
default:
break;
}
}
/* ── Screen creation ──────────────────────────────────────────── */
static GtkWidget *create_local_screen(login_ctx_t *ctx) {
@@ -530,25 +782,98 @@ static GtkWidget *create_seed_screen(login_ctx_t *ctx) {
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
GtkWidget *label = gtk_label_new("Enter a BIP-39 seed phrase (12-24 words) or generate a new one:");
GtkWidget *label = gtk_label_new("Enter your 12-word BIP-39 seed phrase:");
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0);
GtkWidget *entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "abandon abandon abandon ... abandon art");
gtk_entry_set_width_chars(GTK_ENTRY(entry), 60);
gtk_box_pack_start(GTK_BOX(box), entry, FALSE, FALSE, 0);
/* Build the BIP-39 word completion model (shared by all 12 entries). */
GtkListStore *store = gtk_list_store_new(1, G_TYPE_STRING);
int word_count = 0;
const char * const *words = nostr_bip39_get_wordlist(&word_count);
for (int i = 0; i < word_count; i++) {
GtkTreeIter iter;
gtk_list_store_append(store, &iter);
gtk_list_store_set(store, &iter, 0, words[i], -1);
}
/* 12 numbered word entry boxes in a 4x3 grid with inline auto-complete. */
GtkWidget *grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 4);
gtk_grid_set_column_spacing(GTK_GRID(grid), 8);
gtk_box_pack_start(GTK_BOX(box), grid, FALSE, FALSE, 0);
/* Allocate an array of entry pointers for Tab navigation. */
GtkWidget **entries = g_new0(GtkWidget *, SEED_WORD_COUNT);
for (int i = 0; i < SEED_WORD_COUNT; i++) {
int col = i % 4;
int row = i / 4;
/* Number label. */
char num[8];
snprintf(num, sizeof(num), "%d.", i + 1);
GtkWidget *num_label = gtk_label_new(num);
gtk_widget_set_halign(num_label, GTK_ALIGN_END);
gtk_grid_attach(GTK_GRID(grid), num_label, col * 2, row, 1, 1);
/* Word entry with dropdown completion + inline auto-complete. */
GtkWidget *entry = gtk_entry_new();
gtk_entry_set_width_chars(GTK_ENTRY(entry), 14);
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "word");
/* Dropdown completion list. */
GtkEntryCompletion *completion = gtk_entry_completion_new();
gtk_entry_completion_set_model(completion, GTK_TREE_MODEL(store));
gtk_entry_completion_set_text_column(completion, 0);
gtk_entry_completion_set_minimum_key_length(completion, 2);
gtk_entry_set_completion(GTK_ENTRY(entry), completion);
g_object_unref(completion);
/* Inline auto-complete on text change. */
g_signal_connect(entry, "changed", G_CALLBACK(on_word_changed), NULL);
/* Tab/Enter moves to the next word box. */
g_signal_connect(entry, "key-press-event",
G_CALLBACK(on_word_key_press), entries);
gtk_grid_attach(GTK_GRID(grid), entry, col * 2 + 1, row, 1, 1);
entries[i] = entry;
/* Store the entry on the box for later retrieval. */
char key[16];
snprintf(key, sizeof(key), "word-%d", i);
g_object_set_data(G_OBJECT(box), key, entry);
}
/* Store the entries array on the box (freed when box is destroyed). */
g_object_set_data_full(G_OBJECT(box), "entries-array", entries,
(GDestroyNotify)g_free);
g_object_unref(store);
GtkWidget *gen_btn = gtk_button_new_with_label("Generate New Mnemonic");
g_signal_connect(gen_btn, "clicked", G_CALLBACK(on_generate_mnemonic), entry);
g_signal_connect(gen_btn, "clicked", G_CALLBACK(on_generate_mnemonic), box);
gtk_box_pack_start(GTK_BOX(box), gen_btn, FALSE, FALSE, 0);
GtkWidget *hint = gtk_label_new("Key derivation: m/44'/1237'/0'/0/0 (account 0)");
/* Account number selector. */
GtkWidget *acct_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_pack_start(GTK_BOX(box), acct_box, FALSE, FALSE, 0);
GtkWidget *acct_label = gtk_label_new("Account #:");
gtk_box_pack_start(GTK_BOX(acct_box), acct_label, FALSE, FALSE, 0);
GtkWidget *acct_spin = gtk_spin_button_new_with_range(0, 1000, 1);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(acct_spin), 0);
gtk_box_pack_start(GTK_BOX(acct_box), acct_spin, FALSE, FALSE, 0);
GtkWidget *hint = gtk_label_new("Key derivation: m/44'/1237'/0'/0/0");
gtk_widget_set_sensitive(hint, FALSE);
gtk_widget_set_halign(hint, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), hint, FALSE, FALSE, 0);
g_object_set_data(G_OBJECT(box), "mnemonic-entry", entry);
/* Update the hint when the account number changes. */
g_signal_connect(acct_spin, "value-changed",
G_CALLBACK(on_account_changed), hint);
g_object_set_data(G_OBJECT(box), "account-spin", acct_spin);
return box;
}
@@ -629,21 +954,26 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "USB Serial");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "UNIX Socket");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "TCP");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "Qubes qrexec");
gtk_combo_box_set_active(GTK_COMBO_BOX(transport_combo), 0);
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "Other Qube");
gtk_combo_box_set_active(GTK_COMBO_BOX(transport_combo), 3);
gtk_box_pack_start(GTK_BOX(transport_box), transport_combo, FALSE, FALSE, 0);
/* Device path entry. */
GtkWidget *device_label = gtk_label_new("Device / Path:");
/* Device path entry — label and placeholder adapt to transport type.
* Default transport is "Other Qube" (qrexec), so initial UI reflects
* that. The on_transport_changed callback updates these when the user
* switches transport. */
GtkWidget *device_label = gtk_label_new("Target Qube:");
gtk_widget_set_halign(device_label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), device_label, FALSE, FALSE, 0);
GtkWidget *device_entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "/dev/ttyACM0");
gtk_entry_set_text(GTK_ENTRY(device_entry), "nostr_signer");
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nostr_signer");
gtk_entry_set_width_chars(GTK_ENTRY(device_entry), 40);
gtk_box_pack_start(GTK_BOX(box), device_entry, FALSE, FALSE, 0);
/* Enumerate serial devices button. */
/* Enumerate serial devices button (only shown for serial transport).
* Hidden by default since qrexec is the default transport. */
GtkWidget *enum_btn = gtk_button_new_with_label("Detect Serial Devices");
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
g_signal_connect(enum_btn, "clicked", G_CALLBACK(on_detect_serial), device_entry);
@@ -651,6 +981,26 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
gtk_widget_set_sensitive(enum_btn, FALSE);
#endif
gtk_box_pack_start(GTK_BOX(box), enum_btn, FALSE, FALSE, 0);
gtk_widget_hide(enum_btn);
/* Service name field (only shown for qrexec transport). */
GtkWidget *service_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_pack_start(GTK_BOX(box), service_box, FALSE, FALSE, 0);
GtkWidget *service_label = gtk_label_new("Service:");
gtk_box_pack_start(GTK_BOX(service_box), service_label, FALSE, FALSE, 0);
GtkWidget *service_entry = gtk_entry_new();
gtk_entry_set_text(GTK_ENTRY(service_entry), "qubes.NsignerRpc");
gtk_entry_set_width_chars(GTK_ENTRY(service_entry), 30);
gtk_box_pack_start(GTK_BOX(service_box), service_entry, FALSE, FALSE, 0);
/* Show the service field by default (qrexec is the default transport). */
gtk_widget_show_all(service_box);
/* Connect transport change callback to update the UI dynamically. */
g_signal_connect(transport_combo, "changed",
G_CALLBACK(on_transport_changed), box);
/* Nostr index spinner. */
GtkWidget *index_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
@@ -670,7 +1020,11 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
gtk_box_pack_start(GTK_BOX(box), hint, FALSE, FALSE, 0);
g_object_set_data(G_OBJECT(box), "transport-combo", transport_combo);
g_object_set_data(G_OBJECT(box), "device-label", device_label);
g_object_set_data(G_OBJECT(box), "device-entry", device_entry);
g_object_set_data(G_OBJECT(box), "enum-btn", enum_btn);
g_object_set_data(G_OBJECT(box), "service-box", service_box);
g_object_set_data(G_OBJECT(box), "service-entry", service_entry);
g_object_set_data(G_OBJECT(box), "index-spin", index_spin);
return box;
}
@@ -706,15 +1060,24 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
return -1;
}
GtkWidget *dialog = gtk_dialog_new_with_buttons(
"sovereign browser — Sign In",
parent,
GTK_DIALOG_MODAL,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Sign In", GTK_RESPONSE_ACCEPT,
NULL);
/* Create dialog WITHOUT buttons — we add custom buttons so we can
* control whether the dialog closes (the built-in buttons auto-respond
* and close the dialog before our handler can decide to keep it open). */
/* Create dialog without auto-responding buttons — we add custom
* buttons so we control whether the dialog closes. */
GtkWidget *dialog = gtk_dialog_new();
gtk_window_set_title(GTK_WINDOW(dialog), "sovereign browser — Sign In");
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
if (parent) gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
gtk_window_set_default_size(GTK_WINDOW(dialog), 560, 380);
/* Add custom buttons to the action area. */
GtkWidget *action_area = gtk_dialog_get_action_area(GTK_DIALOG(dialog));
GtkWidget *cancel_btn = gtk_button_new_with_label("_Cancel");
GtkWidget *login_btn = gtk_button_new_with_label("_Sign In");
gtk_box_pack_start(GTK_BOX(action_area), cancel_btn, FALSE, FALSE, 0);
gtk_box_pack_start(GTK_BOX(action_area), login_btn, FALSE, FALSE, 0);
/* Apply monospace font styling to the dialog. */
GtkCssProvider *css = gtk_css_provider_new();
gtk_css_provider_load_from_data(css,
@@ -744,49 +1107,29 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
"<span size='large' weight='bold'>Sign in with your Nostr key</span>");
gtk_box_pack_start(GTK_BOX(content), title, FALSE, FALSE, 8);
/* Method selector. */
GtkWidget *method_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_box_pack_start(GTK_BOX(content), method_box, FALSE, FALSE, 4);
/* Notebook with tabs for each login method. */
GtkWidget *notebook = gtk_notebook_new();
gtk_box_pack_start(GTK_BOX(content), notebook, TRUE, TRUE, 4);
ctx.notebook = notebook;
/* Stack for method-specific screens. */
GtkWidget *stack = gtk_stack_new();
gtk_stack_set_transition_type(GTK_STACK(stack), GTK_STACK_TRANSITION_TYPE_CROSSFADE);
gtk_box_pack_start(GTK_BOX(content), stack, TRUE, TRUE, 4);
ctx.stack = stack;
/* Create all screens. */
GtkWidget *local_screen = create_local_screen(&ctx);
gtk_stack_add_named(GTK_STACK(stack), local_screen, "local");
GtkWidget *seed_screen = create_seed_screen(&ctx);
gtk_stack_add_named(GTK_STACK(stack), seed_screen, "seed");
GtkWidget *readonly_screen = create_readonly_screen(&ctx);
gtk_stack_add_named(GTK_STACK(stack), readonly_screen, "readonly");
GtkWidget *nip46_screen = create_nip46_screen(&ctx);
gtk_stack_add_named(GTK_STACK(stack), nip46_screen, "nip46");
GtkWidget *nsigner_screen = create_nsigner_screen(&ctx);
gtk_stack_add_named(GTK_STACK(stack), nsigner_screen, "nsigner");
/* Method buttons. */
/* Create all screens as notebook tabs. */
const struct {
const char *label;
const char *stack_name;
} methods[] = {
{"Local Key", "local"},
{"Seed Phrase", "seed"},
{"Read-only", "readonly"},
{"NIP-46", "nip46"},
{"n_signer", "nsigner"},
const char *method;
GtkWidget *(*create_fn)(login_ctx_t *);
} tabs[] = {
{"Local Key", "local", create_local_screen},
{"Seed Phrase", "seed", create_seed_screen},
{"Read-only", "readonly", create_readonly_screen},
{"NIP-46", "nip46", create_nip46_screen},
{"n_signer", "nsigner", create_nsigner_screen},
};
for (size_t i = 0; i < sizeof(methods) / sizeof(methods[0]); i++) {
GtkWidget *btn = gtk_button_new_with_label(methods[i].label);
g_object_set_data(G_OBJECT(btn), "stack-name", (gpointer)methods[i].stack_name);
g_signal_connect(btn, "clicked", G_CALLBACK(on_method_button_clicked), stack);
gtk_box_pack_start(GTK_BOX(method_box), btn, FALSE, FALSE, 0);
for (size_t i = 0; i < sizeof(tabs) / sizeof(tabs[0]); i++) {
GtkWidget *screen = tabs[i].create_fn(&ctx);
g_object_set_data(G_OBJECT(screen), "method-name", (gpointer)tabs[i].method);
GtkWidget *tab_label = gtk_label_new(tabs[i].label);
gtk_notebook_append_page(GTK_NOTEBOOK(notebook), screen, tab_label);
}
/* Status label. */
@@ -795,11 +1138,12 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
gtk_box_pack_start(GTK_BOX(content), status, FALSE, FALSE, 4);
ctx.status_label = status;
/* Wire the dialog buttons. */
GtkWidget *cancel_btn = gtk_dialog_get_widget_for_response(GTK_DIALOG(dialog), GTK_RESPONSE_CANCEL);
GtkWidget *login_btn = gtk_dialog_get_widget_for_response(GTK_DIALOG(dialog), GTK_RESPONSE_ACCEPT);
/* Wire the custom buttons — these don't auto-respond, so the dialog
* stays open unless we explicitly call gtk_dialog_response. */
g_signal_connect(cancel_btn, "clicked", G_CALLBACK(on_cancel_clicked), &ctx);
g_signal_connect(login_btn, "clicked", G_CALLBACK(on_login_clicked), &ctx);
g_object_set_data(G_OBJECT(dialog), "login-btn", login_btn);
g_object_set_data(G_OBJECT(dialog), "cancel-btn", cancel_btn);
gtk_widget_show_all(dialog);
+420 -330
View File
@@ -1,15 +1,25 @@
/*
* sovereign_browser — WebKitGTK + C99
*
* Minimal browser: a GTK window with a URL entry, a hamburger menu, and a
* WebKitWebView. Type a URL, press Enter, the page loads. The hamburger
* menu to the left of the URL bar exposes roadmap actions (reload, security
* strip toggle, FIPS, Nostr signing) as placeholders to wire up next.
* Multi-tab browser: a GTK window with a GtkNotebook tab strip. Each tab
* has its own toolbar (hamburger menu + URL entry) and WebKitWebView. All
* webviews share a single WebKitWebContext so the sovereign:// Nostr bridge,
* security settings, and TLS policy apply to every tab.
*
* On startup, a Nostr login dialog is shown. The user signs in with a local
* key (nsec), seed phrase, read-only npub, NIP-46 remote signer, or n_signer
* hardware. The resulting nostr_signer_t is held globally and will be used
* to inject window.nostr into every page (Phase 2).
* hardware. The resulting nostr_signer_t is held globally and backs the
* window.nostr injection in every tab.
*
* Features:
* - Tab creation (Ctrl+T, + button), closing (Ctrl+W, close button,
* middle-click)
* - Tab switching (Ctrl+Tab, Ctrl+Shift+Tab, Ctrl+PageUp/Down)
* - Right-click tab context menu (New, Close, Close Others, Close to
* Right, Duplicate, Reload)
* - Tab drag reordering
* - Session save/restore (configurable in Settings)
* - Settings dialog (tab preferences, persisted to disk)
*
* Build: make. Run: ./sovereign_browser [url]
*/
@@ -19,17 +29,25 @@
#include <string.h>
#include <stdlib.h>
#include <signal.h>
#include "version.h"
#include "key_store.h"
#include "login_dialog.h"
#include "nostr_bridge.h"
#include "nostr_inject.h"
#include "history.h"
#include "settings.h"
#include "tab_manager.h"
#include "session.h"
#include "agent_server.h"
#include "agent_login.h"
#include "nostr_core/nostr_core.h"
/* ---- Global state --------------------------------------------------- *
* The signer is created at login and held for the lifetime of the session.
* Phase 2 will use it to back the window.nostr injection.
* It backs the window.nostr injection in every tab via the sovereign://
* URI scheme bridge.
*/
typedef struct {
@@ -40,57 +58,53 @@ typedef struct {
} app_state_t;
static app_state_t g_state = {0};
static GtkWindow *g_window = NULL;
static gboolean g_logged_in = FALSE;
static gboolean g_agent_login_done = FALSE;
/* ---- Menu action callbacks ------------------------------------------- */
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
static void on_menu_reload(GtkMenuItem *item, WebKitWebView *webview) {
(void)item;
if (webview != NULL) {
webkit_web_view_reload_bypass_cache(webview);
void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
key_store_method_t method, gboolean readonly) {
g_state.signer = signer;
g_state.method = method;
g_state.readonly = readonly;
if (pubkey_hex) {
strncpy(g_state.pubkey_hex, pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
}
g_logged_in = TRUE;
}
static void on_menu_stop(GtkMenuItem *item, WebKitWebView *webview) {
(void)item;
if (webview != NULL) {
webkit_web_view_stop_loading(webview);
}
}
static void on_menu_security_strip(GtkMenuItem *item, gpointer data) {
(void)data;
gboolean active = gtk_check_menu_item_get_active(GTK_CHECK_MENU_ITEM(item));
g_print("[menu] security strip: %s\n", active ? "ON" : "OFF");
}
static void on_menu_fips(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
g_print("[menu] FIPS URI scheme: not yet implemented (roadmap)\n");
}
static void on_menu_nostr_sign(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
void app_clear_signer(void) {
if (g_state.signer) {
g_print("[menu] Nostr signing: signer active, pubkey=%s\n",
g_state.pubkey_hex);
} else if (g_state.readonly) {
g_print("[menu] Nostr signing: read-only mode (no signer)\n");
} else {
g_print("[menu] Nostr signing: no signer loaded\n");
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.pubkey_hex[0] = '\0';
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
g_logged_in = FALSE;
}
/* Show the login dialog again (switch identity / re-auth). */
static void on_menu_switch_identity(GtkMenuItem *item, gpointer data) {
nostr_signer_t *app_get_signer(void) { return g_state.signer; }
const char *app_get_pubkey_hex(void) { return g_state.pubkey_hex; }
key_store_method_t app_get_method(void) { return g_state.method; }
gboolean app_get_readonly(void) { return g_state.readonly; }
/* ---- Menu proxy functions ------------------------------------------- *
* These wrap the app_state_t-aware callbacks so they match GTK signal
* handler signatures and can be called from tab_manager.c's hamburger
* menu builder.
*/
void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWindow *window = GTK_WINDOW(data);
if (window == NULL) return;
login_result_t result;
if (login_dialog_run(window, &result) == 0) {
/* Free the old signer. */
if (g_state.signer) {
nostr_signer_free(g_state.signer);
}
@@ -100,7 +114,6 @@ static void on_menu_switch_identity(GtkMenuItem *item, gpointer data) {
g_state.pubkey_hex[64] = '\0';
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY);
/* Update the bridge so window.nostr uses the new signer. */
nostr_bridge_set_signer(g_state.signer, g_state.pubkey_hex,
g_state.readonly);
@@ -109,7 +122,19 @@ static void on_menu_switch_identity(GtkMenuItem *item, gpointer data) {
}
}
static void on_menu_logout(GtkMenuItem *item, gpointer data) {
void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.readonly = TRUE;
nostr_bridge_set_signer(NULL, g_state.pubkey_hex, TRUE);
g_print("[identity] session locked (signer cleared, identity preserved)\n");
}
void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
@@ -123,12 +148,36 @@ static void on_menu_logout(GtkMenuItem *item, gpointer data) {
g_print("[identity] logged out\n");
}
static void on_menu_about(GtkMenuItem *item, gpointer data) {
void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
(void)data;
gboolean active = gtk_check_menu_item_get_active(item);
g_print("[menu] security strip: %s\n", active ? "ON" : "OFF");
}
void app_menu_fips_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
g_print("[menu] FIPS URI scheme: not yet implemented (roadmap)\n");
}
void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
g_print("[menu] Nostr signing: signer active, pubkey=%s\n",
g_state.pubkey_hex);
} else if (g_state.readonly) {
g_print("[menu] Nostr signing: read-only mode (no signer)\n");
} else {
g_print("[menu] Nostr signing: no signer loaded\n");
}
}
void app_menu_about_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWidget *window = GTK_WIDGET(data);
if (window == NULL) {
return;
}
if (window == NULL) return;
GtkWidget *dialog = gtk_message_dialog_new(
GTK_WINDOW(window),
GTK_DIALOG_DESTROY_WITH_PARENT,
@@ -141,227 +190,252 @@ static void on_menu_about(GtkMenuItem *item, gpointer data) {
gtk_widget_show_all(dialog);
}
/* Toggle the WebKit Web Inspector. */
static void on_menu_inspector(GtkMenuItem *item, gpointer data) {
(void)item;
WebKitWebView *webview = WEBKIT_WEB_VIEW(data);
if (webview == NULL) return;
/* ---- Settings dialog ------------------------------------------------ */
WebKitWebInspector *inspector = webkit_web_view_get_inspector(webview);
if (inspector == NULL) return;
static void on_settings_dialog_response(GtkDialog *dialog, gint response_id,
gpointer user_data) {
(void)user_data;
if (response_id == GTK_RESPONSE_ACCEPT) {
browser_settings_t *s = settings_get_mutable();
/* Just show the inspector — WebKitGTK handles the toggle. */
webkit_web_inspector_show(inspector);
/* Read the widgets by name from the dialog's content area. */
GtkWidget *content = gtk_dialog_get_content_area(dialog);
GList *children = gtk_container_get_children(GTK_CONTAINER(content));
for (GList *l = children; l != NULL; l = l->next) {
GtkWidget *child = GTK_WIDGET(l->data);
const char *name = gtk_widget_get_name(child);
if (name == NULL) continue;
if (strcmp(name, "restore_session") == 0) {
s->restore_session = gtk_toggle_button_get_active(
GTK_TOGGLE_BUTTON(child));
} else if (strcmp(name, "new_tab_url") == 0) {
const char *text = gtk_entry_get_text(GTK_ENTRY(child));
snprintf(s->new_tab_url, sizeof(s->new_tab_url), "%s", text);
} else if (strcmp(name, "show_tab_close_buttons") == 0) {
s->show_tab_close_buttons = gtk_toggle_button_get_active(
GTK_TOGGLE_BUTTON(child));
} else if (strcmp(name, "middle_click_close") == 0) {
s->middle_click_close = gtk_toggle_button_get_active(
GTK_TOGGLE_BUTTON(child));
} else if (strcmp(name, "ctrl_tab_switch") == 0) {
s->ctrl_tab_switch = gtk_toggle_button_get_active(
GTK_TOGGLE_BUTTON(child));
} else if (strcmp(name, "tab_drag_reorder") == 0) {
s->tab_drag_reorder = gtk_toggle_button_get_active(
GTK_TOGGLE_BUTTON(child));
} else if (strcmp(name, "max_tabs") == 0) {
const char *text = gtk_entry_get_text(GTK_ENTRY(child));
s->max_tabs = atoi(text);
if (s->max_tabs < 1) s->max_tabs = 1;
} else if (strcmp(name, "tab_bar_position") == 0) {
gint active = gtk_combo_box_get_active(GTK_COMBO_BOX(child));
switch (active) {
case 0: s->tab_bar_position = GTK_POS_TOP; break;
case 1: s->tab_bar_position = GTK_POS_BOTTOM; break;
case 2: s->tab_bar_position = GTK_POS_LEFT; break;
case 3: s->tab_bar_position = GTK_POS_RIGHT; break;
}
}
}
g_list_free(children);
settings_save();
tab_manager_apply_settings();
g_print("[settings] Saved\n");
}
gtk_widget_destroy(GTK_WIDGET(dialog));
}
/* Open a local file via file chooser dialog. */
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
void on_menu_settings(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWindow *window = GTK_WINDOW(data);
if (window == NULL) return;
GtkWidget *dialog = gtk_file_chooser_dialog_new(
"Open File",
window,
GTK_FILE_CHOOSER_ACTION_OPEN,
const browser_settings_t *s = settings_get();
GtkWidget *dialog = gtk_dialog_new_with_buttons(
"Settings", window,
GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Open", GTK_RESPONSE_ACCEPT,
"_OK", GTK_RESPONSE_ACCEPT,
NULL);
gtk_window_set_default_size(GTK_WINDOW(dialog), 400, 350);
GtkFileFilter *filter_html = gtk_file_filter_new();
gtk_file_filter_set_name(filter_html, "HTML files");
gtk_file_filter_add_pattern(filter_html, "*.html");
gtk_file_filter_add_pattern(filter_html, "*.htm");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_html);
GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
gtk_container_set_border_width(GTK_CONTAINER(content), 12);
GtkBox *vbox = GTK_BOX(content);
GtkFileFilter *filter_all = gtk_file_filter_new();
gtk_file_filter_set_name(filter_all, "All files");
gtk_file_filter_add_pattern(filter_all, "*");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_all);
/* Restore session checkbox. */
GtkWidget *cb_restore = gtk_check_button_new_with_label(
"Restore session on startup");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_restore),
s->restore_session);
gtk_widget_set_name(cb_restore, "restore_session");
gtk_box_pack_start(vbox, cb_restore, FALSE, FALSE, 4);
if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) {
char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog));
char *uri = g_strdup_printf("file://%s", filename);
/* Get the web view from the window's data. */
WebKitWebView *webview = g_object_get_data(G_OBJECT(window), "webview");
if (webview) {
webkit_web_view_load_uri(webview, uri);
}
g_free(uri);
g_free(filename);
/* New tab URL entry. */
GtkWidget *lbl_url = gtk_label_new("New tab URL:");
gtk_widget_set_halign(lbl_url, GTK_ALIGN_START);
gtk_box_pack_start(vbox, lbl_url, FALSE, FALSE, 4);
GtkWidget *entry_url = gtk_entry_new();
gtk_entry_set_text(GTK_ENTRY(entry_url), s->new_tab_url);
gtk_widget_set_name(entry_url, "new_tab_url");
gtk_box_pack_start(vbox, entry_url, FALSE, FALSE, 4);
/* Tab bar position combo. */
GtkWidget *lbl_pos = gtk_label_new("Tab bar position:");
gtk_widget_set_halign(lbl_pos, GTK_ALIGN_START);
gtk_box_pack_start(vbox, lbl_pos, FALSE, FALSE, 4);
GtkWidget *combo_pos = gtk_combo_box_text_new();
gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Top");
gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Bottom");
gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Left");
gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo_pos), NULL, "Right");
int pos_active = 0;
switch (s->tab_bar_position) {
case GTK_POS_TOP: pos_active = 0; break;
case GTK_POS_BOTTOM: pos_active = 1; break;
case GTK_POS_LEFT: pos_active = 2; break;
case GTK_POS_RIGHT: pos_active = 3; break;
}
gtk_widget_destroy(dialog);
gtk_combo_box_set_active(GTK_COMBO_BOX(combo_pos), pos_active);
gtk_widget_set_name(combo_pos, "tab_bar_position");
gtk_box_pack_start(vbox, combo_pos, FALSE, FALSE, 4);
/* Show tab close buttons. */
GtkWidget *cb_close_btn = gtk_check_button_new_with_label(
"Show tab close buttons");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_close_btn),
s->show_tab_close_buttons);
gtk_widget_set_name(cb_close_btn, "show_tab_close_buttons");
gtk_box_pack_start(vbox, cb_close_btn, FALSE, FALSE, 4);
/* Middle-click to close. */
GtkWidget *cb_mid_click = gtk_check_button_new_with_label(
"Middle-click to close tab");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_mid_click),
s->middle_click_close);
gtk_widget_set_name(cb_mid_click, "middle_click_close");
gtk_box_pack_start(vbox, cb_mid_click, FALSE, FALSE, 4);
/* Ctrl+Tab switching. */
GtkWidget *cb_ctrl_tab = gtk_check_button_new_with_label(
"Ctrl+Tab to switch tabs");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_ctrl_tab),
s->ctrl_tab_switch);
gtk_widget_set_name(cb_ctrl_tab, "ctrl_tab_switch");
gtk_box_pack_start(vbox, cb_ctrl_tab, FALSE, FALSE, 4);
/* Tab drag reordering. */
GtkWidget *cb_drag = gtk_check_button_new_with_label(
"Allow drag to reorder tabs");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cb_drag),
s->tab_drag_reorder);
gtk_widget_set_name(cb_drag, "tab_drag_reorder");
gtk_box_pack_start(vbox, cb_drag, FALSE, FALSE, 4);
/* Max tabs. */
GtkWidget *lbl_max = gtk_label_new("Maximum tabs:");
gtk_widget_set_halign(lbl_max, GTK_ALIGN_START);
gtk_box_pack_start(vbox, lbl_max, FALSE, FALSE, 4);
GtkWidget *entry_max = gtk_entry_new();
char max_str[16];
snprintf(max_str, sizeof(max_str), "%d", s->max_tabs);
gtk_entry_set_text(GTK_ENTRY(entry_max), max_str);
gtk_widget_set_name(entry_max, "max_tabs");
gtk_box_pack_start(vbox, entry_max, FALSE, FALSE, 4);
g_signal_connect(dialog, "response",
G_CALLBACK(on_settings_dialog_response), NULL);
gtk_widget_show_all(dialog);
}
/* Lock session: clear the signer but keep the saved identity.
* The user will need to re-authenticate (switch identity) to sign again. */
static void on_menu_lock_session(GtkMenuItem *item, gpointer data) {
(void)item;
/* ---- Keyboard shortcuts --------------------------------------------- */
static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer data) {
(void)widget;
(void)data;
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
const browser_settings_t *s = settings_get();
guint mods = event->state & gtk_accelerator_get_default_mod_mask();
/* Ctrl+T — new tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_t) {
tab_manager_new_tab(NULL);
return TRUE;
}
g_state.readonly = TRUE; /* no signing until re-auth */
nostr_bridge_set_signer(NULL, g_state.pubkey_hex, TRUE);
g_print("[identity] session locked (signer cleared, identity preserved)\n");
}
/* Build the hamburger menu. */
static GtkWidget *build_hamburger_menu(GtkWidget *window,
WebKitWebView *webview) {
GtkWidget *menu = gtk_menu_new();
/* Navigation group. */
GtkWidget *item_open = gtk_menu_item_new_with_label("Open File…");
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
GtkWidget *item_stop = gtk_menu_item_new_with_label("Stop");
g_signal_connect(item_open, "activate", G_CALLBACK(on_menu_open_file),
window);
g_signal_connect(item_reload, "activate", G_CALLBACK(on_menu_reload),
webview);
g_signal_connect(item_stop, "activate", G_CALLBACK(on_menu_stop), webview);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_open);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_stop);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Identity group — show current identity status. */
char identity_label[80];
const char *method_name = "none";
switch (g_state.method) {
case KEY_STORE_METHOD_LOCAL: method_name = "local"; break;
case KEY_STORE_METHOD_SEED: method_name = "seed"; break;
case KEY_STORE_METHOD_READONLY: method_name = "readonly"; break;
case KEY_STORE_METHOD_NIP46: method_name = "nip46"; break;
case KEY_STORE_METHOD_NSIGNER: method_name = "nsigner"; break;
default: break;
/* Ctrl+W — close active tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_w) {
tab_manager_close_active();
return TRUE;
}
if (g_state.pubkey_hex[0]) {
/* Show first 8 chars of pubkey for identification. */
char short_pubkey[12];
memcpy(short_pubkey, g_state.pubkey_hex, 8);
short_pubkey[8] = '\0';
snprintf(identity_label, sizeof(identity_label),
"Identity: %s… (%s)", short_pubkey, method_name);
} else {
snprintf(identity_label, sizeof(identity_label), "Identity: none");
}
GtkWidget *item_identity = gtk_menu_item_new_with_label(identity_label);
gtk_widget_set_sensitive(item_identity, FALSE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_identity);
GtkWidget *item_switch = gtk_menu_item_new_with_label("Switch Identity…");
GtkWidget *item_lock = gtk_menu_item_new_with_label("Lock Session");
GtkWidget *item_logout = gtk_menu_item_new_with_label("Logout");
g_signal_connect(item_switch, "activate", G_CALLBACK(on_menu_switch_identity),
window);
g_signal_connect(item_lock, "activate", G_CALLBACK(on_menu_lock_session), NULL);
g_signal_connect(item_logout, "activate", G_CALLBACK(on_menu_logout), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Roadmap group. */
GtkWidget *item_security =
gtk_check_menu_item_new_with_label("Security strip (SOP/CORS/certs)");
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item_security), FALSE);
g_signal_connect(item_security, "toggled",
G_CALLBACK(on_menu_security_strip), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_security);
GtkWidget *item_fips = gtk_menu_item_new_with_label("FIPS URI scheme…");
GtkWidget *item_nostr = gtk_menu_item_new_with_label("Nostr signing status");
g_signal_connect(item_fips, "activate", G_CALLBACK(on_menu_fips), NULL);
g_signal_connect(item_nostr, "activate", G_CALLBACK(on_menu_nostr_sign),
NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_nostr);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_inspector = gtk_menu_item_new_with_label("Toggle Inspector");
g_signal_connect(item_inspector, "activate", G_CALLBACK(on_menu_inspector),
webview);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector);
GtkWidget *item_about = gtk_menu_item_new_with_label("About");
g_signal_connect(item_about, "activate", G_CALLBACK(on_menu_about),
window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_about);
gtk_widget_show_all(menu);
GtkWidget *button = gtk_menu_button_new();
gtk_menu_button_set_popup(GTK_MENU_BUTTON(button), menu);
gtk_button_set_image(
GTK_BUTTON(button),
gtk_image_new_from_icon_name("open-menu-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(button, "Menu");
return button;
}
/* Normalize a bare string into a loadable URL. */
static char *normalize_url(const char *input) {
if (input == NULL || input[0] == '\0') {
return NULL;
}
if (strstr(input, "://") != NULL) {
return g_strdup(input);
}
return g_strdup_printf("https://%s", input);
}
static void on_url_activate(GtkEntry *entry, WebKitWebView *webview) {
const char *text = gtk_entry_get_text(entry);
char *url = normalize_url(text);
if (url != NULL) {
webkit_web_view_load_uri(webview, url);
g_free(url);
}
}
static void on_load_changed(WebKitWebView *webview,
WebKitLoadEvent load_event,
GtkEntry *url_entry) {
if (load_event == WEBKIT_LOAD_COMMITTED) {
const gchar *uri = webkit_web_view_get_uri(webview);
if (uri != NULL) {
gtk_entry_set_text(url_entry, uri);
/* Ctrl+L — focus URL bar of active tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_l) {
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->url_entry) {
gtk_widget_grab_focus(tab->url_entry);
gtk_editable_select_region(GTK_EDITABLE(tab->url_entry), 0, -1);
}
} else if (load_event == WEBKIT_LOAD_FINISHED) {
const gchar *title = webkit_web_view_get_title(webview);
const gchar *uri = webkit_web_view_get_uri(webview);
g_print("[loaded] %s -- title: %s\n",
uri ? uri : "(null)",
(title && title[0]) ? title : "(none)");
return TRUE;
}
/* Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs. */
if (s->ctrl_tab_switch && (mods & GDK_CONTROL_MASK)) {
if (event->keyval == GDK_KEY_Tab) {
if (mods & GDK_SHIFT_MASK) {
tab_manager_prev();
} else {
tab_manager_next();
}
return TRUE;
}
if (event->keyval == GDK_KEY_ISO_Left_Tab) {
tab_manager_prev();
return TRUE;
}
}
/* Ctrl+PageDown — next tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_Page_Down) {
tab_manager_next();
return TRUE;
}
/* Ctrl+PageUp — previous tab. */
if ((mods & GDK_CONTROL_MASK) && event->keyval == GDK_KEY_Page_Up) {
tab_manager_prev();
return TRUE;
}
}
static gboolean on_load_failed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gchar *failing_uri,
GError *error,
gpointer data) {
(void)webview;
(void)load_event;
(void)data;
g_print("[failed] %s -- %s\n",
failing_uri ? failing_uri : "(null)",
error ? error->message : "(unknown)");
return FALSE;
}
/* ---- Agent login callback ------------------------------------------ *
* Called by agent_server when an agent successfully logs in via the
* 'login' tool. Sets the flag so the main loop can proceed.
*/
static void agent_login_callback(void) {
g_agent_login_done = TRUE;
g_print("[login] Agent login detected, proceeding.\n");
}
/* ---- Window destroy ------------------------------------------------- */
static void on_window_destroy(GtkWidget *widget, gpointer data) {
(void)widget;
(void)data;
/* Save the session before cleanup. */
session_save();
/* Stop the agent server. */
agent_server_stop();
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
@@ -370,22 +444,13 @@ static void on_window_destroy(GtkWidget *widget, gpointer data) {
gtk_main_quit();
}
/* ---- Login flow ------------------------------------------------------ *
* Try to restore a saved identity. If none, show the login dialog.
* Returns 0 on success (signer or readonly loaded), -1 if user cancelled.
*/
static int do_login(GtkWindow *parent) {
/* Initialize nostr_core_lib. */
if (nostr_init() != NOSTR_SUCCESS) {
g_printerr("[login] Failed to initialize nostr_core_lib\n");
return -1;
}
/* ---- Login flow (GTK dialog fallback) ───────────────────────────── */
/* Show the login dialog every time — no plaintext key persistence.
* Encrypted key storage will be added in a future phase. */
static int do_login(GtkWindow *parent) {
/* nostr_init() is already called before this function. */
login_result_t result;
if (login_dialog_run(parent, &result) != 0) {
return -1; /* cancelled */
return -1;
}
g_state.signer = result.signer;
@@ -393,125 +458,150 @@ static int do_login(GtkWindow *parent) {
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY);
g_logged_in = TRUE;
g_print("[login] New identity: method=%d pubkey=%s\n",
g_state.method, g_state.pubkey_hex);
return 0;
}
/* ---- Main ------------------------------------------------------------ */
/* ---- Main ----------------------------------------------------------- */
int main(int argc, char **argv) {
(void)signal(SIGPIPE, SIG_IGN);
gtk_init(&argc, &argv);
const char *start_url = (argc > 1) ? argv[1] : "https://example.com";
char *normalized = normalize_url(start_url);
/* Load settings and history from disk. */
settings_load();
history_load();
const char *start_url = (argc > 1) ? argv[1] : NULL;
/* Top-level window. */
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser " SB_VERSION);
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL);
g_signal_connect(window, "key-press-event", G_CALLBACK(on_key_press), NULL);
g_window = GTK_WINDOW(window);
/* Login before showing the browser. */
if (do_login(GTK_WINDOW(window)) != 0) {
/* User cancelled login — exit. */
g_print("[login] Cancelled, exiting.\n");
gtk_widget_destroy(window);
/* Start the agent server before login so agents can authenticate
* without human interaction. The server runs on the configured port
* (default 17777). If disabled in settings, skip it. */
const browser_settings_t *s = settings_get();
if (s->agent_server_enabled) {
if (agent_server_start(s->agent_server_port) == 0) {
g_print("[agent] Server started on port %d\n",
agent_server_get_port());
} else {
g_printerr("[agent] Failed to start server on port %d\n",
s->agent_server_port);
}
}
/* Initialize nostr_core_lib (needed for both agent and GTK login). */
if (nostr_init() != NOSTR_SUCCESS) {
g_printerr("[login] Failed to initialize nostr_core_lib\n");
agent_server_stop();
return EXIT_SUCCESS;
}
/* Vertical box: toolbar on top, web view below. */
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_container_add(GTK_CONTAINER(window), vbox);
/* Login flow: wait for agent login or fall back to GTK dialog.
* If the agent server is running, wait up to agent_login_timeout_ms
* for an agent to call the 'login' tool. If no agent connects or
* the timeout expires, show the GTK login dialog. */
if (s->agent_server_enabled && s->agent_login_timeout_ms > 0) {
g_print("[login] Waiting up to %d ms for agent login...\n",
s->agent_login_timeout_ms);
WebKitWebView *webview = WEBKIT_WEB_VIEW(webkit_web_view_new());
/* Run a main loop with a timeout so the agent server can process
* incoming WebSocket connections and login commands. */
g_agent_login_done = FALSE;
agent_server_set_login_callback(agent_login_callback);
/* ── Security strip: disable web security restrictions ───────
* The "reckless browser" thesis: identity and transport are handled
* at a different layer (Nostr keys + FIPS mesh), so the browser's
* own security sandbox (CORS, SOP, TLS cert enforcement, mixed
* content blocking) gets in the way and is deliberately removed.
*/
GMainContext *ctx = g_main_context_get_thread_default();
gint64 deadline = g_get_monotonic_time() +
(gint64)s->agent_login_timeout_ms * 1000;
/* Enable developer extras (Web Inspector) for debugging. */
WebKitSettings *settings = webkit_web_view_get_settings(webview);
webkit_settings_set_enable_developer_extras(settings, TRUE);
webkit_settings_set_enable_javascript(settings, TRUE);
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
while (!g_agent_login_done) {
gint64 remaining = deadline - g_get_monotonic_time();
if (remaining <= 0) break;
/* Allow file:// pages to access other file:// resources and make
* cross-origin requests (needed for our test page to call
* sovereign://). */
webkit_settings_set_allow_file_access_from_file_urls(settings, TRUE);
webkit_settings_set_allow_universal_access_from_file_urls(settings, TRUE);
/* Process events for up to 200ms at a time. */
g_main_context_iteration(ctx, FALSE);
if (!g_agent_login_done) {
g_usleep(50 * 1000); /* 50ms */
}
}
/* Disable mixed content blocking — allow HTTP resources on HTTPS
* pages (FIPS/nostr:// content may not use TLS). */
webkit_settings_set_allow_modal_dialogs(settings, TRUE);
agent_server_set_login_callback(NULL);
}
/* Get the web context for scheme + security manager registration. */
WebKitWebContext *web_ctx = webkit_web_view_get_context(webview);
/* If not logged in via agent, show the GTK login dialog. */
if (!g_logged_in) {
g_print("[login] No agent login, showing GTK dialog.\n");
if (do_login(g_window) != 0) {
g_print("[login] Cancelled, exiting.\n");
agent_server_stop();
nostr_cleanup();
return EXIT_SUCCESS;
}
}
/* Register sovereign:// as a secure, local, CORS-enabled scheme so
* that fetch() from any page can call it without cross-origin
* restrictions. */
WebKitSecurityManager *sec_mgr = webkit_web_context_get_security_manager(web_ctx);
/* Use the default WebKitWebContext — it comes with proper networking
* (cookies, cache, soup session) that a freshly created context lacks.
* All tabs will create webviews from this shared context. */
WebKitWebContext *web_ctx = webkit_web_context_get_default();
/* ── Security strip: disable web security restrictions ─────── */
WebKitSecurityManager *sec_mgr =
webkit_web_context_get_security_manager(web_ctx);
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_cors_enabled(sec_mgr, "sovereign");
/* Also register file:// as secure so local pages can call our bridge. */
webkit_security_manager_register_uri_scheme_as_cors_enabled(sec_mgr,
"sovereign");
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
WebKitWebsiteDataManager *data_mgr = webkit_web_context_get_website_data_manager(web_ctx);
WebKitWebsiteDataManager *data_mgr =
webkit_web_context_get_website_data_manager(web_ctx);
webkit_website_data_manager_set_tls_errors_policy(
data_mgr, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
/* Register the sovereign:// URI scheme for the window.nostr bridge
* and browser-internal pages (sovereign://security). */
/* Register the sovereign:// URI scheme for the window.nostr bridge. */
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
g_state.readonly);
nostr_bridge_set_security_refs(settings, sec_mgr);
/* Inject window.nostr into every page before page scripts run. */
nostr_inject_setup(webview);
/* Vertical box: the tab manager's notebook fills the window. */
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_container_add(GTK_CONTAINER(window), vbox);
/* Toolbar: [hamburger] [url entry], horizontal. */
GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_top(toolbar, 4);
gtk_widget_set_margin_bottom(toolbar, 4);
gtk_widget_set_margin_start(toolbar, 4);
gtk_widget_set_margin_end(toolbar, 4);
gtk_box_pack_start(GTK_BOX(vbox), toolbar, FALSE, FALSE, 0);
/* Initialize the tab manager. */
tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window);
GtkWidget *hamburger = build_hamburger_menu(window, webview);
gtk_box_pack_start(GTK_BOX(toolbar), hamburger, FALSE, FALSE, 0);
/* Try to restore the previous session. If nothing was restored,
* open a single tab with the start URL or the default new-tab URL. */
int restored = session_restore();
if (restored == 0) {
const char *url = start_url;
if (url == NULL || url[0] == '\0') {
url = settings_get()->new_tab_url;
}
tab_manager_new_tab(url);
}
GtkWidget *url_entry = gtk_entry_new();
gtk_entry_set_text(GTK_ENTRY(url_entry),
normalized ? normalized : start_url);
gtk_box_pack_start(GTK_BOX(toolbar), url_entry, TRUE, TRUE, 0);
/* Store webview reference on the window for menu callbacks. */
g_object_set_data(G_OBJECT(window), "webview", webview);
gtk_box_pack_start(GTK_BOX(vbox), GTK_WIDGET(webview), TRUE, TRUE, 0);
/* Wire signals. */
g_signal_connect(url_entry, "activate", G_CALLBACK(on_url_activate),
webview);
g_signal_connect(webview, "load-changed", G_CALLBACK(on_load_changed),
url_entry);
g_signal_connect(webview, "load-failed", G_CALLBACK(on_load_failed),
NULL);
/* Load the start URL. */
if (normalized != NULL) {
webkit_web_view_load_uri(webview, normalized);
g_free(normalized);
/* Wire the security refs to the first tab's settings (settings are
* per-webview, not per-context). The sovereign://security page uses
* these to read and toggle security features. */
{
tab_info_t *first_tab = tab_manager_get(0);
if (first_tab && first_tab->webview) {
WebKitSettings *settings =
webkit_web_view_get_settings(first_tab->webview);
nostr_bridge_set_security_refs(settings, sec_mgr);
}
}
gtk_widget_show_all(window);
+38 -15
View File
@@ -339,8 +339,8 @@ static void handle_get_relays(WebKitURISchemeRequest *request) {
/* ── Security settings page ────────────────────────────────────── */
/* Generate the sovereign://security HTML page with toggle switches. */
static void handle_security_page(WebKitURISchemeRequest *request) {
/* Generate the sovereign://settings HTML page. */
static void handle_settings_page(WebKitURISchemeRequest *request) {
/* Read current settings state. */
int dev_extras = g_bridge.settings ?
webkit_settings_get_enable_developer_extras(g_bridge.settings) : 0;
@@ -353,11 +353,12 @@ static void handle_security_page(WebKitURISchemeRequest *request) {
char *html = g_strdup_printf(
"<!DOCTYPE html>\n"
"<html><head><meta charset='UTF-8'>\n"
"<title>sovereign browser — Security Settings</title>\n"
"<title>sovereign browser — Settings</title>\n"
"<style>\n"
" body { font-family: monospace; max-width: 700px; margin: 40px auto;\n"
" padding: 20px; background: #1a1a1a; color: #e0e0e0; }\n"
" h1 { color: #ff4444; }\n"
" h1 { color: #ff4444; border-bottom: 2px solid #ff4444; padding-bottom: 8px; }\n"
" h2 { color: #ff8888; margin-top: 30px; }\n"
" .setting { display: flex; justify-content: space-between;\n"
" align-items: center; padding: 12px 0;\n"
" border-bottom: 1px solid #333; }\n"
@@ -376,10 +377,19 @@ static void handle_security_page(WebKitURISchemeRequest *request) {
" .status.show { display: block; }\n"
" .status.ok { background: #1a3a1a; color: #44ff44; }\n"
" .status.err { background: #3a1a1a; color: #ff4444; }\n"
" .note { color: #888; font-size: 12px; margin: 20px 0; }\n"
" .note { color: #888; font-size: 12px; margin: 10px 0 20px 0; }\n"
" .info { color: #888; font-size: 12px; }\n"
" .info-row { display: flex; justify-content: space-between; padding: 6px 0; }\n"
"</style>\n"
"</head><body>\n"
"<h1>Security Settings</h1>\n"
"<h1>sovereign browser — Settings</h1>\n"
"\n"
"<h2>Identity</h2>\n"
" <div class='info-row'><span>Pubkey</span><span class='info'>%.16s…</span></div>\n"
" <div class='info-row'><span>Method</span><span class='info'>%s</span></div>\n"
" <div class='info-row'><span>Signing</span><span class='info'>%s</span></div>\n"
"\n"
"<h2>Security</h2>\n"
"<p class='note'>The 'reckless browser' thesis: identity and transport are handled\n"
"at a different layer (Nostr keys + FIPS mesh), so the browser's own security\n"
"sandbox is deliberately stripped. Toggle features on if you need them.</p>\n"
@@ -418,7 +428,7 @@ static void handle_security_page(WebKitURISchemeRequest *request) {
"\n"
"<script>\n"
" function toggle(feature) {\n"
" fetch('sovereign://security/set?feature=' + feature)\n"
" fetch('sovereign://settings/set?feature=' + feature)\n"
" .then(r => r.json())\n"
" .then(d => {\n"
" var s = document.getElementById('status');\n"
@@ -439,6 +449,9 @@ static void handle_security_page(WebKitURISchemeRequest *request) {
" }\n"
"</script>\n"
"</body></html>\n",
g_bridge.pubkey_hex[0] ? g_bridge.pubkey_hex : "(none)",
g_bridge.readonly ? "read-only" : "signing",
g_bridge.signer ? "active" : (g_bridge.readonly ? "no signer" : "none"),
dev_extras ? "on" : "off",
file_access ? "on" : "off",
universal_access ? "on" : "off"
@@ -448,8 +461,8 @@ static void handle_security_page(WebKitURISchemeRequest *request) {
g_free(html);
}
/* Handle sovereign://security/set?feature=X — toggle a setting. */
static void handle_security_set(WebKitURISchemeRequest *request,
/* Handle sovereign://settings/set?feature=X — toggle a setting. */
static void handle_settings_set(WebKitURISchemeRequest *request,
const char *query) {
if (g_bridge.settings == NULL) {
respond_error_json(request, -1, "Settings not available");
@@ -515,15 +528,25 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
return;
}
/* Route sovereign://security requests. */
if (strcmp(uri, "sovereign://security") == 0 ||
strncmp(uri, "sovereign://security?", 20) == 0) {
handle_security_page(request);
/* Route sovereign://settings requests. */
if (strcmp(uri, "sovereign://settings") == 0 ||
strncmp(uri, "sovereign://settings?", 21) == 0) {
handle_settings_page(request);
return;
}
if (strncmp(uri, "sovereign://security/set", 24) == 0) {
if (strncmp(uri, "sovereign://settings/set", 24) == 0) {
const char *query = strchr(uri + 24, '?');
handle_security_set(request, query ? query + 1 : "");
handle_settings_set(request, query ? query + 1 : "");
return;
}
/* sovereign://security redirects to sovereign://settings for
* backward compatibility. */
if (strcmp(uri, "sovereign://security") == 0) {
respond_html(request,
"<!DOCTYPE html><html><head>"
"<meta http-equiv='refresh' content='0; url=sovereign://settings'>"
"</head><body>Redirecting to settings…</body></html>");
return;
}
+108
View File
@@ -0,0 +1,108 @@
/*
* session.c — tab session save/restore for sovereign_browser
*
* Saves open tab URLs to ~/.sovereign_browser/session.txt (one per line)
* and restores them on startup if settings.restore_session is true.
*/
#include "session.h"
#include "settings.h"
#include "tab_manager.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
#define SESSION_URL_MAX 2048
/* ── Path helper ──────────────────────────────────────────────────── */
static int session_path(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
return -1;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
return -1;
}
n = snprintf(out, out_sz, "%s/session.txt", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Save ─────────────────────────────────────────────────────────── */
void session_save(void) {
char path[512];
if (session_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
return;
}
fchmod(fileno(f), 0600);
int count = tab_manager_count();
for (int i = 0; i < count; i++) {
const char *url = tab_manager_get_url(i);
if (url != NULL && url[0] != '\0') {
fprintf(f, "%s\n", url);
}
}
fclose(f);
g_print("[session] Saved %d tab(s)\n", count);
}
/* ── Restore ──────────────────────────────────────────────────────── */
int session_restore(void) {
const browser_settings_t *s = settings_get();
if (!s->restore_session) {
return 0;
}
char path[512];
if (session_path(path, sizeof(path)) != 0) {
return 0;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
return 0;
}
int restored = 0;
char line[SESSION_URL_MAX];
while (fgets(line, sizeof(line), f) != NULL) {
/* Strip newline. */
size_t len = strlen(line);
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
line[--len] = '\0';
}
if (len == 0) continue;
int index = tab_manager_new_tab(line);
if (index >= 0) {
restored++;
}
}
fclose(f);
g_print("[session] Restored %d tab(s)\n", restored);
return restored;
}
+35
View File
@@ -0,0 +1,35 @@
/*
* session.h — tab session save/restore for sovereign_browser
*
* Saves the list of open tab URLs to ~/.sovereign_browser/session.txt
* on window close, and restores them on startup if settings.restore_session
* is true.
*/
#ifndef SESSION_H
#define SESSION_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* Save the current set of open tab URLs to disk.
* Called on window destroy (before gtk_main_quit).
*/
void session_save(void);
/*
* Restore the saved session by creating a tab for each URL in
* ~/.sovereign_browser/session.txt.
*
* Returns the number of tabs created, or 0 if no session was restored
* (file missing, empty, or restore_session setting is false).
*/
int session_restore(void);
#ifdef __cplusplus
}
#endif
#endif /* SESSION_H */
+220
View File
@@ -0,0 +1,220 @@
/*
* settings.c — browser preferences for sovereign_browser
*
* Persists settings to ~/.sovereign_browser/settings.conf as key=value lines.
*/
#include "settings.h"
#include <gtk/gtk.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h> /* strcasecmp */
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
/* ── Global singleton ─────────────────────────────────────────────── */
static browser_settings_t g_settings;
/* ── Defaults ─────────────────────────────────────────────────────── */
static void settings_set_defaults(browser_settings_t *s) {
s->restore_session = TRUE;
snprintf(s->new_tab_url, sizeof(s->new_tab_url), "%s",
SETTINGS_NEW_TAB_URL_DEFAULT);
s->tab_bar_position = GTK_POS_TOP;
s->show_tab_close_buttons = TRUE;
s->middle_click_close = TRUE;
s->ctrl_tab_switch = TRUE;
s->max_tabs = SETTINGS_MAX_TABS_DEFAULT;
s->tab_drag_reorder = TRUE;
s->agent_server_enabled = TRUE;
s->agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
snprintf(s->agent_allowed_origins, sizeof(s->agent_allowed_origins), "*");
s->agent_login_timeout_ms = SETTINGS_AGENT_LOGIN_TIMEOUT_DEFAULT;
}
/* ── Path helper ──────────────────────────────────────────────────── */
static int settings_path(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
return -1;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
return -1;
}
n = snprintf(out, out_sz, "%s/settings.conf", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Parsing helpers ──────────────────────────────────────────────── */
static gboolean parse_bool(const char *value, gboolean fallback) {
if (value == NULL) return fallback;
if (strcasecmp(value, "true") == 0 ||
strcasecmp(value, "yes") == 0 ||
strcasecmp(value, "1") == 0 ||
strcasecmp(value, "on") == 0) {
return TRUE;
}
if (strcasecmp(value, "false") == 0 ||
strcasecmp(value, "no") == 0 ||
strcasecmp(value, "0") == 0 ||
strcasecmp(value, "off") == 0) {
return FALSE;
}
return fallback;
}
static int parse_int(const char *value, int fallback) {
if (value == NULL || value[0] == '\0') return fallback;
char *end = NULL;
long v = strtol(value, &end, 10);
if (end == value) return fallback;
return (int)v;
}
static int parse_position(const char *value, int fallback) {
if (value == NULL) return fallback;
if (strcasecmp(value, "top") == 0) return GTK_POS_TOP;
if (strcasecmp(value, "bottom") == 0) return GTK_POS_BOTTOM;
if (strcasecmp(value, "left") == 0) return GTK_POS_LEFT;
if (strcasecmp(value, "right") == 0) return GTK_POS_RIGHT;
/* Allow numeric GTK_POS_* values too. */
return parse_int(value, fallback);
}
/* ── Load / Save ──────────────────────────────────────────────────── */
void settings_load(void) {
settings_set_defaults(&g_settings);
char path[512];
if (settings_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
return;
}
char line[1024];
while (fgets(line, sizeof(line), f) != NULL) {
/* Strip newline. */
size_t len = strlen(line);
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
line[--len] = '\0';
}
if (len == 0 || line[0] == '#') continue;
/* Split key=value. */
char *eq = strchr(line, '=');
if (eq == NULL) continue;
*eq = '\0';
char *key = line;
char *value = eq + 1;
/* Trim whitespace from key. */
while (*key == ' ' || *key == '\t') key++;
char *kend = key + strlen(key);
while (kend > key && (kend[-1] == ' ' || kend[-1] == '\t')) *--kend = '\0';
/* Trim whitespace from value. */
while (*value == ' ' || *value == '\t') value++;
if (strcmp(key, "restore_session") == 0) {
g_settings.restore_session = parse_bool(value, g_settings.restore_session);
} else if (strcmp(key, "new_tab_url") == 0) {
snprintf(g_settings.new_tab_url, sizeof(g_settings.new_tab_url),
"%s", value);
} else if (strcmp(key, "tab_bar_position") == 0) {
g_settings.tab_bar_position = parse_position(value, g_settings.tab_bar_position);
} else if (strcmp(key, "show_tab_close_buttons") == 0) {
g_settings.show_tab_close_buttons = parse_bool(value, g_settings.show_tab_close_buttons);
} else if (strcmp(key, "middle_click_close") == 0) {
g_settings.middle_click_close = parse_bool(value, g_settings.middle_click_close);
} else if (strcmp(key, "ctrl_tab_switch") == 0) {
g_settings.ctrl_tab_switch = parse_bool(value, g_settings.ctrl_tab_switch);
} else if (strcmp(key, "max_tabs") == 0) {
g_settings.max_tabs = parse_int(value, g_settings.max_tabs);
if (g_settings.max_tabs < 1) g_settings.max_tabs = 1;
} else if (strcmp(key, "tab_drag_reorder") == 0) {
g_settings.tab_drag_reorder = parse_bool(value, g_settings.tab_drag_reorder);
} else if (strcmp(key, "agent_server_enabled") == 0) {
g_settings.agent_server_enabled = parse_bool(value, g_settings.agent_server_enabled);
} else if (strcmp(key, "agent_server_port") == 0) {
g_settings.agent_server_port = parse_int(value, g_settings.agent_server_port);
if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
} else if (strcmp(key, "agent_allowed_origins") == 0) {
snprintf(g_settings.agent_allowed_origins,
sizeof(g_settings.agent_allowed_origins), "%s", value);
} else if (strcmp(key, "agent_login_timeout_ms") == 0) {
g_settings.agent_login_timeout_ms = parse_int(value, g_settings.agent_login_timeout_ms);
if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0;
}
/* Unknown keys are silently ignored. */
}
fclose(f);
}
void settings_save(void) {
char path[512];
if (settings_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
return;
}
fchmod(fileno(f), 0600);
const char *pos_str = "top";
switch (g_settings.tab_bar_position) {
case GTK_POS_TOP: pos_str = "top"; break;
case GTK_POS_BOTTOM: pos_str = "bottom"; break;
case GTK_POS_LEFT: pos_str = "left"; break;
case GTK_POS_RIGHT: pos_str = "right"; break;
}
fprintf(f, "# sovereign_browser settings\n");
fprintf(f, "restore_session=%s\n", g_settings.restore_session ? "true" : "false");
fprintf(f, "new_tab_url=%s\n", g_settings.new_tab_url);
fprintf(f, "tab_bar_position=%s\n", pos_str);
fprintf(f, "show_tab_close_buttons=%s\n", g_settings.show_tab_close_buttons ? "true" : "false");
fprintf(f, "middle_click_close=%s\n", g_settings.middle_click_close ? "true" : "false");
fprintf(f, "ctrl_tab_switch=%s\n", g_settings.ctrl_tab_switch ? "true" : "false");
fprintf(f, "max_tabs=%d\n", g_settings.max_tabs);
fprintf(f, "tab_drag_reorder=%s\n", g_settings.tab_drag_reorder ? "true" : "false");
fprintf(f, "agent_server_enabled=%s\n", g_settings.agent_server_enabled ? "true" : "false");
fprintf(f, "agent_server_port=%d\n", g_settings.agent_server_port);
fprintf(f, "agent_allowed_origins=%s\n", g_settings.agent_allowed_origins);
fprintf(f, "agent_login_timeout_ms=%d\n", g_settings.agent_login_timeout_ms);
fclose(f);
}
const browser_settings_t *settings_get(void) {
return &g_settings;
}
browser_settings_t *settings_get_mutable(void) {
return &g_settings;
}
+68
View File
@@ -0,0 +1,68 @@
/*
* settings.h — browser preferences for sovereign_browser
*
* Persists user-configurable settings to ~/.sovereign_browser/settings.conf
* as simple key=value lines. A global singleton is loaded at startup and
* accessed via settings_get().
*/
#ifndef SETTINGS_H
#define SETTINGS_H
#include <glib.h>
#ifdef __cplusplus
extern "C" {
#endif
#define SETTINGS_NEW_TAB_URL_DEFAULT "https://example.com"
#define SETTINGS_NEW_TAB_URL_MAX 512
#define SETTINGS_MAX_TABS_DEFAULT 50
#define SETTINGS_AGENT_PORT_DEFAULT 17777
#define SETTINGS_AGENT_LOGIN_TIMEOUT_DEFAULT 30000 /* ms */
#define SETTINGS_AGENT_ORIGINS_MAX 256
typedef struct {
gboolean restore_session; /* restore open tabs on next launch */
char new_tab_url[SETTINGS_NEW_TAB_URL_MAX];
int tab_bar_position; /* GTK_POS_TOP / BOTTOM / LEFT / RIGHT */
gboolean show_tab_close_buttons; /* show per-tab close button */
gboolean middle_click_close; /* middle-click on tab closes it */
gboolean ctrl_tab_switch; /* Ctrl+Tab cycles tabs */
int max_tabs; /* maximum simultaneous tabs */
gboolean tab_drag_reorder; /* allow drag to reorder tabs */
gboolean agent_server_enabled; /* enable agent WebSocket server */
int agent_server_port; /* port for agent server */
char agent_allowed_origins[SETTINGS_AGENT_ORIGINS_MAX]; /* "*" for any */
int agent_login_timeout_ms; /* wait for agent login before GTK dialog */
} browser_settings_t;
/*
* Load settings from disk into the global singleton.
* If the file doesn't exist or a key is missing, defaults are used.
* Call once at startup.
*/
void settings_load(void);
/*
* Save the current global settings to disk.
*/
void settings_save(void);
/*
* Get a pointer to the global settings singleton.
* Always valid after settings_load().
*/
const browser_settings_t *settings_get(void);
/*
* Get a mutable pointer to the global settings.
* Used by the settings dialog to modify values; call settings_save() after.
*/
browser_settings_t *settings_get_mutable(void);
#ifdef __cplusplus
}
#endif
#endif /* SETTINGS_H */
+783
View File
@@ -0,0 +1,783 @@
/*
* tab_manager.c — multi-tab management for sovereign_browser
*
* Each tab is a GtkBox page containing a per-tab toolbar (hamburger menu +
* URL entry) and a WebKitWebView. All webviews share the same
* WebKitWebContext so the sovereign:// bridge and security settings apply
* to every tab automatically.
*/
#include "tab_manager.h"
#include "settings.h"
#include "nostr_inject.h"
#include "history.h"
#include "version.h"
#include <string.h>
#include <stdlib.h>
/* ── External callbacks defined in main.c ─────────────────────────── *
* These handle identity-related menu actions that need access to the
* global app_state_t (signer, pubkey, method). main.c owns that state.
*/
/* Proxy functions defined in main.c that wrap the app_state_t-aware
* callbacks so they match GTK signal handler signatures. */
extern void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_logout_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data);
extern void app_menu_fips_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_about_proxy(GtkMenuItem *item, gpointer data);
extern void on_menu_settings(GtkMenuItem *item, gpointer data);
/* ── Static state ─────────────────────────────────────────────────── */
static GtkWidget *g_notebook = NULL;
static WebKitWebContext *g_ctx = NULL;
static GtkWindow *g_window = NULL;
/* Dynamic array of tab_info_t pointers, indexed by notebook page number. */
static tab_info_t **g_tabs = NULL;
static int g_tab_count = 0;
static int g_tab_cap = 0;
/* ── Forward declarations ─────────────────────────────────────────── */
static tab_info_t *tab_create(const char *url);
static GtkWidget *build_tab_label(tab_info_t *tab);
static GtkWidget *build_hamburger_menu(tab_info_t *tab);
static char *normalize_url(const char *input);
static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data);
/* ── URL helper (same logic as main.c's normalize_url) ────────────── */
static char *normalize_url(const char *input) {
if (input == NULL || input[0] == '\0') {
return NULL;
}
if (strstr(input, "://") != NULL) {
return g_strdup(input);
}
return g_strdup_printf("https://%s", input);
}
/* ── Tab label widget ─────────────────────────────────────────────── */
static void on_tab_close_clicked(GtkButton *btn, gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
(void)btn;
int index = gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook), tab->page);
if (index >= 0) {
tab_manager_close_tab(index);
}
}
static gboolean on_tab_label_button_press(GtkWidget *widget,
GdkEventButton *event,
gpointer user_data) {
(void)widget;
tab_info_t *tab = (tab_info_t *)user_data;
int index = gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook), tab->page);
if (index < 0) return FALSE;
const browser_settings_t *s = settings_get();
/* Middle-click to close. */
if (event->button == 2 && s->middle_click_close) {
tab_manager_close_tab(index);
return TRUE;
}
/* Right-click context menu. */
if (event->button == 3) {
GtkWidget *menu = gtk_menu_new();
GtkWidget *item_new = gtk_menu_item_new_with_label("New Tab");
g_signal_connect(item_new, "activate",
G_CALLBACK(on_tab_close_clicked_proxy_new), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new);
GtkWidget *item_close = gtk_menu_item_new_with_label("Close Tab");
g_signal_connect_swapped(item_close, "activate",
G_CALLBACK(tab_manager_close_tab),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close);
GtkWidget *item_close_others =
gtk_menu_item_new_with_label("Close Other Tabs");
g_signal_connect_swapped(item_close_others, "activate",
G_CALLBACK(tab_manager_close_others),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_others);
GtkWidget *item_close_right =
gtk_menu_item_new_with_label("Close Tabs to the Right");
g_signal_connect_swapped(item_close_right, "activate",
G_CALLBACK(tab_manager_close_to_right),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_right);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_dup = gtk_menu_item_new_with_label("Duplicate Tab");
g_signal_connect_swapped(item_dup, "activate",
G_CALLBACK(tab_manager_duplicate),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dup);
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload Tab");
g_signal_connect_swapped(item_reload, "activate",
G_CALLBACK(tab_manager_reload),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_widget_show_all(menu);
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
return TRUE;
}
return FALSE;
}
/* Proxy callback for "New Tab" in the context menu — just calls new_tab. */
static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab(NULL);
}
static GtkWidget *build_tab_label(tab_info_t *tab) {
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
/* Favicon placeholder icon. */
GtkWidget *icon = gtk_image_new_from_icon_name("text-html",
GTK_ICON_SIZE_MENU);
gtk_box_pack_start(GTK_BOX(box), icon, FALSE, FALSE, 0);
/* Title label. */
tab->title_label = gtk_label_new("New Tab");
gtk_label_set_ellipsize(GTK_LABEL(tab->title_label), PANGO_ELLIPSIZE_END);
gtk_label_set_max_width_chars(GTK_LABEL(tab->title_label), 20);
gtk_box_pack_start(GTK_BOX(box), tab->title_label, TRUE, TRUE, 0);
/* Close button. */
const browser_settings_t *s = settings_get();
if (s->show_tab_close_buttons) {
GtkWidget *close_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(close_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(close_btn),
gtk_image_new_from_icon_name("window-close-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(close_btn, "Close tab");
g_signal_connect(close_btn, "clicked",
G_CALLBACK(on_tab_close_clicked), tab);
gtk_box_pack_start(GTK_BOX(box), close_btn, FALSE, FALSE, 0);
}
/* Connect button-press for middle-click and right-click on the label. */
gtk_widget_add_events(box, GDK_BUTTON_PRESS_MASK);
g_signal_connect(box, "button-press-event",
G_CALLBACK(on_tab_label_button_press), tab);
gtk_widget_show_all(box);
return box;
}
/* ── Per-tab signal handlers ──────────────────────────────────────── */
static void on_url_activate(GtkEntry *entry, gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
const char *text = gtk_entry_get_text(entry);
char *url = normalize_url(text);
if (url != NULL) {
webkit_web_view_load_uri(tab->webview, url);
g_free(url);
}
}
static void on_load_changed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
if (load_event == WEBKIT_LOAD_COMMITTED) {
const gchar *uri = webkit_web_view_get_uri(webview);
if (uri != NULL) {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), uri);
snprintf(tab->current_url, sizeof(tab->current_url), "%s", uri);
}
} else if (load_event == WEBKIT_LOAD_FINISHED) {
const gchar *title = webkit_web_view_get_title(webview);
const gchar *uri = webkit_web_view_get_uri(webview);
g_print("[loaded] %s -- title: %s\n",
uri ? uri : "(null)",
(title && title[0]) ? title : "(none)");
/* Update tab title. */
if (title && title[0]) {
int index = gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook),
tab->page);
if (index >= 0) {
tab_manager_set_title(index, title);
}
}
/* Add to history. */
if (uri != NULL && uri[0] != '\0') {
history_add(uri);
}
}
}
static gboolean on_load_failed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gchar *failing_uri,
GError *error,
gpointer data) {
(void)webview;
(void)load_event;
(void)data;
g_print("[failed] %s -- %s\n",
failing_uri ? failing_uri : "(null)",
error ? error->message : "(unknown)");
return FALSE;
}
/* ── Hamburger menu callbacks (webview-specific) ──────────────────── */
static void on_menu_reload(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
static void on_menu_stop(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_stop_loading(tab->webview);
}
}
static void on_menu_inspector(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
WebKitWebInspector *insp = webkit_web_view_get_inspector(tab->webview);
if (insp) webkit_web_inspector_show(insp);
}
}
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_window == NULL) return;
GtkWidget *dialog = gtk_file_chooser_dialog_new(
"Open File", g_window, GTK_FILE_CHOOSER_ACTION_OPEN,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Open", GTK_RESPONSE_ACCEPT, NULL);
GtkFileFilter *filter_html = gtk_file_filter_new();
gtk_file_filter_set_name(filter_html, "HTML files");
gtk_file_filter_add_pattern(filter_html, "*.html");
gtk_file_filter_add_pattern(filter_html, "*.htm");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_html);
GtkFileFilter *filter_all = gtk_file_filter_new();
gtk_file_filter_set_name(filter_all, "All files");
gtk_file_filter_add_pattern(filter_all, "*");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_all);
if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) {
char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog));
char *uri = g_strdup_printf("file://%s", filename);
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, uri);
}
g_free(uri);
g_free(filename);
}
gtk_widget_destroy(dialog);
}
static void on_menu_history_item(GtkMenuItem *item, gpointer data) {
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL || tab->webview == NULL || item == NULL) return;
const char *url = gtk_menu_item_get_label(item);
if (url && url[0]) {
char *normalized = normalize_url(url);
if (normalized) {
webkit_web_view_load_uri(tab->webview, normalized);
g_free(normalized);
}
}
}
static void on_menu_history_clear(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
history_clear();
g_print("[history] cleared\n");
}
static void on_history_menu_show(GtkWidget *menu, gpointer user_data) {
(void)user_data;
/* Remove all existing items. */
GList *children = gtk_container_get_children(GTK_CONTAINER(menu));
for (GList *l = children; l != NULL; l = l->next) {
gtk_widget_destroy(GTK_WIDGET(l->data));
}
g_list_free(children);
/* Rebuild with current history. */
int hcount = history_count();
if (hcount == 0) {
GtkWidget *empty = gtk_menu_item_new_with_label("(no recent pages)");
gtk_widget_set_sensitive(empty, FALSE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), empty);
} else {
int show = hcount < 20 ? hcount : 20;
for (int i = 0; i < show; i++) {
const char *url = history_get(i);
if (url == NULL) break;
char label[80];
if (strlen(url) > 75) {
snprintf(label, sizeof(label), "%.72s...", url);
} else {
snprintf(label, sizeof(label), "%s", url);
}
GtkWidget *hitem = gtk_menu_item_new_with_label(label);
gtk_widget_set_tooltip_text(hitem, url);
g_signal_connect(hitem, "activate",
G_CALLBACK(on_menu_history_item), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), hitem);
}
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *clear_item = gtk_menu_item_new_with_label("Clear Recents");
g_signal_connect(clear_item, "activate",
G_CALLBACK(on_menu_history_clear), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), clear_item);
}
gtk_widget_show_all(menu);
}
/* ── Hamburger menu builder ───────────────────────────────────────── */
static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
(void)tab;
GtkWidget *menu = gtk_menu_new();
/* Navigation group. */
GtkWidget *item_open = gtk_menu_item_new_with_label("Open File…");
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
GtkWidget *item_stop = gtk_menu_item_new_with_label("Stop");
g_signal_connect(item_open, "activate", G_CALLBACK(on_menu_open_file), NULL);
g_signal_connect(item_reload, "activate", G_CALLBACK(on_menu_reload), NULL);
g_signal_connect(item_stop, "activate", G_CALLBACK(on_menu_stop), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_open);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_stop);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Recents submenu. */
GtkWidget *history_menu = gtk_menu_new();
g_signal_connect(history_menu, "show", G_CALLBACK(on_history_menu_show), NULL);
GtkWidget *item_history = gtk_menu_item_new_with_label("Recents");
gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_history), history_menu);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_history);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Identity group — delegated to main.c (app_state_t). */
GtkWidget *item_switch = gtk_menu_item_new_with_label("Switch Identity…");
GtkWidget *item_lock = gtk_menu_item_new_with_label("Lock Session");
GtkWidget *item_logout = gtk_menu_item_new_with_label("Logout");
g_signal_connect(item_switch, "activate",
G_CALLBACK(app_menu_switch_identity_proxy), g_window);
g_signal_connect(item_lock, "activate",
G_CALLBACK(app_menu_lock_session_proxy), NULL);
g_signal_connect(item_logout, "activate",
G_CALLBACK(app_menu_logout_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Roadmap group. */
GtkWidget *item_security =
gtk_check_menu_item_new_with_label("Security strip (SOP/CORS/certs)");
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item_security), FALSE);
g_signal_connect(item_security, "toggled",
G_CALLBACK(app_menu_security_strip_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_security);
GtkWidget *item_fips = gtk_menu_item_new_with_label("FIPS URI scheme…");
GtkWidget *item_nostr = gtk_menu_item_new_with_label("Nostr signing status");
g_signal_connect(item_fips, "activate",
G_CALLBACK(app_menu_fips_proxy), NULL);
g_signal_connect(item_nostr, "activate",
G_CALLBACK(app_menu_nostr_sign_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_nostr);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_inspector = gtk_menu_item_new_with_label("Toggle Inspector");
g_signal_connect(item_inspector, "activate",
G_CALLBACK(on_menu_inspector), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector);
GtkWidget *item_settings = gtk_menu_item_new_with_label("Settings…");
g_signal_connect(item_settings, "activate",
G_CALLBACK(on_menu_settings), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_settings);
GtkWidget *item_about = gtk_menu_item_new_with_label("About");
g_signal_connect(item_about, "activate",
G_CALLBACK(app_menu_about_proxy), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_about);
gtk_widget_show_all(menu);
GtkWidget *button = gtk_menu_button_new();
gtk_menu_button_set_popup(GTK_MENU_BUTTON(button), menu);
gtk_button_set_image(
GTK_BUTTON(button),
gtk_image_new_from_icon_name("open-menu-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(button, "Menu");
return button;
}
/* ── Tab array management ─────────────────────────────────────────── */
static int tab_array_add(tab_info_t *tab) {
if (g_tab_count >= g_tab_cap) {
int new_cap = g_tab_cap == 0 ? 8 : g_tab_cap * 2;
tab_info_t **new_arr = g_realloc(g_tabs, new_cap * sizeof(tab_info_t *));
if (new_arr == NULL) return -1;
g_tabs = new_arr;
g_tab_cap = new_cap;
}
g_tabs[g_tab_count] = tab;
return g_tab_count++;
}
static void tab_array_remove(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Free the tab_info_t. */
tab_info_t *tab = g_tabs[index];
if (tab) {
/* The webview and widgets are destroyed by GtkNotebook when the
* page is removed. We just free the struct. */
g_free(tab);
}
/* Shift remaining entries down. */
for (int i = index; i < g_tab_count - 1; i++) {
g_tabs[i] = g_tabs[i + 1];
}
g_tab_count--;
}
/* ── Tab creation ─────────────────────────────────────────────────── */
static tab_info_t *tab_create(const char *url) {
const browser_settings_t *s = settings_get();
tab_info_t *tab = g_new0(tab_info_t, 1);
if (tab == NULL) return NULL;
/* Determine the URL to load. */
const char *load_url = url;
char *default_url = NULL;
if (load_url == NULL || load_url[0] == '\0') {
load_url = s->new_tab_url;
}
default_url = normalize_url(load_url);
if (default_url == NULL) {
default_url = g_strdup(load_url);
}
snprintf(tab->current_url, sizeof(tab->current_url), "%s", default_url);
snprintf(tab->title, sizeof(tab->title), "Loading…");
/* Create the webview from the shared context. */
tab->webview = WEBKIT_WEB_VIEW(webkit_web_view_new_with_context(g_ctx));
/* Enable developer extras + security settings (same as original main.c). */
WebKitSettings *settings = webkit_web_view_get_settings(tab->webview);
webkit_settings_set_enable_developer_extras(settings, TRUE);
webkit_settings_set_enable_javascript(settings, TRUE);
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
webkit_settings_set_allow_file_access_from_file_urls(settings, TRUE);
webkit_settings_set_allow_universal_access_from_file_urls(settings, TRUE);
webkit_settings_set_allow_modal_dialogs(settings, TRUE);
/* Inject window.nostr into this webview. */
nostr_inject_setup(tab->webview);
/* Build the per-tab page: toolbar on top, webview below. */
tab->page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_top(toolbar, 4);
gtk_widget_set_margin_bottom(toolbar, 4);
gtk_widget_set_margin_start(toolbar, 4);
gtk_widget_set_margin_end(toolbar, 4);
gtk_box_pack_start(GTK_BOX(tab->page), toolbar, FALSE, FALSE, 0);
tab->hamburger = build_hamburger_menu(tab);
gtk_box_pack_start(GTK_BOX(toolbar), tab->hamburger, FALSE, FALSE, 0);
tab->url_entry = gtk_entry_new();
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), default_url);
gtk_box_pack_start(GTK_BOX(toolbar), tab->url_entry, TRUE, TRUE, 0);
gtk_box_pack_start(GTK_BOX(tab->page), GTK_WIDGET(tab->webview),
TRUE, TRUE, 0);
/* Build the tab label. */
tab->tab_label = build_tab_label(tab);
/* Wire signals. */
g_signal_connect(tab->url_entry, "activate",
G_CALLBACK(on_url_activate), tab);
g_signal_connect(tab->webview, "load-changed",
G_CALLBACK(on_load_changed), tab);
g_signal_connect(tab->webview, "load-failed",
G_CALLBACK(on_load_failed), NULL);
/* Load the URL. */
webkit_web_view_load_uri(tab->webview, default_url);
g_free(default_url);
return tab;
}
/* ── New tab button ───────────────────────────────────────────────── */
static void on_new_tab_clicked(GtkButton *btn, gpointer data) {
(void)btn;
(void)data;
tab_manager_new_tab(NULL);
}
/* ── Public API ───────────────────────────────────────────────────── */
void tab_manager_init(GtkContainer *parent,
WebKitWebContext *ctx,
GtkWindow *window) {
g_ctx = ctx;
g_window = window;
g_notebook = gtk_notebook_new();
gtk_notebook_set_scrollable(GTK_NOTEBOOK(g_notebook), TRUE);
gtk_notebook_set_show_border(GTK_NOTEBOOK(g_notebook), FALSE);
/* New-tab button as an action widget at the end of the tab strip. */
GtkWidget *new_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(new_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(new_btn),
gtk_image_new_from_icon_name("tab-new-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(new_btn, "New tab (Ctrl+T)");
g_signal_connect(new_btn, "clicked", G_CALLBACK(on_new_tab_clicked), NULL);
gtk_widget_show_all(new_btn);
gtk_notebook_set_action_widget(GTK_NOTEBOOK(g_notebook), new_btn,
GTK_PACK_END);
gtk_container_add(parent, g_notebook);
tab_manager_apply_settings();
}
void tab_manager_apply_settings(void) {
if (g_notebook == NULL) return;
const browser_settings_t *s = settings_get();
gtk_notebook_set_tab_pos(GTK_NOTEBOOK(g_notebook), s->tab_bar_position);
/* Apply drag reorder to all existing tabs. */
for (int i = 0; i < g_tab_count; i++) {
if (g_tabs[i] && g_tabs[i]->page) {
gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(g_notebook),
g_tabs[i]->page,
s->tab_drag_reorder);
}
}
}
int tab_manager_new_tab(const char *url) {
const browser_settings_t *s = settings_get();
if (g_tab_count >= s->max_tabs) {
g_print("[tabs] Maximum tab count (%d) reached\n", s->max_tabs);
return -1;
}
tab_info_t *tab = tab_create(url);
if (tab == NULL) return -1;
int index = tab_array_add(tab);
if (index < 0) {
g_free(tab);
return -1;
}
/* Add to notebook. */
int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(g_notebook),
tab->page, tab->tab_label);
gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(g_notebook), tab->page,
s->tab_drag_reorder);
/* Switch to the new tab. */
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), page_num);
/* Show everything. */
gtk_widget_show_all(tab->page);
gtk_widget_show_all(tab->tab_label);
g_print("[tabs] Created tab %d, total: %d\n", page_num, g_tab_count);
return page_num;
}
void tab_manager_close_tab(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return;
/* Remove from notebook (this destroys the page widget). */
gtk_notebook_remove_page(GTK_NOTEBOOK(g_notebook), index);
/* Remove from our array. */
tab_array_remove(index);
g_print("[tabs] Closed tab %d, remaining: %d\n", index, g_tab_count);
/* If no tabs left, quit the app. */
if (g_tab_count == 0) {
g_print("[tabs] Last tab closed, exiting.\n");
if (g_window) {
gtk_window_close(g_window);
}
}
}
void tab_manager_close_active(void) {
int index = tab_manager_get_active_index();
if (index >= 0) {
tab_manager_close_tab(index);
}
}
int tab_manager_get_active_index(void) {
if (g_notebook == NULL) return -1;
return gtk_notebook_get_current_page(GTK_NOTEBOOK(g_notebook));
}
void tab_manager_switch_to(int index) {
if (g_notebook == NULL) return;
if (index < 0 || index >= g_tab_count) return;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), index);
}
tab_info_t *tab_manager_get_active(void) {
int index = tab_manager_get_active_index();
if (index < 0 || index >= g_tab_count) return NULL;
return g_tabs[index];
}
tab_info_t *tab_manager_get(int index) {
if (index < 0 || index >= g_tab_count) return NULL;
return g_tabs[index];
}
int tab_manager_count(void) {
return g_tab_count;
}
void tab_manager_set_title(int index, const char *title) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL || tab->title_label == NULL) return;
snprintf(tab->title, sizeof(tab->title), "%s",
(title && title[0]) ? title : "(Untitled)");
gtk_label_set_text(GTK_LABEL(tab->title_label), tab->title);
}
const char *tab_manager_get_url(int index) {
if (index < 0 || index >= g_tab_count) return NULL;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return NULL;
return tab->current_url;
}
void tab_manager_next(void) {
if (g_notebook == NULL || g_tab_count == 0) return;
int cur = gtk_notebook_get_current_page(GTK_NOTEBOOK(g_notebook));
int next = (cur + 1) % g_tab_count;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), next);
}
void tab_manager_prev(void) {
if (g_notebook == NULL || g_tab_count == 0) return;
int cur = gtk_notebook_get_current_page(GTK_NOTEBOOK(g_notebook));
int prev = (cur - 1 + g_tab_count) % g_tab_count;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), prev);
}
void tab_manager_close_others(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Close tabs to the right first (indices shift as we close). */
tab_manager_close_to_right(index);
/* Close tabs to the left (close from the start so indices stay valid). */
while (index > 0) {
tab_manager_close_tab(0);
index--;
}
}
void tab_manager_close_to_right(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Close from the end so indices don't shift. */
while (g_tab_count > index + 1) {
tab_manager_close_tab(g_tab_count - 1);
}
}
void tab_manager_duplicate(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return;
tab_manager_new_tab(tab->current_url);
}
void tab_manager_reload(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab && tab->webview) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
+142
View File
@@ -0,0 +1,142 @@
/*
* tab_manager.h — multi-tab management for sovereign_browser
*
* Wraps a GtkNotebook and manages per-tab state: each tab has its own
* toolbar (hamburger menu + URL entry) and WebKitWebView. All webviews
* share a single WebKitWebContext so the sovereign:// bridge, security
* settings, and TLS policy apply to every tab.
*/
#ifndef TAB_MANAGER_H
#define TAB_MANAGER_H
#include <gtk/gtk.h>
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
#define TAB_TITLE_MAX 256
#define TAB_URL_MAX 2048
typedef struct {
WebKitWebView *webview;
GtkWidget *url_entry;
GtkWidget *hamburger;
GtkWidget *page; /* the vertical box (toolbar + webview) */
GtkWidget *tab_label; /* composite label widget for the tab strip */
GtkWidget *title_label; /* child of tab_label */
char current_url[TAB_URL_MAX];
char title[TAB_TITLE_MAX];
} tab_info_t;
/*
* Initialize the tab manager. Creates a GtkNotebook, adds it to parent,
* and stores the shared web context for creating new webviews.
*
* parent: the GtkContainer (typically a GtkBox) to add the notebook to
* ctx: the shared WebKitWebContext for all tabs
* window: the top-level GtkWindow (for menu dialogs, file choosers, etc.)
*/
void tab_manager_init(GtkContainer *parent,
WebKitWebContext *ctx,
GtkWindow *window);
/*
* Create a new tab and load the given URL (or the default new-tab URL
* if url is NULL). Returns the tab index, or -1 on failure (e.g. max
* tabs reached).
*/
int tab_manager_new_tab(const char *url);
/*
* Close the tab at the given index. If it's the last tab, the window
* destroy handler will fire (caller is responsible for quitting).
*/
void tab_manager_close_tab(int index);
/*
* Close the currently active tab.
*/
void tab_manager_close_active(void);
/*
* Get the index of the currently active tab.
*/
int tab_manager_get_active_index(void);
/*
* Switch to the tab at the given index.
*/
void tab_manager_switch_to(int index);
/*
* Get the tab_info_t for the active tab, or NULL if no tabs exist.
*/
tab_info_t *tab_manager_get_active(void);
/*
* Get the tab_info_t for the tab at the given index, or NULL.
*/
tab_info_t *tab_manager_get(int index);
/*
* Get the number of open tabs.
*/
int tab_manager_count(void);
/*
* Update the title of the tab at the given index. Called from the
* load-changed signal handler when the page title becomes available.
*/
void tab_manager_set_title(int index, const char *title);
/*
* Get the URL of the tab at the given index (for session save).
* Returns NULL if index is out of range.
*/
const char *tab_manager_get_url(int index);
/*
* Cycle to the next tab (wraps around). Used by Ctrl+Tab / Ctrl+PageDown.
*/
void tab_manager_next(void);
/*
* Cycle to the previous tab (wraps around). Used by Ctrl+Shift+Tab /
* Ctrl+PageUp.
*/
void tab_manager_prev(void);
/*
* Close all tabs except the one at the given index.
*/
void tab_manager_close_others(int index);
/*
* Close all tabs to the right of the given index.
*/
void tab_manager_close_to_right(int index);
/*
* Duplicate the tab at the given index (open a new tab with the same URL).
*/
void tab_manager_duplicate(int index);
/*
* Reload the tab at the given index.
*/
void tab_manager_reload(int index);
/*
* Apply current settings (tab bar position, drag reorder, close buttons).
* Call after settings_change() or at init.
*/
void tab_manager_apply_settings(void);
#ifdef __cplusplus
}
#endif
#endif /* TAB_MANAGER_H */
+2 -2
View File
@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.2"
#define SB_VERSION "v0.0.6"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 2
#define SB_VERSION_PATCH 6
#endif /* SOVEREIGN_BROWSER_VERSION_H */