Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d64bd45f54 | ||
|
|
5c52198d9e | ||
|
|
0160b70dc2 | ||
|
|
40eb1d1a16 |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user