Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a710277931 | ||
|
|
e8e4a06734 | ||
|
|
0a2c6c6d9b | ||
|
|
95a32b5548 | ||
|
|
c7aa7b5c37 | ||
|
|
da7b5eb72c | ||
|
|
2bc5ec7771 | ||
|
|
992b09357a | ||
|
|
24d07946b9 | ||
|
|
e0607fe045 | ||
|
|
ae43decd4d | ||
|
|
5e035bfe6b |
@@ -21,7 +21,7 @@ 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/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c src/process_info.c src/perf_probe.c src/webkit_data.c src/web_context.c
|
||||
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c src/process_info.c src/perf_probe.c src/webkit_data.c src/web_context.c src/site_downloader.c src/local_scheme.c
|
||||
|
||||
# Web files embedded into the binary as C byte arrays.
|
||||
WEB_FILES := $(shell find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) 2>/dev/null)
|
||||
|
||||
@@ -96,6 +96,77 @@ Working browser with a broad feature set:
|
||||
See [`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the
|
||||
friction report from the POC phase.
|
||||
|
||||
## Security restrictions overridden
|
||||
|
||||
This browser intentionally strips the traditional web security model. Trust
|
||||
moves to the **Qube level** (VM isolation), so the browser itself does not
|
||||
enforce same-origin policy, CORS, certificate validation, or storage quotas.
|
||||
Below is the complete inventory of every restriction we've overridden and how.
|
||||
|
||||
### WebKitSettings (per-tab, in [`src/tab_manager.c`](src/tab_manager.c))
|
||||
|
||||
| Setting | Value | Effect |
|
||||
|---------|-------|--------|
|
||||
| `enable_developer_extras` | `TRUE` | Web Inspector / right-click Inspect Element |
|
||||
| `enable_javascript` | `TRUE` | JavaScript enabled |
|
||||
| `javascript_can_open_windows_automatically` | `TRUE` | `window.open()` without user gesture |
|
||||
| `allow_file_access_from_file_urls` | `TRUE` | `file://` pages can fetch/XHR other local files |
|
||||
| `allow_universal_access_from_file_urls` | `TRUE` | `file://` pages bypass same-origin policy entirely |
|
||||
| `allow_modal_dialogs` | `TRUE` | `alert()`, `confirm()`, `prompt()` work |
|
||||
| `hardware_acceleration_policy` | `ALWAYS` | Forces GPU compositing even on software renderers |
|
||||
| `enable_smooth_scrolling` | `TRUE` | Smooth scroll animation |
|
||||
|
||||
### WebKitSecurityManager (per-context, in [`src/web_context.c`](src/web_context.c))
|
||||
|
||||
| Registration | Effect |
|
||||
|-------------|--------|
|
||||
| `register_uri_scheme_as_secure("sovereign")` | `sovereign://` treated as secure origin |
|
||||
| `register_uri_scheme_as_secure("tor")` | `tor://` treated as secure origin |
|
||||
| `register_uri_scheme_as_secure("file")` | `file://` treated as secure origin (normally insecure) |
|
||||
| `register_uri_scheme_as_secure("ws")` | `ws://` treated as secure (allows plain WebSocket from secure pages) |
|
||||
| `register_uri_scheme_as_local("file")` | `file://` treated as local (can load local resources) |
|
||||
| `register_uri_scheme_as_secure("local")` | `local://` treated as secure origin |
|
||||
| `register_uri_scheme_as_local("local")` | `local://` treated as local |
|
||||
| `register_uri_scheme_as_cors_enabled("local")` | `local://` pages can fetch/XHR other local:// URLs without CORS |
|
||||
|
||||
### TLS Policy (in [`src/web_context.c`](src/web_context.c))
|
||||
|
||||
| Setting | Value | Effect |
|
||||
|---------|-------|--------|
|
||||
| `tls_errors_policy` | `IGNORE` | Accepts any TLS certificate, including self-signed/expired |
|
||||
|
||||
### CORS bypass for custom schemes (in [`src/nostr_inject.c`](src/nostr_inject.c))
|
||||
|
||||
The `window.nostr` shim uses **synchronous XMLHttpRequest** to
|
||||
`sovereign://nostr/*` instead of `fetch()`. WebKitGTK enforces CORS on
|
||||
`fetch()` and async XHR even for secure custom schemes, but **synchronous
|
||||
XHR to secure custom schemes bypasses CORS**. This is a WebKitGTK quirk
|
||||
we exploit intentionally.
|
||||
|
||||
### `file://` storage quota bypass via `local://` scheme
|
||||
|
||||
WebKit's WebCore assigns each `file://` URL a **unique opaque origin** with
|
||||
a **0-byte storage quota**, silently breaking `localStorage` and `IndexedDB`
|
||||
on local files. We bypass this with a custom [`local://`](src/local_scheme.c)
|
||||
URI scheme that:
|
||||
|
||||
1. Maps `local:///absolute/path/to/file` to the local filesystem
|
||||
2. Creates a **proper (non-opaque) SecurityOrigin** with the default storage
|
||||
quota (typically 5-10 MB for localStorage, unlimited for IndexedDB)
|
||||
3. Is registered as secure, local, and CORS-enabled
|
||||
|
||||
All `file://` navigations (URL bar, page links, `decide-policy`) are
|
||||
automatically rewritten to `local://` by [`src/tab_manager.c`](src/tab_manager.c).
|
||||
|
||||
### Custom URI schemes registered
|
||||
|
||||
| Scheme | Handler | File |
|
||||
|--------|---------|------|
|
||||
| `sovereign://` | `on_sovereign_scheme` | [`src/nostr_bridge.c`](src/nostr_bridge.c) |
|
||||
| `nostr://` | `on_nostr_scheme` | [`src/nostr_scheme.c`](src/nostr_scheme.c) |
|
||||
| `tor://` | `on_tor_scheme` | [`src/tor_scheme.c`](src/tor_scheme.c) |
|
||||
| `local://` | `on_local_scheme` | [`src/local_scheme.c`](src/local_scheme.c) |
|
||||
|
||||
## Install
|
||||
|
||||
One-command install on Debian 13 (trixie) or similar (x86_64, WebKitGTK 4.1):
|
||||
|
||||
+14
-6
@@ -240,18 +240,26 @@ cmd_start() {
|
||||
fi
|
||||
|
||||
if [[ "$software_rendering" -eq 1 ]]; then
|
||||
echo "[browser] Software rendering detected — disabling WebKit compositor."
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
# On llvmpipe/softpipe, WebKit compositing is still faster than the
|
||||
# non-composited fallback (confirmed by A/B testing on Qubes llvmpipe).
|
||||
# We no longer disable compositing — just log a note.
|
||||
# The DMABUF renderer is still disabled since there's no real GPU.
|
||||
echo "[browser] Software rendering detected (llvmpipe/softpipe) — compositing kept enabled for performance."
|
||||
if [[ -z "${WEBKIT_DISABLE_DMABUF_RENDERER:-}" ]]; then
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
fi
|
||||
# Allow override via pre-set env var for A/B testing.
|
||||
if [[ -z "${WEBKIT_DISABLE_COMPOSITING_MODE:-}" ]]; then
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build first
|
||||
# Build first (use build.sh for proper version embedding)
|
||||
echo "[browser] Building..."
|
||||
if ! make -s 2>/dev/null; then
|
||||
if ! ./build.sh 2>/dev/null; then
|
||||
echo "[browser] Build failed!" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[browser] Build OK."
|
||||
|
||||
# Launch fully detached:
|
||||
# setsid — new session, detached from controlling terminal
|
||||
|
||||
+31
-5
@@ -40,7 +40,9 @@ cleanup_on_exit() {
|
||||
}
|
||||
trap cleanup_on_exit EXIT INT TERM
|
||||
register_cleanup() {
|
||||
[[ -n "$1" ]] && CLEANUP_FILES+=("$1")
|
||||
# Guard so an empty argument (e.g. a failed tarball path) does not cause
|
||||
# this function to return non-zero and abort the script under `set -e`.
|
||||
[[ -n "$1" ]] && CLEANUP_FILES+=("$1") || true
|
||||
}
|
||||
|
||||
# --- Config -------------------------------------------------------------
|
||||
@@ -280,7 +282,19 @@ build_release_binary() {
|
||||
create_source_tarball() {
|
||||
local tarball_name="${BIN_NAME}-${NEW_VERSION#v}.tar.gz"
|
||||
|
||||
if tar -czf "$tarball_name" \
|
||||
# Write the tarball to a temp path OUTSIDE the archived tree (.) so that
|
||||
# creating it does not change the directory while tar is reading it
|
||||
# (which makes tar emit "file changed as we read it" and exit 1, which
|
||||
# under `set -e` would abort the whole release flow). Move it into place
|
||||
# only after tar finishes.
|
||||
local tmp_tarball
|
||||
tmp_tarball=$(mktemp -t "${tarball_name}.XXXXXX") || return 1
|
||||
|
||||
# tar exits 1 for "file changed as we read it", which is benign here
|
||||
# because the only thing changing is the tarball we are writing (now in
|
||||
# /tmp). Accept exit codes 0 and 1 as success; anything else is fatal.
|
||||
set +e
|
||||
tar -czf "$tmp_tarball" \
|
||||
--exclude="$BIN_NAME" \
|
||||
--exclude='.git*' \
|
||||
--exclude='*.log' \
|
||||
@@ -290,11 +304,23 @@ create_source_tarball() {
|
||||
--exclude='tests/local-site/assets/*.m4a' \
|
||||
--exclude='tests/local-site/assets/*.webm' \
|
||||
--exclude='tests/local-site/assets/*.ogg' \
|
||||
. > /dev/null 2>&1; then
|
||||
echo "$tarball_name"
|
||||
else
|
||||
. > /dev/null 2>&1
|
||||
local tar_rc=$?
|
||||
set -e
|
||||
|
||||
if [[ $tar_rc -ne 0 && $tar_rc -ne 1 ]]; then
|
||||
rm -f "$tmp_tarball"
|
||||
print_error "tar failed with exit code $tar_rc"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! mv "$tmp_tarball" "$tarball_name" 2>/dev/null; then
|
||||
rm -f "$tmp_tarball"
|
||||
print_error "Failed to move tarball into place: $tarball_name"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$tarball_name"
|
||||
}
|
||||
|
||||
create_gitea_release() {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Debugging Responsiveness — sovereign_browser
|
||||
|
||||
## Symptoms (from user)
|
||||
1. **Hover lag**: a page that highlights buttons on mouseover responds ~2× slower than in Brave.
|
||||
2. **Load progress invisible until complete**: page loads "take a while" and show no progress until the download finishes.
|
||||
|
||||
## Confirmed root-cause candidates (from code review)
|
||||
|
||||
### A. Hover/scroll lag — WebKitGTK rendering pipeline
|
||||
[`src/tab_manager.c:2912`](src/tab_manager.c:2912) configures `WebKitSettings` but only sets:
|
||||
- `enable_developer_extras`, `enable_javascript`, `javascript_can_open_windows_automatically`
|
||||
- `allow_file_access_from_file_urls`, `allow_universal_access_from_file_urls`, `allow_modal_dialogs`
|
||||
|
||||
**Not set** (left at WebKitGTK defaults):
|
||||
- `enable_accelerated_2d_canvas` (default FALSE on many builds)
|
||||
- `enable_smooth_scrolling` (default FALSE)
|
||||
- compositing mode — controlled by env var `WEBKIT_DISABLE_COMPOSITING_MODE=1`, which if set forces non-accelerated compositing
|
||||
|
||||
WebKitGTK ships software-rendered by default on many distros; Chromium/Brave use GPU compositing + Skia by default. This alone explains ~2× paint lag on hover effects (each `:hover` triggers a repaint; software repaint is much slower).
|
||||
|
||||
### B. No load-progress UI
|
||||
[`on_load_changed`](src/tab_manager.c:1635) handles `LOAD_STARTED` (→ stop icon), `LOAD_COMMITTED` (→ URL bar/title), `LOAD_FINISHED`/`LOAD_FAILED` (→ reload icon). There is **no** `notify::estimated-load-progress` signal handler and **no** progress bar/spinner in the tab strip. So between STARTED and FINISHED the user sees only a static stop icon — "nothing happens until done."
|
||||
|
||||
## Debugging plan (ordered: cheap → invasive)
|
||||
|
||||
### Step 1 — Confirm the rendering hypothesis with env vars (no code change)
|
||||
Run the browser with GPU compositing forced on and compare hover responsiveness:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE=0 ./browser.sh start --login-method generate --url <hover-test-page>
|
||||
# vs
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE=1 ./browser.sh start --login-method generate --url <hover-test-page>
|
||||
```
|
||||
|
||||
Also try `WEBKIT_FORCE_SANDBOX=0` only if the above is inconclusive (rules out sandbox-overhead noise). If forcing compositing on closes the gap with Brave, the fix is enabling accelerated settings + ensuring the compositor is active.
|
||||
|
||||
### Step 2 — Use the existing perf-probe to quantify
|
||||
The browser already ships [`www/js/perf-probe.js`](www/js/perf-probe.js) (long-task observer, rAF FPS, heartbeat main-thread-blockage fallback for WebKitGTK). Enable it (Settings → perf_probe_enabled, or it's gated by `settings_get()->perf_probe_enabled` at [`tab_manager.c:1337`](src/tab_manager.c:1337)) and load the hover-test page. Compare:
|
||||
- `cpu_busy_percent` (heartbeat blockage) — high = main thread is being starved
|
||||
- `fps` — low fps during hover = paint-bound, points at rendering pipeline
|
||||
- `active_timer_count` — high = page is timer-heavy (not our bug)
|
||||
|
||||
Call the MCP `processes.tabs` tool ([`agent_mcp.c:583`](src/agent_mcp.c:583)) to read the probe values per tab while hovering.
|
||||
|
||||
### Step 3 — System-level profiling to rule out the C/GTK main thread
|
||||
Even if rendering is the cause, confirm the GTK main thread isn't also blocked. Two cheap checks:
|
||||
|
||||
```bash
|
||||
# a) Is the UI process CPU-bound during hover?
|
||||
top -p $(pgrep -f sovereign_browser) -H -d 0.5
|
||||
|
||||
# b) perf record the UI process for 10s while hovering, then look at hotspots
|
||||
sudo perf record -p $(pgrep -f sovereign_browser) -g -- sleep 10
|
||||
sudo perf report --no-children
|
||||
```
|
||||
|
||||
Look for time in `webkit*`/`WebCore*`/`cairo`/`GLib` dispatchers. If time is dominated by `g_main_context_*` or our own handlers, the C layer is implicated; if it's `WebCore::paint`/`cairo`, it's the renderer.
|
||||
|
||||
### Step 4 — Check for main-thread blocking from sync JS eval
|
||||
[`agent_js_eval_sync`](src/agent_snapshot.c:80) runs a nested `g_main_loop` on the default context. The agent loop ([`agent_loop.c:333`](src/agent_loop.c:333)) calls this from a background thread while pumping the main loop. If an agent/MCP tool is running during normal browsing, this can re-dispatch unrelated sources and cause UI hitches. Verify by checking whether sluggishness only occurs while an agent loop is active (watch `[agent-loop] iter` log lines). If correlated, the fix is to route sync JS eval through a dedicated `GMainContext` instead of the default one.
|
||||
|
||||
### Step 5 — Fix candidates (after diagnosis confirms which)
|
||||
|
||||
**Rendering (likely):**
|
||||
- In [`src/tab_manager.c:2912`](src/tab_manager.c:2912) add:
|
||||
```c
|
||||
webkit_settings_set_enable_accelerated_2d_canvas(settings, TRUE);
|
||||
webkit_settings_set_enable_smooth_scrolling(settings, TRUE);
|
||||
```
|
||||
and ensure `WEBKIT_DISABLE_COMPOSITING_MODE` is **not** set in the launch environment (check `browser.sh`).
|
||||
- Verify GPU is actually being used: `WEBKIT_DEBUG=compositing` or check `glxinfo`/EGL logs.
|
||||
|
||||
**Load progress (confirmed gap):**
|
||||
- Add a `notify::estimated-load-progress` handler on each webview that drives a thin progress bar (or the tab label background) between `LOAD_STARTED` and `LOAD_FINISHED`. WebKitGTK emits this continuously during fetch; wiring it gives the missing "loading" feedback. Add the handler next to the `load-changed` connect at [`src/tab_manager.c:3136`](src/tab_manager.c:3136).
|
||||
|
||||
## Recommended order
|
||||
1. Step 1 (env-var A/B test) — fastest signal, no rebuild.
|
||||
2. Step 2 (perf-probe) — quantifies, uses existing infra.
|
||||
3. Step 3 (perf/top) — rules out C-layer blocking.
|
||||
4. Step 4 — only if sluggishness correlates with agent activity.
|
||||
5. Step 5 — implement the fix(es) confirmed by 1–4.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Plan: Download Website for Offline Viewing
|
||||
|
||||
## Goal
|
||||
|
||||
Add a "Download Website" option to the tab right-click context menu. It crawls
|
||||
the current site (same-origin, bounded by a page cap), saves all pages and
|
||||
assets to a per-profile directory, rewrites links to relative paths, and opens
|
||||
the offline copy in a new tab via `file://`.
|
||||
|
||||
## User decisions
|
||||
|
||||
- **Depth:** Entire site — follow all same-origin links, no depth limit, bounded
|
||||
by a max-page-count safety cap (default 500).
|
||||
- **Storage:** Per-profile:
|
||||
`~/.sovereign_browser/profiles/<pubkey>/offline_sites/<hostname>/`
|
||||
(falls back to `~/.sovereign_browser/offline_sites/<hostname>/` when not
|
||||
logged in).
|
||||
- **Feedback:** Small modal progress dialog with pages/files counts, a progress
|
||||
bar, and a Cancel button.
|
||||
|
||||
## Trigger
|
||||
|
||||
Right-click on a tab → "Download Website" menu item. Added to **both** tab
|
||||
context menus in [`src/tab_manager.c`](../src/tab_manager.c):
|
||||
|
||||
- `on_tab_label_button_press` menu (line ~828)
|
||||
- `on_notebook_button_press` menu (line ~751)
|
||||
|
||||
The callback reads the right-clicked tab's `current_url` and is only active for
|
||||
`http://` / `https://` URLs (greyed out / error dialog otherwise).
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Right-click tab] --> B[Download Website menu item]
|
||||
B --> C{URL is http/https?}
|
||||
C -- No --> D[Show error, abort]
|
||||
C -- Yes --> E[Show progress dialog]
|
||||
E --> F[Start worker thread BFS crawl]
|
||||
F --> G[Fetch page via libsoup]
|
||||
G --> H[Save HTML to offline_sites/host/path]
|
||||
H --> I[Extract same-origin links + assets]
|
||||
I --> J{Cancel clicked?}
|
||||
J -- Yes --> K[Stop crawl]
|
||||
J -- No --> L{More URLs and under cap?}
|
||||
L -- Yes --> G
|
||||
L -- No --> M[Close dialog]
|
||||
K --> M
|
||||
M --> N[Open file://offline_sites/host/index.html in new tab]
|
||||
```
|
||||
|
||||
### New files
|
||||
|
||||
#### `src/site_downloader.h`
|
||||
|
||||
Public API (self-contained, called from the tab context menu):
|
||||
|
||||
```c
|
||||
/* Start downloading the website at start_url. Shows a modal progress
|
||||
* dialog parented to `parent`. On completion (or cancel) opens the
|
||||
* offline copy in a new tab. No-op for non-http(s) URLs. */
|
||||
void site_downloader_start(const char *start_url, GtkWindow *parent);
|
||||
```
|
||||
|
||||
#### `src/site_downloader.c`
|
||||
|
||||
Implementation:
|
||||
|
||||
1. **Progress dialog** — `GtkDialog` with:
|
||||
- `GtkLabel` showing hostname + "Pages: N / Files: M"
|
||||
- `GtkProgressBar` (pulse mode; or fraction = pages / cap)
|
||||
- Cancel `GtkButton` → sets `g_atomic_int_set(&cancel, 1)`
|
||||
|
||||
2. **Worker thread** — `g_thread_new("site-dl", ...)` running a BFS crawl:
|
||||
- `SoupSession` (libsoup-3.0; pattern from
|
||||
[`src/agent_llm.c`](../src/agent_llm.c:285) and
|
||||
[`src/search.c`](../src/search.c:158)).
|
||||
- Queue (GQueue) of URLs to fetch + a `GHashTable` visited-set.
|
||||
- Politeness delay between requests (default 100ms).
|
||||
|
||||
3. **Fetch + save** — for each URL:
|
||||
- `soup_session_send_and_read()` (sync; we're on a worker thread).
|
||||
- Map URL → local path: mirror the URL path under the site root;
|
||||
directory-style URLs → `index.html`; sanitize illegal filename chars.
|
||||
- Write bytes to disk (`g_file_set_contents` for text, manual write for
|
||||
binary).
|
||||
- For HTML responses: extract links, rewrite them to relative paths, then
|
||||
save the rewritten HTML.
|
||||
|
||||
4. **Link extraction & rewriting** (no HTML parser dep in the project, so use
|
||||
regex over `href=`, `src=`, `srcset=`, `poster=`, and CSS `url(...)`):
|
||||
- Resolve each link against the page URL (`g_uri_resolve_relative`).
|
||||
- **Same-origin filter:** keep only links whose host matches the start
|
||||
URL's host (via `g_uri_parse`, pattern from
|
||||
[`src/tor_scheme.c`](../src/tor_scheme.c:74)). Enqueue same-origin HTML
|
||||
pages; download all assets (CSS/JS/images/fonts/etc.) regardless of
|
||||
origin but don't crawl cross-origin pages.
|
||||
- Rewrite kept links to relative file paths so the offline copy works from
|
||||
`file://`.
|
||||
|
||||
5. **Completion** — `g_idle_add` a callback on the main thread that:
|
||||
- Destroys the progress dialog.
|
||||
- Opens `file://<offline_dir>/index.html` in a new tab via
|
||||
`tab_manager_new_tab()`.
|
||||
|
||||
### Modified files
|
||||
|
||||
- [`src/tab_manager.c`](../src/tab_manager.c) — add "Download Website" item to
|
||||
both context menus; new callback `on_tab_download_website` calling
|
||||
`site_downloader_start(tab->current_url, g_window)`.
|
||||
- [`Makefile`](../Makefile) — add `src/site_downloader.c` to `SRC`.
|
||||
- [`src/settings.h`](../src/settings.h) / [`src/settings.c`](../src/settings.c)
|
||||
— add to `browser_settings_t`:
|
||||
- `offline_site_max_pages` (default 500)
|
||||
- `offline_site_request_delay_ms` (default 100)
|
||||
- load/save in `settings_load()` / `settings_save()`.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Non-http(s) URLs → menu item shows an error dialog, no crawl.
|
||||
- Not logged in → fall back to global `~/.sovereign_browser/offline_sites/`.
|
||||
- Redirects → follow libsoup redirects; use the final URL for same-origin
|
||||
checks and path mapping.
|
||||
- Binary assets → write raw bytes, never parse/rewrite.
|
||||
- Duplicate URLs → visited-set prevents re-fetching.
|
||||
- Very large sites → max-page cap stops the crawl; dialog shows "cap reached".
|
||||
- Filename sanitization → replace illegal chars (`/`, `?`, `:`, etc.) with `_`.
|
||||
- Cancel → worker checks the cancel flag between requests; partial downloads
|
||||
are kept and the new tab opens to whatever was fetched.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Add settings fields + load/save.
|
||||
2. Create `site_downloader.h` / `site_downloader.c` skeleton: progress dialog
|
||||
+ thread stub + completion callback.
|
||||
3. Implement libsoup fetch + file write + URL→path mapping.
|
||||
4. Implement HTML link extraction, same-origin filter, link rewriting.
|
||||
5. Implement BFS crawl loop: visited-set, cap, cancel flag, progress updates.
|
||||
6. Implement completion callback: open new tab with `file://` URL.
|
||||
7. Wire "Download Website" into both tab context menus in `tab_manager.c`.
|
||||
8. Add `site_downloader.c` to `Makefile`.
|
||||
9. Build (`make`) and test with `tests/local-site/` served via
|
||||
`python3 -m http.server`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Serve `tests/local-site/` with `python3 -m http.server 8000` from the
|
||||
`tests/` directory.
|
||||
- Open `http://localhost:8000/local-site/index.html` in the browser.
|
||||
- Right-click the tab → Download Website.
|
||||
- Verify the progress dialog appears, files are saved under
|
||||
`~/.sovereign_browser/profiles/<pubkey>/offline_sites/localhost/`, and a new
|
||||
tab opens the offline `index.html` with working relative links and assets.
|
||||
- Test Cancel mid-download.
|
||||
- Test a non-http URL (e.g. `sovereign://profile`) → error dialog.
|
||||
@@ -131,6 +131,8 @@ A fixed enum of browser actions the user can bind. Each has: id, display label,
|
||||
| Action ID | Label | Default binding |
|
||||
|----------------------|------------------------|---------------------------|
|
||||
| `new_tab` | New tab | `<Control>t` |
|
||||
| `new_window` | New window | `<Control>n` |
|
||||
| `open_file` | Open file | `<Control>o` |
|
||||
| `close_tab` | Close tab | `<Control>w` |
|
||||
| `focus_url` | Focus URL bar | `<Control>l` |
|
||||
| `next_tab` | Next tab | `<Control>Tab` |
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Security Restrictions Overridden & `file://` Storage Quota Fix
|
||||
|
||||
## Part 1 — Inventory of all security restrictions we've overridden
|
||||
|
||||
### WebKitSettings (per-tab, in [`src/tab_manager.c:2955`](src/tab_manager.c:2955))
|
||||
|
||||
| Setting | Value | Effect |
|
||||
|---------|-------|--------|
|
||||
| `enable_developer_extras` | `TRUE` | Enables Web Inspector / right-click Inspect Element |
|
||||
| `enable_javascript` | `TRUE` | JavaScript enabled (default, but explicit) |
|
||||
| `javascript_can_open_windows_automatically` | `TRUE` | Allows `window.open()` without user gesture |
|
||||
| `allow_file_access_from_file_urls` | `TRUE` | `file://` pages can fetch/XHR other local files (normally blocked) |
|
||||
| `allow_universal_access_from_file_urls` | `TRUE` | `file://` pages bypass same-origin policy entirely |
|
||||
| `allow_modal_dialogs` | `TRUE` | `alert()`, `confirm()`, `prompt()` work |
|
||||
| `hardware_acceleration_policy` | `ALWAYS` | Forces GPU compositing even on software renderers |
|
||||
|
||||
### WebKitSecurityManager (per-context, in [`src/web_context.c:207`](src/web_context.c:207))
|
||||
|
||||
| Registration | Effect |
|
||||
|-------------|--------|
|
||||
| `register_uri_scheme_as_secure("sovereign")` | `sovereign://` treated as secure origin (no mixed-content warnings) |
|
||||
| `register_uri_scheme_as_secure("tor")` | `tor://` treated as secure origin |
|
||||
| `register_uri_scheme_as_secure("file")` | `file://` treated as secure origin (normally insecure) |
|
||||
| `register_uri_scheme_as_secure("ws")` | `ws://` treated as secure (allows plain WebSocket from secure pages) |
|
||||
| `register_uri_scheme_as_local("file")` | `file://` treated as local (can load local resources) |
|
||||
|
||||
### TLS Policy (in [`src/web_context.c:218`](src/web_context.c:218))
|
||||
|
||||
| Setting | Value | Effect |
|
||||
|---------|-------|--------|
|
||||
| `tls_errors_policy` | `IGNORE` | Accepts any TLS certificate, including self-signed/expired |
|
||||
|
||||
### CORS bypass (in [`src/nostr_inject.c:38`](src/nostr_inject.c:38))
|
||||
|
||||
The `window.nostr` shim uses **synchronous XMLHttpRequest** to `sovereign://nostr/*` instead of `fetch()`. WebKitGTK enforces CORS on `fetch()` and async XHR even for secure custom schemes, but **synchronous XHR to secure custom schemes bypasses CORS**. This is a WebKitGTK quirk we exploit intentionally.
|
||||
|
||||
### Custom URI schemes registered
|
||||
|
||||
| Scheme | Handler | File |
|
||||
|--------|---------|------|
|
||||
| `sovereign://` | `on_sovereign_scheme` | [`src/nostr_bridge.c:4588`](src/nostr_bridge.c:4588) |
|
||||
| `nostr://` | `on_nostr_scheme` | [`src/nostr_scheme.c:410`](src/nostr_scheme.c:410) |
|
||||
| `tor://` | `on_tor_scheme` | [`src/tor_scheme.c:289`](src/tor_scheme.c:289) |
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — The `file://` Storage Quota Problem
|
||||
|
||||
### Root cause
|
||||
|
||||
WebKit's WebCore layer assigns each `file://` URL a **unique opaque origin** with a **0-byte storage quota**. This is hardcoded in `WebCore::SecurityOrigin::createForFilePath()` and `WebCore::StorageQuotaManager`. Even though we've set `allow_file_access_from_file_urls=TRUE` and `allow_universal_access_from_file_urls=TRUE`, those settings affect **network access** (fetch/XHR/CORS), not **storage quotas**.
|
||||
|
||||
The result:
|
||||
- `localStorage.setItem('key', 'value')` silently fails or throws `QuotaExceededError`
|
||||
- `IndexedDB.open('db')` fails with `QuotaExceededError`
|
||||
- `sessionStorage` works (it's in-memory per-tab, not quota-managed)
|
||||
|
||||
### No public WebKitGTK API to fix this
|
||||
|
||||
The WebKitGTK headers expose:
|
||||
- `webkit_settings_set_enable_html5_local_storage()` — enables/disables localStorage entirely (default: TRUE)
|
||||
- `webkit_website_data_manager_get_local_storage_directory()` — returns the on-disk path (deprecated)
|
||||
|
||||
There is **no public API** to set the storage quota for a given origin or to change how `file://` origins are classified.
|
||||
|
||||
### Solution: Custom `local://` URI scheme
|
||||
|
||||
The cleanest approach is to create a custom URI scheme that:
|
||||
1. Has a **proper origin** (not opaque like `file://`)
|
||||
2. Gets **unlimited storage quota** (the default for http/https origins)
|
||||
3. Serves the same file content
|
||||
|
||||
#### Implementation plan
|
||||
|
||||
**Step 1 — Register a new `local://` scheme** in [`src/web_context.c`](src/web_context.c):
|
||||
|
||||
```c
|
||||
// In configure_context():
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "local");
|
||||
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "local");
|
||||
webkit_security_manager_register_uri_scheme_as_cors_enabled(sec_mgr, "local");
|
||||
```
|
||||
|
||||
**Step 2 — Create a handler** that maps `local:///path/to/file` to the local filesystem:
|
||||
|
||||
```c
|
||||
// New file: src/local_scheme.c
|
||||
static void on_local_scheme(WebKitURISchemeRequest *request) {
|
||||
const char *uri = webkit_uri_scheme_request_get_uri(request);
|
||||
// Strip "local://" prefix to get the file path
|
||||
const char *path = uri + 8; // "local://" = 8 chars
|
||||
// Read the file and serve it
|
||||
char *content;
|
||||
gsize length;
|
||||
if (g_file_get_contents(path, &content, &length, NULL)) {
|
||||
const char *mime = g_content_type_guess(path, NULL, 0, NULL);
|
||||
webkit_uri_scheme_request_finish(request, content, length, mime);
|
||||
g_free(content);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3 — Intercept `file://` navigation** in the `decide-policy` handler ([`src/tab_manager.c`](src/tab_manager.c)) and rewrite to `local://`:
|
||||
|
||||
```c
|
||||
// In on_decide_policy(), when navigation policy is requested:
|
||||
if (g_str_has_prefix(uri, "file://")) {
|
||||
// Rewrite file:///home/user/foo.html -> local:///home/user/foo.html
|
||||
char *local_uri = g_strconcat("local", uri + 4, NULL); // "file" -> "local"
|
||||
webkit_uri_request_set_uri(request, local_uri);
|
||||
g_free(local_uri);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 — Also rewrite `file://` URLs in the URL bar** when the user presses Enter, so typed paths get the `local://` scheme.
|
||||
|
||||
### Why this works
|
||||
|
||||
When a page is loaded from `local:///home/user/site/index.html`, WebKit creates a `SecurityOrigin` with scheme `local`, host `localhost` (or empty), and port `0`. This is a **valid, non-opaque origin** that gets the **default storage quota** (typically 5-10 MB for localStorage, unlimited for IndexedDB). The same origin is shared by all `local://` URLs, so pages can share localStorage.
|
||||
|
||||
### Alternative considered: Local HTTP server
|
||||
|
||||
We could run a minimal HTTP server on `localhost` using libsoup and serve files from `http://localhost:PORT/path`. This would also give proper origins with full storage. However, it adds:
|
||||
- Port management (conflict with other services)
|
||||
- Startup latency (server must be ready before pages load)
|
||||
- Complexity (MIME type handling, directory listing, etc.)
|
||||
|
||||
The custom scheme approach is simpler and has no port/startup issues.
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`src/web_context.c:207`](src/web_context.c:207) | Register `local://` as secure, local, and CORS-enabled |
|
||||
| `src/local_scheme.c` (new) | Custom scheme handler that reads files from disk |
|
||||
| `src/local_scheme.h` (new) | Header for the handler |
|
||||
| [`src/tab_manager.c`](src/tab_manager.c) | Intercept `file://` navigation in `decide-policy` and rewrite to `local://` |
|
||||
| [`src/main.c`](src/main.c) | Register the `local://` scheme during startup |
|
||||
| `Makefile` | Add `local_scheme.o` to the build |
|
||||
@@ -0,0 +1,177 @@
|
||||
# Plan: Keyboard Shortcut to Toggle All Menu Bars (per-window)
|
||||
|
||||
## Goal
|
||||
|
||||
Add a configurable keyboard shortcut that shows/hides all the browser's
|
||||
"menu bars" — the per-tab toolbars, bookmark bars, and the tab strip —
|
||||
**independently per window**. Pressing the shortcut in one window hides
|
||||
that window's chrome only; other windows are unaffected. This mirrors how
|
||||
the agent sidebar already works (per-window `sidebar_visible` on
|
||||
[`window_state_t`](src/tab_manager.c:87)).
|
||||
|
||||
In sovereign_browser there is no traditional `GtkMenuBar`; each tab's chrome
|
||||
is two widgets packed at the top of the tab page
|
||||
([`tab->page`](src/tab_manager.c:2966)):
|
||||
|
||||
1. **`#main-toolbar`** — the horizontal GtkBox holding the hamburger menu
|
||||
button, refresh/stop, back, forward, URL entry, and bookmark button
|
||||
(built in [`tab_create()`](src/tab_manager.c:2973)).
|
||||
2. **`#bookmark-bar`** — the bookmarks toolbar below the URL toolbar
|
||||
([`tab->bookmark_bar`](src/tab_manager.c:3089)).
|
||||
|
||||
Plus the tab strip itself, which is the `GtkNotebook` header, controlled
|
||||
via `gtk_notebook_set_show_tabs()`.
|
||||
|
||||
Hiding all three gives the webview the entire window (a focus / kiosk
|
||||
mode). Pressing the shortcut again restores them.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. New shortcut action
|
||||
|
||||
Add `SHORTCUT_TOGGLE_TOOLBARS` to the enum in [`shortcuts.h`](src/shortcuts.h:29)
|
||||
(before `SHORTCUT_COUNT`) and a matching entry in the `g_registry` table in
|
||||
[`shortcuts.c`](src/shortcuts.c:24):
|
||||
|
||||
| Field | Value |
|
||||
|---------|----------------------------------------------------|
|
||||
| id | `toggle_toolbars` |
|
||||
| label | `Toggle menu bars` |
|
||||
| desc | `Show or hide the tab strip, toolbars, and bookmark bars in the active window` |
|
||||
| dflt | `<Control><Shift>m` |
|
||||
|
||||
Default `<Control><Shift>m` ("m" for menu) is free and mnemonic. It is a
|
||||
browser-level shortcut (not consumed by WebKit for web content), so it
|
||||
reaches the window-level `on_key_press` handler reliably.
|
||||
|
||||
Because the settings page enumerates actions via
|
||||
[`sovereign://settings/config`](src/nostr_bridge.c:2994) (which loops over
|
||||
`SHORTCUT_COUNT` calling [`shortcuts_meta()`](src/shortcuts.c:232) /
|
||||
[`shortcuts_get()`](src/shortcuts.c:202)), the new action appears
|
||||
automatically in the Keyboard Shortcuts section of
|
||||
[`www/settings.html`](www/settings.html:74) with no JS changes. NIP-78 sync
|
||||
also picks it up for free via [`shortcuts_serialize()`](src/shortcuts.c:247).
|
||||
|
||||
### 2. Track toolbar widgets on each tab
|
||||
|
||||
The `#main-toolbar` GtkBox is currently a local variable in
|
||||
[`tab_create()`](src/tab_manager.c:2973) and is not stored on
|
||||
[`tab_info_t`](src/tab_manager.h:23). Add a field:
|
||||
|
||||
```c
|
||||
GtkWidget *toolbar; /* #main-toolbar (hamburger + nav + URL) */
|
||||
```
|
||||
|
||||
to `tab_info_t` in [`tab_manager.h`](src/tab_manager.h:23), and assign
|
||||
`tab->toolbar = toolbar;` in [`tab_create()`](src/tab_manager.c:2973).
|
||||
`tab->bookmark_bar` already exists.
|
||||
|
||||
### 3. Per-window visibility flag
|
||||
|
||||
Add a field to [`window_state_t`](src/tab_manager.c:87) in
|
||||
[`tab_manager.c`](src/tab_manager.c):
|
||||
|
||||
```c
|
||||
gboolean toolbars_visible; /* per-window menu-bar visibility */
|
||||
```
|
||||
|
||||
Initialize it to `TRUE` for `g_main_window` (in
|
||||
[`tab_manager_init`](src/tab_manager.c:3598), near where
|
||||
`sidebar_visible = FALSE` is set at line 3643) and for each aux window
|
||||
created in [`tab_manager_new_window`](src/tab_manager.c:1262) (near line
|
||||
1376 where `sidebar_visible = FALSE` is set).
|
||||
|
||||
### 4. Toggle function (active window only)
|
||||
|
||||
Add a public function in [`tab_manager.c`](src/tab_manager.c):
|
||||
|
||||
```c
|
||||
void tab_manager_toggle_toolbars(void);
|
||||
```
|
||||
|
||||
Implementation — mirrors [`tab_manager_toggle_sidebar()`](src/tab_manager.c:4143):
|
||||
|
||||
1. `window_state_t *ws = get_active_window_state();` — resolves the focused
|
||||
window (main or aux), exactly like the sidebar toggle does.
|
||||
2. Flip `ws->toolbars_visible`.
|
||||
3. `gtk_notebook_set_show_tabs(GTK_NOTEBOOK(ws->notebook), ws->toolbars_visible);`
|
||||
— hides/shows the tab strip for this window's notebook only.
|
||||
4. Iterate `g_tabs[0..g_tab_count)` and, for each tab whose **page belongs
|
||||
to this window's notebook** (use [`tab_find_notebook(tab->page)`](src/tab_manager.c:2660)
|
||||
and compare to `ws->notebook`), call
|
||||
`gtk_widget_set_visible(tab->toolbar, ws->toolbars_visible)` and
|
||||
`gtk_widget_set_visible(tab->bookmark_bar, ws->toolbars_visible)`.
|
||||
|
||||
This affects only the active window's tabs and tab strip; other windows
|
||||
keep their own `toolbars_visible` state.
|
||||
|
||||
### 5. Respect hidden state for new tabs
|
||||
|
||||
In [`tab_create()`](src/tab_manager.c:2973), after building `toolbar` and
|
||||
`tab->bookmark_bar`, look up the notebook the tab is being added to (the
|
||||
target notebook — `g_active_notebook` / `get_effective_notebook()` at tab
|
||||
creation time, or `tab_find_notebook(tab->page)` after packing) and its
|
||||
`window_state_t` via [`window_state_for_notebook()`](src/tab_manager.c:4051),
|
||||
then apply that window's flag:
|
||||
|
||||
```c
|
||||
window_state_t *ws = window_state_for_notebook(<target notebook>);
|
||||
gboolean vis = ws ? ws->toolbars_visible : TRUE;
|
||||
gtk_widget_set_visible(toolbar, vis);
|
||||
gtk_widget_set_visible(tab->bookmark_bar, vis);
|
||||
```
|
||||
|
||||
So a tab opened in a hidden-chrome window is also hidden, while a tab
|
||||
opened in a visible-chrome window stays visible. The tab strip visibility
|
||||
is per-notebook via `gtk_notebook_set_show_tabs()`, so no extra work is
|
||||
needed there.
|
||||
|
||||
### 6. Dispatch the shortcut
|
||||
|
||||
Add a case to the switch in [`on_key_press`](src/main.c:611) in
|
||||
[`main.c`](src/main.c):
|
||||
|
||||
```c
|
||||
case SHORTCUT_TOGGLE_TOOLBARS:
|
||||
tab_manager_toggle_toolbars();
|
||||
return TRUE;
|
||||
```
|
||||
|
||||
The key-press handler is window-level, so `get_active_window_state()`
|
||||
inside the toggle resolves to the window that had focus when the key was
|
||||
pressed — giving the per-window behavior.
|
||||
|
||||
### 7. Expose in tab_manager.h
|
||||
|
||||
Add the prototype for `tab_manager_toggle_toolbars(void)` near the other
|
||||
toggle prototypes ([`tab_manager_toggle_inspector`](src/tab_manager.h),
|
||||
[`tab_manager_toggle_sidebar`](src/tab_manager.h)).
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---------------------|---------------------------------------------------------------|
|
||||
| [`src/shortcuts.h`](src/shortcuts.h:29) | Add `SHORTCUT_TOGGLE_TOOLBARS` to enum |
|
||||
| [`src/shortcuts.c`](src/shortcuts.c:24) | Add registry entry (id/label/desc/dflt) |
|
||||
| [`src/tab_manager.h`](src/tab_manager.h:23) | Add `GtkWidget *toolbar` field + `tab_manager_toggle_toolbars` prototype |
|
||||
| [`src/tab_manager.c`](src/tab_manager.c:87) | Add `toolbars_visible` to `window_state_t`; init to TRUE for main + aux windows |
|
||||
| [`src/tab_manager.c`](src/tab_manager.c:2973) | Store `tab->toolbar`; apply window's `toolbars_visible` on creation; implement `tab_manager_toggle_toolbars()` (active window only: tab strip + that window's tabs' toolbars/bookmark bars) |
|
||||
| [`src/main.c`](src/main.c:611) | Add `case SHORTCUT_TOGGLE_TOOLBARS` dispatch |
|
||||
|
||||
No changes needed in `www/` (settings page auto-discovers the action) or in
|
||||
`settings_sync.c` (NIP-78 sync already serializes all actions).
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make` — builds clean.
|
||||
2. `./browser.sh restart --login-method generate --url https://example.com`
|
||||
3. Press `Ctrl+Shift+M` → in the focused window, toolbars + bookmark bar +
|
||||
tab strip disappear; the webview fills the entire window. Press again →
|
||||
they reappear.
|
||||
4. Open a second window (`Ctrl+N`). Hide chrome in window A with
|
||||
`Ctrl+Shift+M`; window B's chrome stays visible. Hide B independently.
|
||||
5. With chrome hidden in a window, `Ctrl+T` opens a new tab in that window
|
||||
whose toolbars are also hidden; the tab strip stays hidden too.
|
||||
6. Open `sovereign://settings` → Keyboard Shortcuts section lists
|
||||
"Toggle menu bars" with `Ctrl+Shift+M`; rebind it and confirm the new
|
||||
binding works.
|
||||
+2
-2
@@ -230,8 +230,8 @@ const mcp_tool_def_t tool_defs[] = {
|
||||
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
|
||||
|
||||
{"click_at",
|
||||
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation.",
|
||||
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}},\"required\":[\"x\",\"y\"]}"},
|
||||
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation. Optional 'button': 1=left (default), 2=middle, 3=right (opens the native context menu).",
|
||||
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"},\"button\":{\"type\":\"integer\",\"enum\":[1,2,3],\"default\":1}},\"required\":[\"x\",\"y\"]}"},
|
||||
|
||||
{"fill",
|
||||
"Clear an input and fill it with a value.",
|
||||
|
||||
+32
-12
@@ -12,10 +12,31 @@
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ── Synchronous JS evaluation ─────────────────────────────────────── *
|
||||
* WebKitGTK's evaluate_javascript is async. We use a nested GMainLoop
|
||||
* on the default context to wait for the result. The key is to acquire
|
||||
* the context before creating the loop so that the WebKit IPC sources
|
||||
* are properly dispatched.
|
||||
* WebKitGTK's evaluate_javascript is async. We run a nested GMainLoop
|
||||
* on the default main context until the JS callback (or a timeout)
|
||||
* quits it.
|
||||
*
|
||||
* This function is routinely called from within a Soup server source
|
||||
* callback (the MCP HTTP handler) that is already dispatching on the
|
||||
* default main context. Running a nested GMainLoop on the same context
|
||||
* is the standard GTK modal pattern (gtk_dialog_run does the same) and
|
||||
* is safe: g_main_loop_run() manages context ownership internally for
|
||||
* the nested run, and the outer loop's dispatch is suspended until the
|
||||
* inner loop quits.
|
||||
*
|
||||
* The PREVIOUS code additionally called g_main_context_acquire()/
|
||||
* g_main_context_release() around the loop. That was the bug: the
|
||||
* default context is already owned by the outer dispatch, so acquire()
|
||||
* fails silently (owner_count is NOT incremented), yet release() then
|
||||
* decrements it unconditionally — producing
|
||||
* "g_main_context_release_unlocked: assertion 'context->owner_count > 0'"
|
||||
* and, combined with the recursive check, a segfault. The fix is simply
|
||||
* to drop the explicit acquire/release and let g_main_loop_run() handle
|
||||
* ownership. We must NOT use a g_main_context_iteration() pump instead,
|
||||
* because that would re-dispatch unrelated ready sources (other Soup
|
||||
* requests, WebKit load events, GTK UI events) re-entrantly while a
|
||||
* tool call is in progress, which can deadlock when an MCP tool runs
|
||||
* during page loading.
|
||||
*/
|
||||
|
||||
typedef struct {
|
||||
@@ -48,6 +69,7 @@ static gboolean on_js_timeout(gpointer user_data) {
|
||||
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
|
||||
if (!ctx->done) {
|
||||
g_printerr("[agent] JS evaluation timed out\n");
|
||||
ctx->done = TRUE;
|
||||
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
|
||||
g_main_loop_quit(ctx->loop);
|
||||
}
|
||||
@@ -61,26 +83,24 @@ char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
|
||||
|
||||
js_eval_ctx_t ctx = {0};
|
||||
|
||||
/* Use the default main context for the nested loop. */
|
||||
GMainContext *main_ctx = g_main_context_default();
|
||||
g_main_context_acquire(main_ctx);
|
||||
|
||||
ctx.loop = g_main_loop_new(main_ctx, FALSE);
|
||||
/* Nested loop on the default context. Do NOT acquire/release the
|
||||
* context here — see the block comment above. */
|
||||
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
|
||||
|
||||
/* Add a timeout source to the same context. */
|
||||
guint timeout_id = g_timeout_add(timeout_ms, on_js_timeout, &ctx);
|
||||
|
||||
/* Start async evaluation. */
|
||||
/* Start async evaluation. The completion callback fires on the
|
||||
* default main context (where WebKit schedules its async work). */
|
||||
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
|
||||
on_js_finished, &ctx);
|
||||
|
||||
/* Run the nested loop until JS completes or timeout. */
|
||||
/* Run the nested loop until JS completes or the timeout quits it. */
|
||||
g_main_loop_run(ctx.loop);
|
||||
|
||||
/* Cleanup. */
|
||||
g_source_remove(timeout_id);
|
||||
g_main_loop_unref(ctx.loop);
|
||||
g_main_context_release(main_ctx);
|
||||
|
||||
return ctx.result;
|
||||
}
|
||||
|
||||
+43
-22
@@ -130,9 +130,12 @@ static WebKitWebView *get_active_webview(void) {
|
||||
*
|
||||
* Coordinates from getBoundingClientRect() are relative to the viewport
|
||||
* and match the webview's GdkWindow coordinate space when the webview
|
||||
* fills its parent window. Returns TRUE on success. */
|
||||
static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
|
||||
* fills its parent window. `button` is 1 (left), 2 (middle), or 3 (right).
|
||||
* Returns TRUE on success. */
|
||||
static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y,
|
||||
int button) {
|
||||
if (wv == NULL) return FALSE;
|
||||
if (button < 1) button = 1;
|
||||
|
||||
GtkWidget *widget = GTK_WIDGET(wv);
|
||||
GdkWindow *window = gtk_widget_get_window(widget);
|
||||
@@ -150,7 +153,7 @@ static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
|
||||
press->button.y = y;
|
||||
press->button.axes = NULL;
|
||||
press->button.state = 0; /* no modifiers */
|
||||
press->button.button = 1; /* left button */
|
||||
press->button.button = button;
|
||||
press->button.device = gdk_seat_get_pointer(gdk_display_get_default_seat(gdk_window_get_display(window)));
|
||||
gdk_event_put(press);
|
||||
gdk_event_free(press);
|
||||
@@ -170,7 +173,7 @@ static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
|
||||
release->button.y = y;
|
||||
release->button.axes = NULL;
|
||||
release->button.state = 0;
|
||||
release->button.button = 1;
|
||||
release->button.button = button;
|
||||
release->button.device = gdk_seat_get_pointer(gdk_display_get_default_seat(gdk_window_get_display(window)));
|
||||
gdk_event_put(release);
|
||||
gdk_event_free(release);
|
||||
@@ -1002,8 +1005,16 @@ static cJSON *tool_eval(cJSON *params) {
|
||||
/* ── Screenshot tool ──────────────────────────────────────────────── */
|
||||
|
||||
/* Context for the async webkit_web_view_snapshot() call. We use a
|
||||
* nested GMainLoop to turn the async API into a synchronous one,
|
||||
* matching the pattern in agent_js_eval_sync(). */
|
||||
* async API into a synchronous one.
|
||||
*
|
||||
* We run a nested GMainLoop on the default main context (the standard
|
||||
* GTK modal pattern) but do NOT call g_main_context_acquire()/
|
||||
* g_main_context_release() around it — the default context is already
|
||||
* owned by the outer dispatch (these tools are invoked from the MCP
|
||||
* HTTP handler), so an explicit acquire fails silently and a matching
|
||||
* release corrupts the owner_count, causing a segfault. The nested
|
||||
* g_main_loop_run() handles ownership internally. See the block comment
|
||||
* in agent_js_eval_sync() for the full rationale. */
|
||||
typedef struct {
|
||||
cairo_surface_t *surface;
|
||||
gboolean done;
|
||||
@@ -1031,6 +1042,7 @@ static gboolean snapshot_timeout_cb(gpointer user_data) {
|
||||
snapshot_ctx_t *ctx = (snapshot_ctx_t *)user_data;
|
||||
if (!ctx->done) {
|
||||
g_printerr("[agent] screenshot timed out\n");
|
||||
ctx->done = TRUE;
|
||||
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
|
||||
g_main_loop_quit(ctx->loop);
|
||||
}
|
||||
@@ -1051,11 +1063,10 @@ static cJSON *tool_screenshot(cJSON *params) {
|
||||
WebKitWebView *wv = get_active_webview();
|
||||
if (!wv) return make_error("NO_TAB", "No active tab");
|
||||
|
||||
/* Set up the nested GMainLoop to wait for the async snapshot. */
|
||||
/* Nested GMainLoop on the default context. No acquire/release —
|
||||
* see the note on snapshot_ctx_t above. */
|
||||
snapshot_ctx_t ctx = {0};
|
||||
GMainContext *main_ctx = g_main_context_default();
|
||||
g_main_context_acquire(main_ctx);
|
||||
ctx.loop = g_main_loop_new(main_ctx, FALSE);
|
||||
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
|
||||
|
||||
guint timeout_id = g_timeout_add(10000, snapshot_timeout_cb, &ctx);
|
||||
|
||||
@@ -1071,7 +1082,6 @@ static cJSON *tool_screenshot(cJSON *params) {
|
||||
|
||||
g_source_remove(timeout_id);
|
||||
g_main_loop_unref(ctx.loop);
|
||||
g_main_context_release(main_ctx);
|
||||
|
||||
if (!ctx.done || ctx.surface == NULL) {
|
||||
if (ctx.surface) cairo_surface_destroy(ctx.surface);
|
||||
@@ -1166,7 +1176,7 @@ static cJSON *tool_click(cJSON *params) {
|
||||
double cy = jy->valuedouble + jh->valuedouble / 2.0;
|
||||
cJSON_Delete(box);
|
||||
g_free(sel);
|
||||
if (synthesize_click_at(wv, cx, cy)) {
|
||||
if (synthesize_click_at(wv, cx, cy, 1)) {
|
||||
return make_success(NULL);
|
||||
}
|
||||
/* Fall through to JS fallback if GDK synthesis failed. */
|
||||
@@ -1210,7 +1220,17 @@ static cJSON *tool_click(cJSON *params) {
|
||||
/* click_at — Click at explicit viewport coordinates via GDK event
|
||||
* synthesis. Useful when the agent already knows the target point
|
||||
* (e.g. from a screenshot or get_box result) and wants to bypass
|
||||
* selector resolution entirely. */
|
||||
* selector resolution entirely. Optional 'button': 1=left (default),
|
||||
* 2=middle, 3=right (triggers the native context menu). */
|
||||
static int click_button_from_params(cJSON *params) {
|
||||
cJSON *jb = cJSON_GetObjectItem(params, "button");
|
||||
if (jb && cJSON_IsNumber(jb)) {
|
||||
int b = jb->valueint;
|
||||
if (b >= 1 && b <= 3) return b;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static cJSON *tool_click_at(cJSON *params) {
|
||||
cJSON *jx = cJSON_GetObjectItem(params, "x");
|
||||
cJSON *jy = cJSON_GetObjectItem(params, "y");
|
||||
@@ -1223,8 +1243,9 @@ static cJSON *tool_click_at(cJSON *params) {
|
||||
|
||||
double x = jx->valuedouble;
|
||||
double y = jy->valuedouble;
|
||||
int button = click_button_from_params(params);
|
||||
|
||||
if (!synthesize_click_at(wv, x, y)) {
|
||||
if (!synthesize_click_at(wv, x, y, button)) {
|
||||
return make_error("CLICK_FAILED", "Failed to synthesize GDK click event");
|
||||
}
|
||||
return make_success(NULL);
|
||||
@@ -4022,11 +4043,9 @@ static cJSON *tool_screenshot_annotated(cJSON *params) {
|
||||
/* We don't strictly need the result, but wait for it to complete. */
|
||||
g_free(ann_result);
|
||||
|
||||
/* Step 3: Take the WebKit snapshot (same pattern as tool_screenshot). */
|
||||
/* Step 3: Take the WebKit snapshot (same nested-loop pattern as tool_screenshot). */
|
||||
snapshot_ctx_t ctx = {0};
|
||||
GMainContext *main_ctx = g_main_context_default();
|
||||
g_main_context_acquire(main_ctx);
|
||||
ctx.loop = g_main_loop_new(main_ctx, FALSE);
|
||||
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
|
||||
|
||||
guint timeout_id = g_timeout_add(10000, snapshot_timeout_cb, &ctx);
|
||||
|
||||
@@ -4039,7 +4058,6 @@ static cJSON *tool_screenshot_annotated(cJSON *params) {
|
||||
|
||||
g_source_remove(timeout_id);
|
||||
g_main_loop_unref(ctx.loop);
|
||||
g_main_context_release(main_ctx);
|
||||
|
||||
if (!ctx.done || ctx.surface == NULL) {
|
||||
if (ctx.surface) cairo_surface_destroy(ctx.surface);
|
||||
@@ -4581,7 +4599,7 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
|
||||
double cy = jy->valuedouble + jh->valuedouble / 2.0;
|
||||
cJSON_Delete(box);
|
||||
g_free(box_result);
|
||||
if (synthesize_click_at(wv, cx, cy)) {
|
||||
if (synthesize_click_at(wv, cx, cy, 1)) {
|
||||
g_free(sel);
|
||||
/* Send a success response directly through the
|
||||
* WebSocket connection, matching the async path. */
|
||||
@@ -4619,7 +4637,9 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
|
||||
|
||||
/* click_at — coordinate-based click via GDK event synthesis (sync).
|
||||
* Takes explicit x/y viewport coordinates and dispatches a real
|
||||
* GDK button press/release. Bypasses selector resolution entirely. */
|
||||
* GDK button press/release. Optional 'button': 1=left (default),
|
||||
* 2=middle, 3=right (triggers the native context menu). Bypasses
|
||||
* selector resolution entirely. */
|
||||
if (conn != NULL && strcmp(tool_name, "click_at") == 0) {
|
||||
cJSON *jx = cJSON_GetObjectItem(params, "x");
|
||||
cJSON *jy = cJSON_GetObjectItem(params, "y");
|
||||
@@ -4630,7 +4650,8 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
|
||||
if (wv == NULL) return make_error("NO_TAB", "No active tab");
|
||||
double x = jx->valuedouble;
|
||||
double y = jy->valuedouble;
|
||||
if (!synthesize_click_at(wv, x, y)) {
|
||||
int button = click_button_from_params(params);
|
||||
if (!synthesize_click_at(wv, x, y, button)) {
|
||||
return make_error("CLICK_FAILED", "Failed to synthesize GDK click event");
|
||||
}
|
||||
/* Send success response directly through the WebSocket. */
|
||||
|
||||
+45
-42
@@ -8,12 +8,13 @@
|
||||
* - d tag = HMAC-SHA256(hmac_key, path) (deterministic, opaque, 64 hex)
|
||||
* - content = NIP-44 encrypted JSON {"path": ..., "bookmarks": [...]}
|
||||
* - hmac_key = HMAC-SHA256(privkey, BOOKMARKS_HMAC_KEY_LABEL)
|
||||
*
|
||||
* On startup, the relay fetch retrieves kind 30003 events, which are
|
||||
* decrypted and loaded into an in-memory trie. Legacy events with plaintext
|
||||
* d tags (from the previous flat-directory implementation) are detected,
|
||||
* migrated to the new HMAC-d format, and their old events are marked for
|
||||
* kind 5 deletion.
|
||||
* d tags (from the previous flat-directory implementation) are IGNORED on
|
||||
* load — they are neither migrated nor re-published. This project is
|
||||
* pre-public and the only user has already re-published every folder with
|
||||
* HMAC d tags, so legacy events are dead data. Skipping them avoids the
|
||||
* per-boot migration/publish storm that earlier versions triggered.
|
||||
*/
|
||||
|
||||
#include "bookmarks.h"
|
||||
@@ -541,6 +542,8 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
if (events != NULL) {
|
||||
int n = cJSON_GetArraySize(events);
|
||||
g_print("[bookmarks] Loading %d cached folder event(s)\n", n);
|
||||
int legacy_skipped = 0;
|
||||
int loaded = 0;
|
||||
cJSON *event;
|
||||
cJSON_ArrayForEach(event, events) {
|
||||
/* Get the d tag. */
|
||||
@@ -560,6 +563,25 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Ignore legacy (non-HMAC) bookmark events entirely.
|
||||
* Legacy events used the plaintext folder path as the d tag;
|
||||
* the current format uses an opaque HMAC-SHA256 d tag (see
|
||||
* path_to_d_tag). This project is pre-public and the only
|
||||
* user has already re-published every folder with HMAC d
|
||||
* tags, so legacy events are dead data. Skipping them here
|
||||
* (a) avoids the per-boot migration/publish storm that
|
||||
* re-encrypted and re-published every folder on every launch,
|
||||
* and (b) keeps the local SQLite cache from re-triggering
|
||||
* migration via the duplicate legacy rows that earlier
|
||||
* migrations left behind (publish_deletion_for_d stored a
|
||||
* kind 5 tombstone but never deleted the old kind 30003
|
||||
* row). Legacy events are simply not loaded; they are not
|
||||
* deleted from the db or from relays here. */
|
||||
if (!is_hmac_d_tag(d_value)) {
|
||||
legacy_skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Decrypt content. */
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
if (!cJSON_IsString(content) || content->valuestring[0] == '\0')
|
||||
@@ -568,22 +590,14 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
char *plaintext = decrypt_content(content->valuestring);
|
||||
if (plaintext == NULL) continue;
|
||||
|
||||
/* Determine the path. */
|
||||
/* New format: path is inside the encrypted content. */
|
||||
char *path_from_content = NULL;
|
||||
bookmark_node_t scratch = {0};
|
||||
scratch.name = g_strdup("");
|
||||
scratch.path = g_strdup("");
|
||||
node_load_from_content(&scratch, plaintext, &path_from_content);
|
||||
|
||||
const char *path = NULL;
|
||||
if (is_hmac_d_tag(d_value)) {
|
||||
/* New format: path is inside the encrypted content. */
|
||||
path = path_from_content;
|
||||
} else {
|
||||
/* Legacy: d tag is the plaintext path. */
|
||||
path = d_value;
|
||||
}
|
||||
|
||||
const char *path = path_from_content;
|
||||
if (path == NULL || path[0] == '\0') path = "General";
|
||||
|
||||
bookmark_node_t *leaf = node_ensure_path(&g_root, path, NULL);
|
||||
@@ -595,21 +609,7 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
scratch.bookmarks = NULL;
|
||||
scratch.bookmark_count = 0;
|
||||
}
|
||||
|
||||
/* Migration: re-publish with the current single-step HMAC d
|
||||
* tag and delete the old event if the d tag doesn't match.
|
||||
* This covers both legacy plaintext d tags AND old two-step
|
||||
* HMAC d tags (from before the single-step switch). */
|
||||
if (g_have_signer && path != NULL && path[0] != '\0') {
|
||||
char *new_d = path_to_d_tag(path);
|
||||
if (new_d != NULL && strcmp(new_d, d_value) != 0) {
|
||||
g_print("[bookmarks] Migrating folder '%s' to current HMAC d tag\n",
|
||||
path);
|
||||
if (leaf) publish_node(leaf);
|
||||
publish_deletion_for_d(d_value);
|
||||
}
|
||||
g_free(new_d);
|
||||
}
|
||||
loaded++;
|
||||
|
||||
g_free(path_from_content);
|
||||
/* node_free frees scratch.name, scratch.path, and any
|
||||
@@ -618,6 +618,11 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
free(plaintext);
|
||||
}
|
||||
cJSON_Delete(events);
|
||||
if (legacy_skipped > 0) {
|
||||
g_print("[bookmarks] Skipped %d legacy (non-HMAC) folder event(s)\n",
|
||||
legacy_skipped);
|
||||
}
|
||||
g_print("[bookmarks] Loaded %d HMAC folder event(s)\n", loaded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,6 +1060,16 @@ int bookmarks_store_and_load_event(const void *event_cjson) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Ignore legacy (non-HMAC) bookmark events. See the matching comment in
|
||||
* bookmarks_init() for the full rationale. The event has already been
|
||||
* stored in SQLite by the caller (relay_fetch); we simply do not load
|
||||
* it into the in-memory tree and do not migrate/re-publish it. This
|
||||
* breaks the per-boot migration storm that was triggered every time the
|
||||
* relay fetch re-served a legacy event. */
|
||||
if (!is_hmac_d_tag(d_value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
if (!cJSON_IsString(content) || content->valuestring[0] == '\0')
|
||||
return 0;
|
||||
@@ -1068,7 +1083,7 @@ int bookmarks_store_and_load_event(const void *event_cjson) {
|
||||
scratch.path = g_strdup("");
|
||||
node_load_from_content(&scratch, plaintext, &path_from_content);
|
||||
|
||||
const char *path = is_hmac_d_tag(d_value) ? path_from_content : d_value;
|
||||
const char *path = path_from_content;
|
||||
if (path == NULL || path[0] == '\0') path = "General";
|
||||
|
||||
bookmark_node_t *leaf = node_ensure_path(&g_root, path, NULL);
|
||||
@@ -1080,18 +1095,6 @@ int bookmarks_store_and_load_event(const void *event_cjson) {
|
||||
scratch.bookmark_count = 0;
|
||||
}
|
||||
|
||||
/* Migration: re-publish with the current single-step HMAC d tag and
|
||||
* delete the old event if the d tag doesn't match. Covers both legacy
|
||||
* plaintext d tags and old two-step HMAC d tags. */
|
||||
if (g_have_signer && path != NULL && path[0] != '\0') {
|
||||
char *new_d = path_to_d_tag(path);
|
||||
if (new_d != NULL && strcmp(new_d, d_value) != 0) {
|
||||
if (leaf) publish_node(leaf);
|
||||
publish_deletion_for_d(d_value);
|
||||
}
|
||||
g_free(new_d);
|
||||
}
|
||||
|
||||
g_free(path_from_content);
|
||||
/* node_free frees scratch.name, scratch.path, and any remaining
|
||||
* bookmarks (already moved to leaf, so NULL). */
|
||||
|
||||
@@ -109,6 +109,7 @@ enum {
|
||||
OPT_VERBOSE,
|
||||
OPT_QUIET,
|
||||
OPT_HELP,
|
||||
OPT_DOWNLOAD_URL,
|
||||
};
|
||||
|
||||
static struct option long_opts[] = {
|
||||
@@ -143,6 +144,7 @@ static struct option long_opts[] = {
|
||||
{"verbose", no_argument, 0, OPT_VERBOSE},
|
||||
{"quiet", no_argument, 0, OPT_QUIET},
|
||||
{"help", no_argument, 0, OPT_HELP},
|
||||
{"download-url", required_argument, 0, OPT_DOWNLOAD_URL},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
|
||||
@@ -198,6 +200,7 @@ void cli_args_free(cli_args_t *args) {
|
||||
g_free(args->nsigner_transport);
|
||||
g_free(args->nsigner_device);
|
||||
g_free(args->nsigner_service);
|
||||
g_free(args->download_url);
|
||||
memset(args, 0, sizeof(*args));
|
||||
}
|
||||
|
||||
@@ -356,6 +359,11 @@ int cli_parse(int *argc, char ***argv, cli_args_t *out) {
|
||||
out->want_help = TRUE;
|
||||
return 0;
|
||||
|
||||
case OPT_DOWNLOAD_URL:
|
||||
g_free(out->download_url);
|
||||
out->download_url = xstrdup(optarg);
|
||||
break;
|
||||
|
||||
default:
|
||||
fprintf(stderr, "[cli] unhandled option\n");
|
||||
return -1;
|
||||
|
||||
@@ -68,6 +68,10 @@ typedef struct {
|
||||
int verbose; /* --verbose / -v (repeatable count) */
|
||||
gboolean quiet; /* --quiet / -q */
|
||||
|
||||
/* Offline site download (test/diagnostic hook — triggers
|
||||
* site_downloader_start shortly after startup). */
|
||||
char *download_url; /* --download-url <url> */
|
||||
|
||||
/* Exit-request flags set by --help / --version. */
|
||||
gboolean want_help;
|
||||
gboolean want_version;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* local_scheme.c — local:// URI scheme handler for sovereign_browser
|
||||
*
|
||||
* Maps local:///absolute/path/to/file to the local filesystem. Unlike
|
||||
* file://, this scheme creates a proper (non-opaque) SecurityOrigin in
|
||||
* WebKit, so localStorage, IndexedDB, and other web storage APIs work
|
||||
* with the default quota instead of the 0-byte quota that WebKit assigns
|
||||
* to file:// origins.
|
||||
*
|
||||
* The handler reads the requested file from disk and serves it with the
|
||||
* correct MIME type. Directory requests return a 404 (no directory listing).
|
||||
*
|
||||
* Security: this is intentionally unrestricted — the browser's security
|
||||
* model is at the Qube level, not the browser level.
|
||||
*/
|
||||
|
||||
#include "local_scheme.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ── MIME type helper ───────────────────────────────────────────────── */
|
||||
|
||||
/* Guess the MIME type from a file path's extension. Falls back to
|
||||
* application/octet-stream for unknown types. */
|
||||
static const char *guess_mime(const char *path) {
|
||||
const char *ext = strrchr(path, '.');
|
||||
if (ext == NULL) return "application/octet-stream";
|
||||
|
||||
if (g_ascii_strcasecmp(ext, ".html") == 0 ||
|
||||
g_ascii_strcasecmp(ext, ".htm") == 0)
|
||||
return "text/html; charset=utf-8";
|
||||
if (g_ascii_strcasecmp(ext, ".css") == 0)
|
||||
return "text/css; charset=utf-8";
|
||||
if (g_ascii_strcasecmp(ext, ".js") == 0)
|
||||
return "application/javascript; charset=utf-8";
|
||||
if (g_ascii_strcasecmp(ext, ".mjs") == 0)
|
||||
return "text/javascript; charset=utf-8";
|
||||
if (g_ascii_strcasecmp(ext, ".json") == 0)
|
||||
return "application/json";
|
||||
if (g_ascii_strcasecmp(ext, ".svg") == 0)
|
||||
return "image/svg+xml";
|
||||
if (g_ascii_strcasecmp(ext, ".png") == 0)
|
||||
return "image/png";
|
||||
if (g_ascii_strcasecmp(ext, ".jpg") == 0 ||
|
||||
g_ascii_strcasecmp(ext, ".jpeg") == 0)
|
||||
return "image/jpeg";
|
||||
if (g_ascii_strcasecmp(ext, ".gif") == 0)
|
||||
return "image/gif";
|
||||
if (g_ascii_strcasecmp(ext, ".ico") == 0)
|
||||
return "image/x-icon";
|
||||
if (g_ascii_strcasecmp(ext, ".webp") == 0)
|
||||
return "image/webp";
|
||||
if (g_ascii_strcasecmp(ext, ".woff") == 0)
|
||||
return "font/woff";
|
||||
if (g_ascii_strcasecmp(ext, ".woff2") == 0)
|
||||
return "font/woff2";
|
||||
if (g_ascii_strcasecmp(ext, ".ttf") == 0)
|
||||
return "font/ttf";
|
||||
if (g_ascii_strcasecmp(ext, ".otf") == 0)
|
||||
return "font/otf";
|
||||
if (g_ascii_strcasecmp(ext, ".mp4") == 0)
|
||||
return "video/mp4";
|
||||
if (g_ascii_strcasecmp(ext, ".webm") == 0)
|
||||
return "video/webm";
|
||||
if (g_ascii_strcasecmp(ext, ".mp3") == 0)
|
||||
return "audio/mpeg";
|
||||
if (g_ascii_strcasecmp(ext, ".ogg") == 0)
|
||||
return "audio/ogg";
|
||||
if (g_ascii_strcasecmp(ext, ".pdf") == 0)
|
||||
return "application/pdf";
|
||||
if (g_ascii_strcasecmp(ext, ".txt") == 0)
|
||||
return "text/plain; charset=utf-8";
|
||||
if (g_ascii_strcasecmp(ext, ".xml") == 0)
|
||||
return "application/xml";
|
||||
if (g_ascii_strcasecmp(ext, ".wasm") == 0)
|
||||
return "application/wasm";
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
/* ── Scheme handler ─────────────────────────────────────────────────── */
|
||||
|
||||
static void on_local_scheme(WebKitURISchemeRequest *request, gpointer user_data) {
|
||||
(void)user_data;
|
||||
const char *uri = webkit_uri_scheme_request_get_uri(request);
|
||||
|
||||
/* Strip "local://" prefix to get the file path. The URI is
|
||||
* local:///absolute/path, so we skip 8 characters ("local://")
|
||||
* and the result is /absolute/path. */
|
||||
const char *path_start = uri + 8;
|
||||
if (path_start == NULL || path_start[0] == '\0') {
|
||||
g_printerr("[local-scheme] Empty path in URI: %s\n", uri);
|
||||
webkit_uri_scheme_request_finish_error(request, g_error_new_literal(
|
||||
g_quark_from_static_string("local-scheme"), 1, "Empty path"));
|
||||
return;
|
||||
}
|
||||
|
||||
/* Strip query parameters (?...) from the path. Web pages and
|
||||
* SharedWorkers may append ?v=... or other cache-busting params. */
|
||||
char *path = g_strdup(path_start);
|
||||
char *qmark = strchr(path, '?');
|
||||
if (qmark) *qmark = '\0';
|
||||
|
||||
/* Resolve root-relative paths against the requesting page's directory.
|
||||
* If the path is an absolute filesystem path (e.g. /home/user/...),
|
||||
* use it as-is. If it's a root-relative path (e.g. /nostr-login-lite/...)
|
||||
* that doesn't exist, try resolving it relative to the page's directory. */
|
||||
if (path[0] == '/' && !g_file_test(path, G_FILE_TEST_EXISTS)) {
|
||||
WebKitWebView *wv = webkit_uri_scheme_request_get_web_view(request);
|
||||
if (wv) {
|
||||
const char *page_uri = webkit_web_view_get_uri(wv);
|
||||
if (page_uri && g_str_has_prefix(page_uri, "local://")) {
|
||||
/* Get the page's directory. */
|
||||
const char *page_path = page_uri + 8;
|
||||
char *page_dir = g_strdup(page_path);
|
||||
char *last_slash = strrchr(page_dir, '/');
|
||||
if (last_slash) {
|
||||
*(last_slash + 1) = '\0';
|
||||
char *resolved = g_strconcat(page_dir, path + 1, NULL);
|
||||
if (g_file_test(resolved, G_FILE_TEST_EXISTS)) {
|
||||
g_print("[local-scheme] Resolved root-relative %s -> %s\n",
|
||||
path, resolved);
|
||||
g_free(path);
|
||||
path = resolved;
|
||||
} else {
|
||||
g_free(resolved);
|
||||
}
|
||||
}
|
||||
g_free(page_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Read the file. */
|
||||
gsize length = 0;
|
||||
char *content = NULL;
|
||||
GError *error = NULL;
|
||||
|
||||
if (!g_file_get_contents(path, &content, &length, &error)) {
|
||||
g_printerr("[local-scheme] Failed to read %s: %s\n", path,
|
||||
error ? error->message : "unknown error");
|
||||
g_free(path);
|
||||
if (error) g_error_free(error);
|
||||
webkit_uri_scheme_request_finish_error(request, g_error_new_literal(
|
||||
g_quark_from_static_string("local-scheme"), 2, "File not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
/* Determine MIME type and serve. */
|
||||
const char *mime_type = guess_mime(path);
|
||||
g_print("[local-scheme] Serving %s (%s, %lu bytes)\n", path, mime_type,
|
||||
(unsigned long)length);
|
||||
g_free(path);
|
||||
|
||||
/* Wrap the content in a GInputStream. g_memory_input_stream_new_from_data
|
||||
* takes ownership of the data via the GDestroyNotify callback. */
|
||||
GInputStream *stream = g_memory_input_stream_new_from_data(
|
||||
content, length, g_free);
|
||||
webkit_uri_scheme_request_finish(request, stream, length, mime_type);
|
||||
g_object_unref(stream);
|
||||
}
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────── */
|
||||
|
||||
void local_scheme_setup(WebKitWebContext *ctx) {
|
||||
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
|
||||
|
||||
webkit_web_context_register_uri_scheme(ctx, "local", on_local_scheme,
|
||||
NULL, NULL);
|
||||
|
||||
/* Register local:// as secure (no mixed-content warnings), local
|
||||
* (can load local resources), and CORS-enabled (so fetch/XHR from
|
||||
* local:// pages to other local:// URLs work without CORS headers). */
|
||||
WebKitSecurityManager *sec_mgr =
|
||||
webkit_web_context_get_security_manager(ctx);
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "local");
|
||||
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "local");
|
||||
webkit_security_manager_register_uri_scheme_as_cors_enabled(sec_mgr, "local");
|
||||
|
||||
g_print("[local-scheme] Registered local:// scheme handler\n");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* local_scheme.h — local:// URI scheme handler for sovereign_browser
|
||||
*
|
||||
* Registers a "local" URI scheme that maps local:///path/to/file to the
|
||||
* local filesystem. Unlike file://, local:// creates a proper (non-opaque)
|
||||
* SecurityOrigin in WebKit, which means localStorage, IndexedDB, and other
|
||||
* web storage APIs work with the default quota instead of the 0-byte quota
|
||||
* that WebKit assigns to file:// origins.
|
||||
*
|
||||
* Usage:
|
||||
* local_scheme_setup(ctx) — register the handler on the WebKitWebContext
|
||||
*
|
||||
* Then load pages via local:///absolute/path/to/file.html instead of
|
||||
* file:///absolute/path/to/file.html.
|
||||
*/
|
||||
|
||||
#ifndef LOCAL_SCHEME_H
|
||||
#define LOCAL_SCHEME_H
|
||||
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Register the local:// URI scheme handler on the given WebKitWebContext.
|
||||
* Must be called once during startup, after the context is created.
|
||||
*/
|
||||
void local_scheme_setup(WebKitWebContext *ctx);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* LOCAL_SCHEME_H */
|
||||
+32
@@ -38,6 +38,7 @@
|
||||
#include "nostr_bridge.h"
|
||||
#include "nostr_scheme.h"
|
||||
#include "tor_scheme.h"
|
||||
#include "local_scheme.h"
|
||||
#include "nostr_inject.h"
|
||||
#include "history.h"
|
||||
#include "settings.h"
|
||||
@@ -48,6 +49,7 @@
|
||||
#include "agent_server.h"
|
||||
#include "agent_login.h"
|
||||
#include "cli.h"
|
||||
#include "site_downloader.h"
|
||||
#include "db.h"
|
||||
#include "profile.h"
|
||||
#include "relay_fetch.h"
|
||||
@@ -163,6 +165,18 @@ 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; }
|
||||
|
||||
/* Idle callback for the --download-url diagnostic flag: triggers the
|
||||
* offline site downloader on the main thread shortly after startup. */
|
||||
static gboolean dl_test_trigger(gpointer data) {
|
||||
const char *url = (const char *)data;
|
||||
GtkWindow *win = gtk_window_get_focus(GTK_WINDOW(g_window)) ?
|
||||
g_window : NULL;
|
||||
/* g_window may not be set yet in some early-trigger scenarios; fall
|
||||
* back to NULL (unparented dialog). */
|
||||
site_downloader_start(url, win);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* ---- 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
|
||||
@@ -520,6 +534,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
tab_manager_new_window_blank();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_OPEN_FILE:
|
||||
tab_manager_open_file();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_CLOSE_TAB:
|
||||
tab_manager_close_active();
|
||||
return TRUE;
|
||||
@@ -614,6 +632,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
|
||||
tab_manager_toggle_sidebar();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_TOGGLE_TOOLBARS:
|
||||
tab_manager_toggle_toolbars();
|
||||
return TRUE;
|
||||
|
||||
case SHORTCUT_ZOOM_IN:
|
||||
tab_manager_zoom_in();
|
||||
return TRUE;
|
||||
@@ -914,6 +936,7 @@ static WebKitWebContext *build_context_for_current_user(void) {
|
||||
* on every fresh context. */
|
||||
nostr_scheme_register(web_ctx);
|
||||
tor_scheme_register(web_ctx);
|
||||
local_scheme_setup(web_ctx);
|
||||
|
||||
/* Point tab_manager at the new context so subsequent new tabs (and
|
||||
* the sidebar webview) are created from it. */
|
||||
@@ -1234,6 +1257,15 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Diagnostic/test hook: --download-url triggers the offline site
|
||||
* downloader shortly after startup (same code path as the tab
|
||||
* context menu's "Download Website"). */
|
||||
if (cli.download_url != NULL && cli.download_url[0] != '\0') {
|
||||
char *dl_url = g_strdup(cli.download_url);
|
||||
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
|
||||
(GSourceFunc)dl_test_trigger, dl_url, g_free);
|
||||
}
|
||||
|
||||
cli_args_free(&cli);
|
||||
|
||||
gtk_widget_show_all(window);
|
||||
|
||||
@@ -1255,6 +1255,10 @@ static void handle_settings_set(WebKitURISchemeRequest *request,
|
||||
}
|
||||
/* Trigger debounced NIP-78 sync. */
|
||||
settings_sync_publish();
|
||||
} else if (strcmp(key, "perf_probe.enabled") == 0) {
|
||||
bs->perf_probe_enabled = (strcmp(value, "true") == 0 ||
|
||||
strcmp(value, "1") == 0);
|
||||
settings_save();
|
||||
} else {
|
||||
respond_error_json(request, -1, "Unknown key");
|
||||
g_free(key); g_free(value);
|
||||
|
||||
+26
-2
@@ -28,6 +28,10 @@
|
||||
#define MAX_RELAYS 32
|
||||
|
||||
/* Relay query timeout in seconds. */
|
||||
|
||||
/* Forward declaration — defined below. */
|
||||
static gboolean merge_settings_idle(gpointer data);
|
||||
|
||||
#define RELAY_TIMEOUT_SECONDS 15
|
||||
|
||||
/* Forward declaration — defined after relay_fetch_bootstrap. */
|
||||
@@ -120,11 +124,18 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
|
||||
/* Store in SQLite first, then try to merge into local settings.
|
||||
* settings_sync_merge_from_nostr checks the d-tag and only
|
||||
* merges if it's the shared "user-settings" event (or the
|
||||
* legacy "sovereign_browser" event, which is migrated). */
|
||||
* legacy "sovereign_browser" event, which is migrated).
|
||||
*
|
||||
* IMPORTANT: The merge calls settings_load() which writes to
|
||||
* the global g_settings struct. This must run on the main
|
||||
* thread to avoid a data race with the GTK main loop. */
|
||||
if (db_store_event(results[i]) == 0) {
|
||||
stored++;
|
||||
}
|
||||
settings_sync_merge_from_nostr(results[i]);
|
||||
cJSON *event_copy = cJSON_Duplicate(results[i], 1);
|
||||
if (event_copy) {
|
||||
g_idle_add(merge_settings_idle, event_copy);
|
||||
}
|
||||
} else {
|
||||
if (db_store_event(results[i]) == 0) {
|
||||
stored++;
|
||||
@@ -208,6 +219,19 @@ static gboolean avatar_refresh_idle(gpointer data) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/* Idle callback to merge NIP-78 settings on the main thread.
|
||||
* settings_sync_merge_from_nostr calls settings_load() which writes to
|
||||
* the global g_settings struct, so it must run on the GTK main thread
|
||||
* to avoid data races with the main loop. Takes ownership of event_cjson. */
|
||||
static gboolean merge_settings_idle(gpointer data) {
|
||||
cJSON *event = (cJSON *)data;
|
||||
if (event) {
|
||||
settings_sync_merge_from_nostr(event);
|
||||
cJSON_Delete(event);
|
||||
}
|
||||
return FALSE; /* run once */
|
||||
}
|
||||
|
||||
/* ── Background thread ─────────────────────────────────────────────── */
|
||||
|
||||
gpointer relay_fetch_thread(gpointer data) {
|
||||
|
||||
@@ -126,6 +126,14 @@ static void settings_set_defaults(browser_settings_t *s) {
|
||||
s->fips_control_socket[0] = '\0';
|
||||
snprintf(s->fips_config_dir, sizeof(s->fips_config_dir),
|
||||
"~/.sovereign_browser/fips");
|
||||
|
||||
s->offline_site_max_pages = SETTINGS_OFFLINE_SITE_MAX_PAGES_DEFAULT;
|
||||
s->offline_site_request_delay_ms = SETTINGS_OFFLINE_SITE_REQUEST_DELAY_MS_DEFAULT;
|
||||
|
||||
/* Perf probe is OFF by default — its instrumentation overhead
|
||||
* (patched addEventListener/setTimeout/setInterval/XHR + a permanent
|
||||
* rAF loop) breaks scrolling on listener-heavy pages. */
|
||||
s->perf_probe_enabled = FALSE;
|
||||
}
|
||||
|
||||
/* ── Parsing helpers ──────────────────────────────────────────────── */
|
||||
@@ -317,6 +325,20 @@ void settings_load_user(void) {
|
||||
val = db_kv_get("fips.config_dir");
|
||||
if (val) snprintf(g_settings.fips_config_dir, sizeof(g_settings.fips_config_dir), "%s", val);
|
||||
|
||||
val = db_kv_get("offline_site.max_pages");
|
||||
if (val) {
|
||||
g_settings.offline_site_max_pages = parse_int(val, g_settings.offline_site_max_pages);
|
||||
if (g_settings.offline_site_max_pages < 1) g_settings.offline_site_max_pages = 1;
|
||||
}
|
||||
val = db_kv_get("offline_site.request_delay_ms");
|
||||
if (val) {
|
||||
g_settings.offline_site_request_delay_ms = parse_int(val, g_settings.offline_site_request_delay_ms);
|
||||
if (g_settings.offline_site_request_delay_ms < 0) g_settings.offline_site_request_delay_ms = 0;
|
||||
}
|
||||
|
||||
val = db_kv_get("perf_probe.enabled");
|
||||
if (val) g_settings.perf_probe_enabled = parse_bool(val, g_settings.perf_probe_enabled);
|
||||
|
||||
/* Agent LLM provider settings — these are the "resolved" values
|
||||
* (active provider's base_url + api_key + selected model). They are
|
||||
* populated from db_kv here for local persistence, and overwritten
|
||||
@@ -483,6 +505,13 @@ void settings_save_user(void) {
|
||||
db_kv_set("fips.control_socket", g_settings.fips_control_socket);
|
||||
db_kv_set("fips.config_dir", g_settings.fips_config_dir);
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.offline_site_max_pages);
|
||||
db_kv_set("offline_site.max_pages", buf);
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.offline_site_request_delay_ms);
|
||||
db_kv_set("offline_site.request_delay_ms", buf);
|
||||
|
||||
db_kv_set("perf_probe.enabled", g_settings.perf_probe_enabled ? "true" : "false");
|
||||
|
||||
/* Agent LLM provider settings — resolved values (active provider). */
|
||||
db_kv_set("agent.llm_base_url", g_settings.agent_llm_base_url);
|
||||
db_kv_set("agent.llm_api_key", g_settings.agent_llm_api_key);
|
||||
|
||||
@@ -34,6 +34,8 @@ extern "C" {
|
||||
#define SETTINGS_AGENT_LLM_BASE_URL_DEFAULT "https://api.ppq.ai"
|
||||
#define SETTINGS_AGENT_LLM_MODEL_DEFAULT "gpt-4o"
|
||||
#define SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT 100
|
||||
#define SETTINGS_OFFLINE_SITE_MAX_PAGES_DEFAULT 500
|
||||
#define SETTINGS_OFFLINE_SITE_REQUEST_DELAY_MS_DEFAULT 100
|
||||
#define SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT \
|
||||
"You are an AI assistant embedded in a web browser. You have access to browser automation tools (navigate, snapshot, click, fill, etc.) and system tools (filesystem read/write, shell command execution). Use the snapshot tool to understand page content, then interact with elements using refs (e.g. @e1). You can read and write files and run shell commands. Be concise in your responses. When a task is complete, summarize what you did."
|
||||
|
||||
@@ -121,6 +123,21 @@ typedef struct {
|
||||
char fips_binary_path[256];
|
||||
char fips_control_socket[512];
|
||||
char fips_config_dir[512];
|
||||
|
||||
/* Offline website downloader (right-click tab → Download Website). */
|
||||
int offline_site_max_pages; /* safety cap on pages crawled */
|
||||
int offline_site_request_delay_ms; /* politeness delay between requests */
|
||||
|
||||
/* Per-tab performance probe (sovereign://processes). The probe patches
|
||||
* addEventListener / setTimeout / setInterval / XHR and runs a permanent
|
||||
* requestAnimationFrame loop on every page, which adds main-thread
|
||||
* overhead. On listener-heavy pages (e.g. NIP-17 DM clients that
|
||||
* re-register many listeners per re-render) this overhead is enough to
|
||||
* starve the main thread and break scrolling — the page's own
|
||||
* scroll-to-bottom rAF wins the race and yanks the view to the bottom.
|
||||
* Default OFF so normal browsing is not penalized; enable explicitly when
|
||||
* you want the processes/perf view. */
|
||||
gboolean perf_probe_enabled;
|
||||
} browser_settings_t;
|
||||
|
||||
/*
|
||||
|
||||
@@ -30,6 +30,10 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
|
||||
"new_window", "New window",
|
||||
"Open a new browser window", "<Control>n"
|
||||
},
|
||||
[SHORTCUT_OPEN_FILE] = {
|
||||
"open_file", "Open file",
|
||||
"Open a local file in the active tab", "<Control>o"
|
||||
},
|
||||
[SHORTCUT_CLOSE_TAB] = {
|
||||
"close_tab", "Close tab",
|
||||
"Close the active tab", "<Control>w"
|
||||
@@ -100,6 +104,11 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
|
||||
"toggle_sidebar", "Toggle agent sidebar",
|
||||
"Show or hide the agent chat sidebar", "<Control><Shift>a"
|
||||
},
|
||||
[SHORTCUT_TOGGLE_TOOLBARS] = {
|
||||
"toggle_toolbars", "Toggle menu bars",
|
||||
"Show or hide the tab strip, toolbars, and bookmark bars in the active window",
|
||||
"<Control><Shift>m"
|
||||
},
|
||||
[SHORTCUT_ZOOM_IN] = {
|
||||
"zoom_in", "Zoom in",
|
||||
"Increase the zoom level of the active tab", "<Control>plus"
|
||||
|
||||
@@ -29,6 +29,7 @@ extern "C" {
|
||||
typedef enum {
|
||||
SHORTCUT_NEW_TAB = 0,
|
||||
SHORTCUT_NEW_WINDOW,
|
||||
SHORTCUT_OPEN_FILE,
|
||||
SHORTCUT_CLOSE_TAB,
|
||||
SHORTCUT_FOCUS_URL,
|
||||
SHORTCUT_NEXT_TAB,
|
||||
@@ -46,6 +47,7 @@ typedef enum {
|
||||
SHORTCUT_TOGGLE_FULLSCREEN,
|
||||
SHORTCUT_TOGGLE_INSPECTOR,
|
||||
SHORTCUT_TOGGLE_SIDEBAR,
|
||||
SHORTCUT_TOGGLE_TOOLBARS,
|
||||
SHORTCUT_ZOOM_IN,
|
||||
SHORTCUT_ZOOM_OUT,
|
||||
SHORTCUT_ZOOM_RESET,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* site_downloader.h — download a website for offline viewing
|
||||
*
|
||||
* Triggered from the tab right-click context menu ("Download Website").
|
||||
* Crawls the current site (same-origin, bounded by a page-count cap),
|
||||
* saves all pages and assets to a per-profile directory, rewrites links
|
||||
* to relative paths, and opens the offline copy in a new tab via file://.
|
||||
*
|
||||
* See plans/download-website-offline.md for the full design.
|
||||
*/
|
||||
|
||||
#ifndef SITE_DOWNLOADER_H
|
||||
#define SITE_DOWNLOADER_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Start downloading the website at start_url.
|
||||
*
|
||||
* Shows a modal progress dialog (pages/files counts + progress bar +
|
||||
* Cancel button) parented to `parent`. A background thread runs a BFS
|
||||
* crawl over same-origin links using libsoup, saving pages and assets
|
||||
* under ~/.sovereign_browser/profiles/<pubkey>/offline_sites/<host>/
|
||||
* (or the global ~/.sovereign_browser/offline_sites/<host>/ when not
|
||||
* logged in). On completion or cancel, the progress dialog is closed
|
||||
* and the offline index.html is opened in a new tab.
|
||||
*
|
||||
* No-op (shows an error dialog) for non-http(s) URLs.
|
||||
*
|
||||
* start_url — absolute http:// or https:// URL of the page to download
|
||||
* parent — the GtkWindow to parent the progress dialog to (may be NULL)
|
||||
*/
|
||||
void site_downloader_start(const char *start_url, GtkWindow *parent);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* SITE_DOWNLOADER_H */
|
||||
+306
-18
@@ -21,6 +21,7 @@
|
||||
#include "db.h"
|
||||
#include "net_services.h"
|
||||
#include "agent_chat.h" /* agent_chat_route_input() for ";" URL-bar shortcut */
|
||||
#include "site_downloader.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <strings.h> /* strcasecmp */
|
||||
@@ -90,6 +91,8 @@ typedef struct {
|
||||
GtkWidget *sidebar_container; /* GtkBox for sidebar webview */
|
||||
WebKitWebView *sidebar_webview; /* lazily created on first toggle */
|
||||
gboolean sidebar_visible;
|
||||
gboolean toolbars_visible; /* per-window menu-bar (tab strip +
|
||||
* toolbars + bookmark bars) visibility */
|
||||
} window_state_t;
|
||||
|
||||
/* ── Static state ─────────────────────────────────────────────────── */
|
||||
@@ -136,9 +139,20 @@ static GtkWidget *g_target_notebook = NULL;
|
||||
* When non-NULL, tab_create() creates the webview with
|
||||
* webkit_web_view_new_with_related_view() so it shares the parent's
|
||||
* WebProcess and window features (avoids the WindowFeatures assertion
|
||||
* crash). Used by tab_manager_new_window(). */
|
||||
* crash). Used by tab_manager_new_window() and on_create_webview(). */
|
||||
static WebKitWebView *g_target_related_view = NULL;
|
||||
|
||||
/* When TRUE, tab_create() skips the explicit webkit_web_view_load_uri()
|
||||
* call. This is only needed in the "create" signal path (on_create_webview),
|
||||
* where WebKit already has a pending NavigationAction with WindowFeatures
|
||||
* that it will apply to the returned webview itself — an explicit load
|
||||
* here would race with WebKit's own navigation setup and trigger the
|
||||
* std::optional<WindowFeatures> assertion crash.
|
||||
*
|
||||
* tab_manager_new_window() does NOT set this: it creates a fresh webview
|
||||
* with no pending WebKit navigation, so it MUST load the URL explicitly. */
|
||||
static gboolean g_target_skip_load = FALSE;
|
||||
|
||||
/* 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;
|
||||
@@ -168,6 +182,7 @@ static int tab_array_find(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);
|
||||
static void on_tab_download_website(GtkMenuItem *item, gpointer data);
|
||||
static void on_avatar_clicked(GtkButton *btn, gpointer data);
|
||||
static GtkWidget *tab_manager_new_window(const char *url,
|
||||
WebKitWebView *related_view);
|
||||
@@ -797,6 +812,15 @@ static gboolean on_notebook_button_press(GtkWidget *widget,
|
||||
GINT_TO_POINTER(index));
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
|
||||
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||||
gtk_separator_menu_item_new());
|
||||
|
||||
GtkWidget *item_dl =
|
||||
gtk_menu_item_new_with_label("Download Website");
|
||||
g_signal_connect(item_dl, "activate",
|
||||
G_CALLBACK(on_tab_download_website), tab);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dl);
|
||||
|
||||
gtk_widget_show_all(menu);
|
||||
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
|
||||
return TRUE;
|
||||
@@ -875,6 +899,15 @@ static gboolean on_tab_label_button_press(GtkWidget *widget,
|
||||
GINT_TO_POINTER(index));
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
|
||||
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
|
||||
gtk_separator_menu_item_new());
|
||||
|
||||
GtkWidget *item_dl =
|
||||
gtk_menu_item_new_with_label("Download Website");
|
||||
g_signal_connect(item_dl, "activate",
|
||||
G_CALLBACK(on_tab_download_website), tab);
|
||||
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dl);
|
||||
|
||||
gtk_widget_show_all(menu);
|
||||
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
|
||||
return TRUE;
|
||||
@@ -890,6 +923,19 @@ static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data) {
|
||||
tab_manager_new_tab(NULL);
|
||||
}
|
||||
|
||||
/* "Download Website" — crawl the tab's current URL for offline viewing.
|
||||
* The tab_info_t is passed as data; we read its current_url and hand off
|
||||
* to site_downloader_start, which shows a progress dialog and opens the
|
||||
* offline copy in a new tab when done. */
|
||||
static void on_tab_download_website(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
tab_info_t *tab = (tab_info_t *)data;
|
||||
if (tab == NULL) return;
|
||||
if (tab->current_url[0] == '\0') return;
|
||||
GtkWindow *parent = g_active_window ? g_active_window : g_window;
|
||||
site_downloader_start(tab->current_url, parent);
|
||||
}
|
||||
|
||||
/* ── Favicon handling ─────────────────────────────────────────────── *
|
||||
* WebKitGTK has a built-in favicon database (WebKitFaviconDatabase) that
|
||||
* automatically fetches and caches favicons as pages load. It must be
|
||||
@@ -1028,6 +1074,18 @@ static gboolean on_decide_policy(WebKitWebView *webview,
|
||||
}
|
||||
}
|
||||
|
||||
/* Rewrite file:// URIs to local:// so pages get a proper SecurityOrigin
|
||||
* with full storage quota (localStorage, IndexedDB). WebKit assigns a
|
||||
* 0-byte quota to file:// origins, but local:// creates a non-opaque
|
||||
* origin with the default storage quota. */
|
||||
if (uri && strncmp(uri, "file://", 7) == 0) {
|
||||
char *local_uri = g_strconcat("local", uri + 4, NULL); /* "file" -> "local" */
|
||||
g_print("[decide-policy] Rewriting %s -> %s\n", uri, local_uri);
|
||||
webkit_policy_decision_ignore(decision);
|
||||
defer_load_uri(webview, local_uri); /* takes ownership */
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* NIP-21 links use nostr:entity rather than nostr://entity. Normalize
|
||||
* either form so WebKit consistently invokes our registered handler. */
|
||||
if (uri && strncmp(uri, "nostr:", 6) == 0 &&
|
||||
@@ -1117,9 +1175,17 @@ static GtkWidget *on_create_webview(WebKitWebView *webview,
|
||||
if (uri && uri[0]) {
|
||||
/* Set the related view so tab_create() uses
|
||||
* webkit_web_view_new_with_related_view() — shares the parent's
|
||||
* WebProcess and avoids the WindowFeatures assertion crash. */
|
||||
* WebProcess and avoids the WindowFeatures assertion crash. Also
|
||||
* set g_target_skip_load: WebKit already has a pending
|
||||
* NavigationAction with WindowFeatures that it will apply to the
|
||||
* returned webview itself, so tab_create() must NOT call
|
||||
* webkit_web_view_load_uri() (that would race with WebKit's own
|
||||
* navigation setup and trigger the std::optional<WindowFeatures>
|
||||
* assertion crash). */
|
||||
g_target_related_view = webview;
|
||||
g_target_skip_load = TRUE;
|
||||
int idx = tab_manager_new_tab(uri);
|
||||
g_target_skip_load = FALSE;
|
||||
g_target_related_view = NULL;
|
||||
|
||||
if (idx >= 0) {
|
||||
@@ -1275,8 +1341,14 @@ static GtkWidget *tab_manager_new_window(const char *url,
|
||||
}
|
||||
|
||||
/* Inject the per-tab performance probe (sovereign://processes).
|
||||
* Skips internal sovereign:// pages at runtime via the preamble. */
|
||||
perf_probe_setup(tab->webview, index);
|
||||
* Skips internal sovereign:// pages at runtime via the preamble.
|
||||
* Gated on the perf_probe_enabled setting: the probe patches
|
||||
* addEventListener/setTimeout/setInterval/XHR and runs a permanent
|
||||
* rAF loop, which adds enough main-thread overhead to break
|
||||
* scrolling on listener-heavy pages. Default OFF. */
|
||||
if (settings_get()->perf_probe_enabled) {
|
||||
perf_probe_setup(tab->webview, index);
|
||||
}
|
||||
|
||||
const browser_settings_t *s = settings_get();
|
||||
int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(notebook),
|
||||
@@ -1316,6 +1388,7 @@ static GtkWidget *tab_manager_new_window(const char *url,
|
||||
ws.sidebar_container = sidebar_container;
|
||||
ws.sidebar_webview = NULL;
|
||||
ws.sidebar_visible = FALSE;
|
||||
ws.toolbars_visible = TRUE;
|
||||
g_array_append_val(g_aux_windows, ws);
|
||||
|
||||
/* This new window is now the active window. */
|
||||
@@ -1423,6 +1496,14 @@ static void on_url_activate(GtkEntry *entry, gpointer user_data) {
|
||||
|
||||
char *url = normalize_url(text);
|
||||
if (url != NULL) {
|
||||
/* Rewrite file:// to local:// so pages get a proper SecurityOrigin
|
||||
* with full storage quota (localStorage, IndexedDB). */
|
||||
if (strncmp(url, "file://", 7) == 0) {
|
||||
char *local_url = g_strconcat("local", url + 4, NULL);
|
||||
g_print("[url-bar] Rewriting %s -> %s\n", url, local_url);
|
||||
g_free(url);
|
||||
url = local_url;
|
||||
}
|
||||
webkit_web_view_load_uri(tab->webview, url);
|
||||
g_free(url);
|
||||
}
|
||||
@@ -1571,6 +1652,30 @@ static gboolean on_refresh_button_press(GtkWidget *widget,
|
||||
return FALSE; /* Let left-click propagate to "clicked" signal */
|
||||
}
|
||||
|
||||
/* ── Load progress bar ──────────────────────────────────────────────── *
|
||||
* WebKitGTK emits notify::estimated-load-progress continuously during
|
||||
* page fetch. We drive a thin progress bar between LOAD_STARTED and
|
||||
* LOAD_FINISHED so the user sees activity even when the page takes a
|
||||
* while to download. The bar is hidden on LOAD_FINISHED / LOAD_FAILED. */
|
||||
static void on_load_progress_changed(WebKitWebView *webview,
|
||||
GParamSpec *pspec,
|
||||
gpointer user_data) {
|
||||
(void)pspec;
|
||||
tab_info_t *tab = (tab_info_t *)user_data;
|
||||
if (tab == NULL || tab->progress_bar == NULL) return;
|
||||
|
||||
gdouble progress = webkit_web_view_get_estimated_load_progress(webview);
|
||||
if (progress < 0.01) {
|
||||
/* Not yet loading — keep hidden. */
|
||||
return;
|
||||
}
|
||||
|
||||
if (!gtk_widget_get_visible(tab->progress_bar)) {
|
||||
gtk_widget_set_visible(tab->progress_bar, TRUE);
|
||||
}
|
||||
gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(tab->progress_bar), progress);
|
||||
}
|
||||
|
||||
static void on_load_changed(WebKitWebView *webview,
|
||||
WebKitLoadEvent load_event,
|
||||
gpointer user_data) {
|
||||
@@ -1638,6 +1743,12 @@ static void on_load_changed(WebKitWebView *webview,
|
||||
/* Restore the reload button visual now that loading is done. */
|
||||
tab_refresh_button_update(tab);
|
||||
|
||||
/* Hide the load-progress bar. */
|
||||
if (tab != NULL && tab->progress_bar != NULL &&
|
||||
gtk_widget_get_visible(tab->progress_bar)) {
|
||||
gtk_widget_set_visible(tab->progress_bar, FALSE);
|
||||
}
|
||||
|
||||
/* Update tab title if already available (works for tabs in any
|
||||
* window's notebook). The notify::title handler covers the case
|
||||
* where the title arrives after load-finished. */
|
||||
@@ -1678,6 +1789,12 @@ static gboolean on_load_failed(WebKitWebView *webview,
|
||||
* user clicks the X to cancel a load. */
|
||||
tab_refresh_button_update(tab);
|
||||
|
||||
/* Hide the load-progress bar on failure/cancellation. */
|
||||
if (tab != NULL && tab->progress_bar != NULL &&
|
||||
gtk_widget_get_visible(tab->progress_bar)) {
|
||||
gtk_widget_set_visible(tab->progress_bar, FALSE);
|
||||
}
|
||||
|
||||
/* Keep the failed URL in the address bar so the user can see what
|
||||
* failed and edit/retry. Without this, WebKit reverts to about:blank
|
||||
* and the user loses the URL they typed or clicked. */
|
||||
@@ -1726,6 +1843,14 @@ static void on_title_changed(WebKitWebView *webview,
|
||||
|
||||
/* ── Web view context menu (right-click) ──────────────────────────── */
|
||||
|
||||
/* Context passed to on_context_open_in_new_window(): the link URL and the
|
||||
* webview that was right-clicked (used as the related view so the new
|
||||
* window shares the parent's WebProcess). */
|
||||
struct new_window_ctx {
|
||||
char *url;
|
||||
WebKitWebView *related_view;
|
||||
};
|
||||
|
||||
/* Callback for "Open Link in New Tab" / "Open Page in New Tab". */
|
||||
static void on_context_open_in_new_tab(GSimpleAction *action,
|
||||
GVariant *parameter,
|
||||
@@ -1739,6 +1864,47 @@ static void on_context_open_in_new_tab(GSimpleAction *action,
|
||||
g_free(url);
|
||||
}
|
||||
|
||||
/* Idle callback that actually creates the new window. We defer the
|
||||
* tab_manager_new_window() call out of the GAction "activate" handler
|
||||
* because that runs inside the GtkMenu's signal emission / GSource
|
||||
* teardown. Creating a new top-level window (which calls
|
||||
* gtk_widget_show_all + gtk_window_present and thus processes pending
|
||||
* events) from within that handler caused GLib-CRITICAL
|
||||
* g_source_ref/g_source_unref assertion failures as the menu's own
|
||||
* GSources were being released concurrently. Running on the next idle
|
||||
* tick lets the menu fully close first. */
|
||||
static gboolean on_idle_open_in_new_window(gpointer user_data) {
|
||||
struct new_window_ctx *ctx = (struct new_window_ctx *)user_data;
|
||||
if (ctx && ctx->url && ctx->url[0]) {
|
||||
tab_manager_new_window(ctx->url, ctx->related_view);
|
||||
}
|
||||
if (ctx) {
|
||||
g_free(ctx->url);
|
||||
g_slice_free(struct new_window_ctx, ctx);
|
||||
}
|
||||
return G_SOURCE_REMOVE; /* run once */
|
||||
}
|
||||
|
||||
/* Callback for "Open Link in New Window". Opens the link URL in a brand
|
||||
* new top-level GtkWindow (via tab_manager_new_window) instead of a new
|
||||
* tab. The related view (the webview that was right-clicked) is passed so
|
||||
* the new window shares the parent's WebProcess. The actual window
|
||||
* creation is deferred to an idle handler (see on_idle_open_in_new_window)
|
||||
* to avoid GLib-CRITICAL source-refcount warnings from creating the
|
||||
* window inside the menu's activate callback. */
|
||||
static void on_context_open_in_new_window(GSimpleAction *action,
|
||||
GVariant *parameter,
|
||||
gpointer user_data) {
|
||||
(void)action;
|
||||
(void)parameter;
|
||||
/* Steal the ctx and defer to idle — do NOT free it here; the idle
|
||||
* callback owns and frees it. */
|
||||
struct new_window_ctx *ctx = (struct new_window_ctx *)user_data;
|
||||
if (ctx) {
|
||||
g_idle_add(on_idle_open_in_new_window, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/* Context menu signal handler for the webview. Appends "Open in New Tab"
|
||||
* to the default WebKit context menu — either "Open Link in New Tab" if
|
||||
* right-clicking a link, or "Open Page in New Tab" if right-clicking the
|
||||
@@ -1788,21 +1954,63 @@ static gboolean on_webview_context_menu(WebKitWebView *webview,
|
||||
NULL);
|
||||
|
||||
/* For links: WebKit's default menu starts with "Open Link" (pos 0)
|
||||
* then "Open Link in New Window" (pos 1). Insert "Open Link in New
|
||||
* Tab" at position 1 — between "Open Link" and "Open Link in New
|
||||
* Window" — so the order is:
|
||||
* then "Open Link in New Window" (pos 1). We replace that default
|
||||
* "Open Link in New Window" item with our own that actually opens a
|
||||
* real top-level GtkWindow (WebKit's default fires the "create"
|
||||
* signal, which our on_create_webview handler routes to a new *tab*,
|
||||
* so without this replacement "Open in New Window" would wrongly open
|
||||
* a tab). We then insert "Open Link in New Tab" at position 1, so the
|
||||
* final order is:
|
||||
* Open Link
|
||||
* Open Link in New Tab
|
||||
* Open Link in New Window
|
||||
* Open Link in New Window (ours — real new window)
|
||||
*
|
||||
* For page background: insert at position 0 (top). */
|
||||
int insert_pos = is_link ? 1 : 0;
|
||||
* For page background: insert the "Open Page in New Tab" item at
|
||||
* position 0 (top). */
|
||||
int insert_pos;
|
||||
if (is_link) {
|
||||
/* Find and remove WebKit's stock "Open Link in New Window" item
|
||||
* so we can replace it with our own below. */
|
||||
guint n = webkit_context_menu_get_n_items(context_menu);
|
||||
for (guint i = 0; i < n; i++) {
|
||||
WebKitContextMenuItem *it =
|
||||
webkit_context_menu_get_item_at_position(context_menu, i);
|
||||
if (it == NULL) continue;
|
||||
if (webkit_context_menu_item_get_stock_action(it) ==
|
||||
WEBKIT_CONTEXT_MENU_ACTION_OPEN_LINK_IN_NEW_WINDOW) {
|
||||
webkit_context_menu_remove(context_menu, it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
insert_pos = 1;
|
||||
} else {
|
||||
insert_pos = 0;
|
||||
}
|
||||
webkit_context_menu_insert(context_menu, item, insert_pos);
|
||||
|
||||
/* The action is owned by the menu item now. g_object_unref is safe
|
||||
* because the WebKitContextMenuItem holds its own ref. */
|
||||
g_object_unref(action);
|
||||
|
||||
/* For links: also add our own "Open Link in New Window" item that
|
||||
* opens a real top-level GtkWindow. Insert it right after the
|
||||
* "Open Link in New Tab" item we just added (position 2). */
|
||||
if (is_link) {
|
||||
GSimpleAction *win_action =
|
||||
g_simple_action_new("open-in-new-window", NULL);
|
||||
struct new_window_ctx *ctx = g_slice_new(struct new_window_ctx);
|
||||
ctx->url = g_strdup(url);
|
||||
ctx->related_view = webview; /* share WebProcess with parent */
|
||||
g_signal_connect(win_action, "activate",
|
||||
G_CALLBACK(on_context_open_in_new_window), ctx);
|
||||
|
||||
WebKitContextMenuItem *win_item =
|
||||
webkit_context_menu_item_new_from_gaction(
|
||||
G_ACTION(win_action), "Open Link in New Window", NULL);
|
||||
webkit_context_menu_insert(context_menu, win_item, 2);
|
||||
g_object_unref(win_action);
|
||||
}
|
||||
|
||||
return FALSE; /* Let WebKit show the menu. */
|
||||
}
|
||||
|
||||
@@ -1953,9 +2161,11 @@ static void on_menu_toggle_sidebar(GtkMenuItem *item, gpointer data) {
|
||||
tab_manager_toggle_sidebar();
|
||||
}
|
||||
|
||||
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
/* Show the "Open File" dialog and load the chosen file into the active
|
||||
* tab. Shared by the hamburger-menu callback and the Ctrl+O keyboard
|
||||
* shortcut. Parents the dialog to the focused window so it appears over
|
||||
* the window the user invoked it from. No-op if there is no window. */
|
||||
void tab_manager_open_file(void) {
|
||||
/* Parent the dialog to the currently focused window (g_active_window)
|
||||
* rather than always the main window (g_window), so the file chooser
|
||||
* appears over the window the user invoked it from. g_active_window is
|
||||
@@ -1993,6 +2203,12 @@ static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
|
||||
gtk_widget_destroy(dialog);
|
||||
}
|
||||
|
||||
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
|
||||
(void)item;
|
||||
(void)data;
|
||||
tab_manager_open_file();
|
||||
}
|
||||
|
||||
static void on_menu_history_item(GtkMenuItem *item, gpointer data) {
|
||||
(void)data;
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
@@ -2764,6 +2980,13 @@ static tab_info_t *tab_create(const char *url) {
|
||||
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);
|
||||
/* Rendering performance: force hardware acceleration always on and
|
||||
* enable smooth scrolling. These are off by default in WebKitGTK on
|
||||
* many distros but significantly improve hover/scroll responsiveness
|
||||
* even on llvmpipe software rendering. */
|
||||
webkit_settings_set_hardware_acceleration_policy(settings,
|
||||
WEBKIT_HARDWARE_ACCELERATION_POLICY_ALWAYS);
|
||||
webkit_settings_set_enable_smooth_scrolling(settings, TRUE);
|
||||
|
||||
/* Enhance native application/json documents at document end. The
|
||||
* embedded file is not NUL-terminated, so copy it before passing it to
|
||||
@@ -2827,6 +3050,7 @@ static tab_info_t *tab_create(const char *url) {
|
||||
gtk_widget_set_margin_bottom(toolbar, 4);
|
||||
gtk_widget_set_margin_start(toolbar, 4);
|
||||
gtk_widget_set_margin_end(toolbar, 4);
|
||||
tab->toolbar = toolbar;
|
||||
gtk_box_pack_start(GTK_BOX(tab->page), toolbar, FALSE, FALSE, 0);
|
||||
|
||||
tab->hamburger = build_hamburger_menu(tab);
|
||||
@@ -2946,6 +3170,27 @@ static tab_info_t *tab_create(const char *url) {
|
||||
gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0);
|
||||
bookmark_bar_refresh(tab);
|
||||
|
||||
/* Thin load-progress bar between the bookmark bar and the webview.
|
||||
* Hidden by default; shown during page loads via the
|
||||
* notify::estimated-load-progress signal. */
|
||||
tab->progress_bar = gtk_progress_bar_new();
|
||||
gtk_progress_bar_set_show_text(GTK_PROGRESS_BAR(tab->progress_bar), FALSE);
|
||||
gtk_widget_set_size_request(tab->progress_bar, -1, 3);
|
||||
gtk_widget_set_no_show_all(tab->progress_bar, TRUE);
|
||||
gtk_widget_set_visible(tab->progress_bar, FALSE);
|
||||
gtk_box_pack_start(GTK_BOX(tab->page), tab->progress_bar, FALSE, FALSE, 0);
|
||||
|
||||
/* Apply the active window's menu-bar visibility so a tab opened in a
|
||||
* hidden-chrome window is also hidden. The tab strip itself is
|
||||
* per-notebook (gtk_notebook_set_show_tabs), already set by the
|
||||
* toggle; here we only sync the per-tab toolbar + bookmark bar. */
|
||||
{
|
||||
window_state_t *ws = get_active_window_state();
|
||||
gboolean vis = ws ? ws->toolbars_visible : TRUE;
|
||||
gtk_widget_set_visible(tab->toolbar, vis);
|
||||
gtk_widget_set_visible(tab->bookmark_bar, vis);
|
||||
}
|
||||
|
||||
/* Ensure the webview expands vertically to fill the available space.
|
||||
* Without this, WebKitGTK may only allocate 1px of height on some
|
||||
* display servers, resulting in a blank page. */
|
||||
@@ -2973,6 +3218,8 @@ static tab_info_t *tab_create(const char *url) {
|
||||
G_CALLBACK(on_load_changed), tab);
|
||||
g_signal_connect(tab->webview, "load-failed",
|
||||
G_CALLBACK(on_load_failed), tab);
|
||||
g_signal_connect(tab->webview, "notify::estimated-load-progress",
|
||||
G_CALLBACK(on_load_progress_changed), tab);
|
||||
g_signal_connect(tab->webview, "notify::favicon",
|
||||
G_CALLBACK(on_favicon_changed), tab);
|
||||
g_signal_connect(tab->webview, "notify::title",
|
||||
@@ -2996,7 +3243,7 @@ static tab_info_t *tab_create(const char *url) {
|
||||
* _M_get() const: Assertion 'this->_M_is_engaged()' failed.
|
||||
* So we skip the explicit load and let WebKit drive the navigation. The
|
||||
* tab's URL bar / title were already initialized from default_url above. */
|
||||
if (g_target_related_view == NULL) {
|
||||
if (!g_target_skip_load) {
|
||||
webkit_web_view_load_uri(tab->webview, default_url);
|
||||
}
|
||||
g_free(default_url);
|
||||
@@ -3492,6 +3739,7 @@ void tab_manager_init(GtkContainer *parent,
|
||||
g_main_window.notebook = g_notebook;
|
||||
g_main_window.sidebar_webview = NULL;
|
||||
g_main_window.sidebar_visible = FALSE;
|
||||
g_main_window.toolbars_visible = TRUE;
|
||||
|
||||
/* The main window/notebook are the default active window/notebook
|
||||
* for MCP get_active_webview() and keyboard shortcuts (zoom, etc.).
|
||||
@@ -3549,8 +3797,11 @@ int tab_manager_new_tab(const char *url) {
|
||||
}
|
||||
|
||||
/* Inject the per-tab performance probe (sovereign://processes).
|
||||
* Skips internal sovereign:// pages at runtime via the preamble. */
|
||||
perf_probe_setup(tab->webview, index);
|
||||
* Skips internal sovereign:// pages at runtime via the preamble.
|
||||
* Gated on the perf_probe_enabled setting (see comment above). */
|
||||
if (settings_get()->perf_probe_enabled) {
|
||||
perf_probe_setup(tab->webview, index);
|
||||
}
|
||||
|
||||
/* Add to the effective notebook (active window's notebook, or the
|
||||
* main notebook as fallback). This makes Ctrl+T / "New Tab" open in
|
||||
@@ -3577,8 +3828,23 @@ int tab_manager_new_tab(const char *url) {
|
||||
gtk_widget_grab_focus(tab->url_entry);
|
||||
gtk_editable_select_region(GTK_EDITABLE(tab->url_entry), 0, -1);
|
||||
|
||||
g_print("[tabs] Created tab %d, total: %d\n", page_num, g_tab_count);
|
||||
return page_num;
|
||||
g_print("[tabs] Created tab %d (page %d), total: %d\n",
|
||||
index, page_num, g_tab_count);
|
||||
|
||||
/* Return the g_tabs array index (NOT the notebook page_num).
|
||||
*
|
||||
* The notebook page number only matches the g_tabs index for the
|
||||
* main window; auxiliary windows have their own page numbering, and
|
||||
* even in the main window the two can diverge after tabs are closed
|
||||
* and reopened. The only caller that uses the return value
|
||||
* (on_create_webview, for the WebKit "create" signal) passes it to
|
||||
* tab_manager_get(), which expects a g_tabs index. Returning
|
||||
* page_num there caused tab_manager_get() to resolve the WRONG tab
|
||||
* — an already-existing webview with no pending WindowFeatures —
|
||||
* which WebKit then tried to apply WindowFeatures to, triggering:
|
||||
* std::optional<WindowFeatures>::_M_get(): Assertion
|
||||
* 'this->_M_is_engaged()' failed. */
|
||||
return index;
|
||||
}
|
||||
|
||||
void tab_manager_close_tab(int index) {
|
||||
@@ -3997,6 +4263,28 @@ void tab_manager_toggle_sidebar(void) {
|
||||
}
|
||||
}
|
||||
|
||||
void tab_manager_toggle_toolbars(void) {
|
||||
window_state_t *ws = get_active_window_state();
|
||||
if (ws == NULL || ws->notebook == NULL) return;
|
||||
|
||||
ws->toolbars_visible = !ws->toolbars_visible;
|
||||
gboolean vis = ws->toolbars_visible;
|
||||
|
||||
/* Tab strip (GtkNotebook header) — per-notebook. */
|
||||
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(ws->notebook), vis);
|
||||
|
||||
/* Per-tab toolbar + bookmark bar, but only for tabs that belong to
|
||||
* this window's notebook (aux windows have their own notebook). */
|
||||
for (int i = 0; i < g_tab_count; i++) {
|
||||
tab_info_t *tab = g_tabs[i];
|
||||
if (tab == NULL || tab->page == NULL) continue;
|
||||
GtkWidget *nb = tab_find_notebook(tab->page);
|
||||
if (nb != ws->notebook) continue;
|
||||
if (tab->toolbar) gtk_widget_set_visible(tab->toolbar, vis);
|
||||
if (tab->bookmark_bar) gtk_widget_set_visible(tab->bookmark_bar, vis);
|
||||
}
|
||||
}
|
||||
|
||||
WebKitWebView *tab_manager_get_main_webview(void) {
|
||||
tab_info_t *tab = tab_manager_get_active();
|
||||
if (tab == NULL) return NULL;
|
||||
|
||||
@@ -29,7 +29,9 @@ typedef struct {
|
||||
GtkWidget *title_label; /* child of tab_label */
|
||||
GtkWidget *favicon; /* favicon image in tab_label */
|
||||
GtkWidget *refresh_btn; /* toolbar reload/stop button (per-tab) */
|
||||
GtkWidget *toolbar; /* #main-toolbar (hamburger + nav + URL) */
|
||||
GtkWidget *bookmark_bar; /* bookmarks toolbar (below URL toolbar) */
|
||||
GtkWidget *progress_bar; /* thin load-progress bar (below bookmark bar, above webview) */
|
||||
gboolean refresh_shows_stop; /* cached visual state, avoids redundant updates */
|
||||
char current_url[TAB_URL_MAX];
|
||||
char title[TAB_TITLE_MAX];
|
||||
@@ -79,6 +81,14 @@ void tab_manager_open_in_new_window(int index);
|
||||
*/
|
||||
void tab_manager_new_window_blank(void);
|
||||
|
||||
/*
|
||||
* Show the "Open File" dialog and load the chosen local file into the
|
||||
* active tab via a file:// URI. The dialog is parented to the focused
|
||||
* window. Used by the hamburger-menu "Open File…" item and the Ctrl+O
|
||||
* keyboard shortcut. No-op if there is no window or active tab.
|
||||
*/
|
||||
void tab_manager_open_file(void);
|
||||
|
||||
/*
|
||||
* Close the tab at the given index. If it's the last tab, the window
|
||||
* destroy handler will fire (caller is responsible for quitting).
|
||||
@@ -228,6 +238,15 @@ void tab_manager_zoom_reset(void);
|
||||
*/
|
||||
void tab_manager_toggle_sidebar(void);
|
||||
|
||||
/*
|
||||
* Toggle visibility of all "menu bars" in the active window: the tab strip
|
||||
* (GtkNotebook header), each tab's #main-toolbar, and each tab's bookmark
|
||||
* bar. Per-window — only the focused window is affected, mirroring
|
||||
* tab_manager_toggle_sidebar(). New tabs opened in a hidden-chrome window
|
||||
* inherit the hidden state.
|
||||
*/
|
||||
void tab_manager_toggle_toolbars(void);
|
||||
|
||||
/*
|
||||
* Returns the main webview (the web page) of the active tab, never the
|
||||
* sidebar webview. This is used by agent tools so they always operate
|
||||
|
||||
+2
-2
@@ -11,9 +11,9 @@
|
||||
#ifndef SOVEREIGN_BROWSER_VERSION_H
|
||||
#define SOVEREIGN_BROWSER_VERSION_H
|
||||
|
||||
#define SB_VERSION "v0.0.56"
|
||||
#define SB_VERSION "v0.0.68"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 56
|
||||
#define SB_VERSION_PATCH 68
|
||||
|
||||
#endif /* SOVEREIGN_BROWSER_VERSION_H */
|
||||
|
||||
+184
-1
@@ -9,6 +9,7 @@
|
||||
#include "profile.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#include <string.h>
|
||||
@@ -18,6 +19,173 @@
|
||||
* wants the per-user context. */
|
||||
static WebKitWebContext *g_ctx = NULL;
|
||||
|
||||
/* ── Download handling ─────────────────────────────────────────────── *
|
||||
* WebKitGTK fires the "download-started" signal on the WebKitWebContext
|
||||
* whenever the user triggers a download — most notably the "Save Image"
|
||||
* context-menu item on an <img>. In the webkit2gtk-4.1 API the download
|
||||
* does NOT auto-prompt for a destination: the application must handle the
|
||||
* download's "decide-destination" signal (which supplies a suggested
|
||||
* filename), call webkit_download_set_destination(), and return TRUE.
|
||||
* If nobody handles "decide-destination", WebKit writes the file to
|
||||
* G_USER_DIRECTORY_DOWNLOAD using the suggested name — but on many
|
||||
* minimal/containerized setups that directory resolves to a path the
|
||||
* user never sees, so "Save Image" appears to do nothing.
|
||||
*
|
||||
* Our flow:
|
||||
* download-started (context) → take a ref, wire finished/failed
|
||||
* decide-destination (download) → show a GtkFileChooserDialog pre-filled
|
||||
* with the suggested filename, set the
|
||||
* chosen destination, return TRUE
|
||||
* finished / failed → unref the download
|
||||
*
|
||||
* Since 2.40, returning TRUE from "decide-destination" without
|
||||
* immediately calling webkit_download_set_destination() is allowed: the
|
||||
* download pauses until the destination is set or the download is
|
||||
* cancelled. We use that to run a modal file chooser inside the handler.
|
||||
*/
|
||||
|
||||
/* Find a reasonable parent GtkWindow for the file chooser. We prefer the
|
||||
* currently-active (focused) top-level window; if none is focused we fall
|
||||
* back to the first visible top-level. web_context.c deliberately does
|
||||
* not depend on tab_manager.c's g_active_window, so this stays self-
|
||||
* contained. Returns NULL if no usable window is found. */
|
||||
static GtkWindow *pick_dialog_parent(void) {
|
||||
GList *toplevels = gtk_window_list_toplevels();
|
||||
GtkWindow *active = NULL;
|
||||
GtkWindow *first_visible = NULL;
|
||||
|
||||
for (GList *l = toplevels; l != NULL; l = l->next) {
|
||||
GtkWindow *w = GTK_WINDOW(l->data);
|
||||
if (!gtk_widget_get_visible(GTK_WIDGET(w))) continue;
|
||||
if (first_visible == NULL) first_visible = w;
|
||||
if (gtk_window_is_active(w)) { active = w; break; }
|
||||
}
|
||||
|
||||
GtkWindow *result = active ? active : first_visible;
|
||||
if (result != NULL) g_object_ref_sink(G_OBJECT(result));
|
||||
g_list_free(toplevels);
|
||||
return result; /* caller unrefs */
|
||||
}
|
||||
|
||||
/* Reduce a possibly-full suggested path to its final path segment so the
|
||||
* save dialog never starts in an unexpected directory. Returns a newly
|
||||
* allocated string. */
|
||||
static char *basename_of(const char *name) {
|
||||
if (name == NULL || name[0] == '\0') return NULL;
|
||||
const char *slash = strrchr(name, '/');
|
||||
return g_strdup(slash ? slash + 1 : name);
|
||||
}
|
||||
|
||||
static void on_download_finished(WebKitDownload *dl, gpointer user) {
|
||||
(void)user;
|
||||
const char *dest = webkit_download_get_destination(dl);
|
||||
g_print("[download] Finished: %s\n", dest ? dest : "(no destination)");
|
||||
g_object_unref(dl);
|
||||
}
|
||||
|
||||
static void on_download_failed(WebKitDownload *dl, GError *err, gpointer user) {
|
||||
(void)user;
|
||||
g_printerr("[download] Failed: %s\n", err ? err->message : "(unknown)");
|
||||
g_object_unref(dl);
|
||||
}
|
||||
|
||||
/* "decide-destination" handler. WebKit passes the suggested filename
|
||||
* (derived from the response Content-Disposition / URL). We show a save
|
||||
* dialog, set the destination, and return TRUE. Returning TRUE without
|
||||
* setting a destination is explicitly supported since 2.40 and pauses
|
||||
* the download until we set one (or cancel). */
|
||||
|
||||
/* Remember the directory the user last saved a download to, so the next
|
||||
* save dialog opens in the same folder instead of the default. Owned by
|
||||
* this module (g_free'd on teardown). NULL until the first successful
|
||||
* save. */
|
||||
static char *g_last_save_dir = NULL;
|
||||
|
||||
static gboolean on_download_decide_destination(WebKitDownload *dl,
|
||||
const char *suggested,
|
||||
gpointer user) {
|
||||
(void)user;
|
||||
|
||||
char *default_name = basename_of(suggested);
|
||||
if (default_name == NULL || default_name[0] == '\0') {
|
||||
g_free(default_name);
|
||||
default_name = g_strdup("download");
|
||||
}
|
||||
|
||||
GtkWindow *parent = pick_dialog_parent();
|
||||
|
||||
GtkWidget *dialog = gtk_file_chooser_dialog_new(
|
||||
"Save File", parent, GTK_FILE_CHOOSER_ACTION_SAVE,
|
||||
"_Cancel", GTK_RESPONSE_CANCEL,
|
||||
"_Save", GTK_RESPONSE_ACCEPT,
|
||||
NULL);
|
||||
if (parent) g_object_unref(parent);
|
||||
|
||||
GtkFileChooser *chooser = GTK_FILE_CHOOSER(dialog);
|
||||
|
||||
/* If the user saved a download before, open the dialog in that same
|
||||
* directory. Otherwise GTK falls back to the current working
|
||||
* directory / last-used folder for the application. */
|
||||
if (g_last_save_dir != NULL && g_last_save_dir[0] != '\0') {
|
||||
gtk_file_chooser_set_current_folder(chooser, g_last_save_dir);
|
||||
}
|
||||
|
||||
gtk_file_chooser_set_current_name(chooser, default_name);
|
||||
gtk_file_chooser_set_do_overwrite_confirmation(chooser, TRUE);
|
||||
|
||||
gint resp = gtk_dialog_run(GTK_DIALOG(dialog));
|
||||
char *path = (resp == GTK_RESPONSE_ACCEPT)
|
||||
? gtk_file_chooser_get_filename(chooser)
|
||||
: NULL;
|
||||
gtk_widget_destroy(dialog);
|
||||
g_free(default_name);
|
||||
|
||||
if (path == NULL) {
|
||||
/* User cancelled — abort the download. */
|
||||
webkit_download_cancel(dl);
|
||||
return TRUE; /* we handled it (by cancelling) */
|
||||
}
|
||||
|
||||
/* Remember the directory the user chose for next time. */
|
||||
char *dir = g_path_get_dirname(path);
|
||||
if (dir != NULL && dir[0] != '\0' &&
|
||||
(g_last_save_dir == NULL ||
|
||||
g_strcmp0(dir, g_last_save_dir) != 0)) {
|
||||
g_free(g_last_save_dir);
|
||||
g_last_save_dir = g_strdup(dir);
|
||||
}
|
||||
g_free(dir);
|
||||
|
||||
char *uri = g_filename_to_uri(path, NULL, NULL);
|
||||
g_free(path);
|
||||
if (uri == NULL) {
|
||||
webkit_download_cancel(dl);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
webkit_download_set_destination(dl, uri);
|
||||
g_free(uri);
|
||||
return TRUE; /* stop other handlers; destination is now set */
|
||||
}
|
||||
|
||||
static void on_download_started(WebKitWebContext *ctx,
|
||||
WebKitDownload *dl,
|
||||
gpointer user_data) {
|
||||
(void)ctx;
|
||||
(void)user_data;
|
||||
|
||||
/* Hold a ref for the lifetime of the download so our finished/failed
|
||||
* handlers can safely access it; we unref there. */
|
||||
g_object_ref(dl);
|
||||
|
||||
g_signal_connect(dl, "decide-destination",
|
||||
G_CALLBACK(on_download_decide_destination), NULL);
|
||||
g_signal_connect(dl, "finished",
|
||||
G_CALLBACK(on_download_finished), NULL);
|
||||
g_signal_connect(dl, "failed",
|
||||
G_CALLBACK(on_download_failed), NULL);
|
||||
}
|
||||
|
||||
/* ── Security / TLS / favicon configuration ────────────────────────── *
|
||||
* Applied to every context we build (per-user and ephemeral). Mirrors
|
||||
* the configuration that main.c used to apply to the default context.
|
||||
@@ -27,12 +195,21 @@ static void configure_context(WebKitWebContext *ctx) {
|
||||
|
||||
/* Security strip: register sovereign://, tor://, file:// as secure
|
||||
* (no CORS enforcement), and file:// as local. Same as the original
|
||||
* main.c configuration. */
|
||||
* main.c configuration.
|
||||
*
|
||||
* Also register ws (insecure WebSocket) as secure. Without this,
|
||||
* WebKitGTK blocks ws:// connections opened from https:// pages as
|
||||
* mixed active content (a secure page may not connect to an
|
||||
* insecure origin). This browser is intentionally open and should
|
||||
* be able to connect to anywhere, including local plaintext
|
||||
* WebSocket services, so we treat ws:// as a secure scheme — the
|
||||
* same status wss:// already has by default. */
|
||||
WebKitSecurityManager *sec_mgr =
|
||||
webkit_web_context_get_security_manager(ctx);
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "tor");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "ws");
|
||||
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
|
||||
|
||||
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
|
||||
@@ -58,6 +235,12 @@ static void configure_context(WebKitWebContext *ctx) {
|
||||
g_get_tmp_dir());
|
||||
}
|
||||
webkit_web_context_set_favicon_database_directory(ctx, fav_dir);
|
||||
|
||||
/* Handle downloads — most importantly the "Save Image" context-menu
|
||||
* action. Without this handler WebKitGTK cancels the download (and
|
||||
* the user sees nothing happen). See on_download_started() above. */
|
||||
g_signal_connect(ctx, "download-started",
|
||||
G_CALLBACK(on_download_started), NULL);
|
||||
}
|
||||
|
||||
WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex) {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hover Responsiveness Test</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
padding: 40px;
|
||||
}
|
||||
h1 { margin-bottom: 10px; }
|
||||
p { margin-bottom: 30px; color: #aaa; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #16213e;
|
||||
border: 2px solid #0f3460;
|
||||
border-radius: 12px;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.05s, border-color 0.05s, transform 0.05s, box-shadow 0.05s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Hover effect: quick background + border + shadow change */
|
||||
.card:hover {
|
||||
background: #e94560;
|
||||
border-color: #e94560;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 20px rgba(233, 69, 96, 0.5);
|
||||
}
|
||||
|
||||
.card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.card .icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card .label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#fps-display {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: #0f0;
|
||||
font-family: monospace;
|
||||
font-size: 18px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
#instructions {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: #ff0;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
z-index: 999;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="fps-display">FPS: --</div>
|
||||
<div id="instructions">Move mouse rapidly across the cards — watch hover highlight latency</div>
|
||||
|
||||
<h1>🖱️ Hover Responsiveness Test</h1>
|
||||
<p>Each card has a <code>transition: 0.05s</code> on background, border, transform, and box-shadow.
|
||||
Compare how quickly the hover highlight appears vs Brave/Chrome.</p>
|
||||
|
||||
<div class="grid" id="grid"></div>
|
||||
|
||||
<script>
|
||||
// Generate 60 cards
|
||||
var grid = document.getElementById('grid');
|
||||
var icons = ['🌟','🔥','💎','⚡','🎯','🚀','🌈','🍀','🎨','🦋','🌺','⭐','💫','✨','🎪','🏆','🥇','💡','🔮','🎭'];
|
||||
for (var i = 0; i < 60; i++) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = '<div class="icon">' + icons[i % icons.length] + '</div><div class="label">Card ' + (i+1) + '</div>';
|
||||
grid.appendChild(card);
|
||||
}
|
||||
|
||||
// Simple FPS counter
|
||||
var fpsEl = document.getElementById('fps-display');
|
||||
var frameCount = 0;
|
||||
var lastFpsTime = performance.now();
|
||||
function rafLoop() {
|
||||
frameCount++;
|
||||
var now = performance.now();
|
||||
if (now - lastFpsTime >= 1000) {
|
||||
fpsEl.textContent = 'FPS: ' + frameCount;
|
||||
frameCount = 0;
|
||||
lastFpsTime = now;
|
||||
}
|
||||
requestAnimationFrame(rafLoop);
|
||||
}
|
||||
requestAnimationFrame(rafLoop);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SharedWorker importScripts Test</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; background: #1a1a2e; color: #eee; padding: 40px; }
|
||||
h1 { margin-bottom: 10px; }
|
||||
.pass { color: #0f0; }
|
||||
.fail { color: #f00; }
|
||||
.info { color: #ff0; }
|
||||
#output { background: #16213e; padding: 16px; border-radius: 8px; margin-top: 20px; font-family: monospace; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔄 SharedWorker importScripts Test</h1>
|
||||
<p>Tests whether <code>importScripts()</code> works inside a SharedWorker loaded from <code>local://</code>.</p>
|
||||
|
||||
<div id="output">Running tests...</div>
|
||||
|
||||
<script>
|
||||
var output = document.getElementById('output');
|
||||
function log(msg, cls) {
|
||||
output.innerHTML += '<span class="' + (cls || '') + '">' + msg + '</span>\n';
|
||||
}
|
||||
|
||||
log('=== importScripts Test ===\n', 'info');
|
||||
|
||||
try {
|
||||
var worker = new SharedWorker('shared-worker-import-test.js');
|
||||
log('✓ SharedWorker constructor succeeded', 'pass');
|
||||
|
||||
worker.port.onmessage = function(e) {
|
||||
log('✓ Received: ' + e.data, 'pass');
|
||||
};
|
||||
|
||||
worker.port.onerror = function(e) {
|
||||
log('✗ Worker error: ' + (e.message || 'unknown'), 'fail');
|
||||
};
|
||||
|
||||
worker.port.postMessage('ping');
|
||||
|
||||
setTimeout(function() {
|
||||
log('\n--- Done ---', 'info');
|
||||
}, 3000);
|
||||
|
||||
} catch (e) {
|
||||
log('✗ SharedWorker constructor threw: ' + e.message, 'fail');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* SharedWorker test for importScripts() on local://.
|
||||
* Tests whether importScripts() can load scripts from the local:// scheme.
|
||||
*/
|
||||
console.log('[Import-Test] Worker script executing');
|
||||
console.log('[Import-Test] self.location.href:', self.location.href);
|
||||
console.log('[Import-Test] self.location.origin:', self.location.origin);
|
||||
|
||||
// Try importScripts with a relative path
|
||||
try {
|
||||
importScripts('./shared-worker-test.js');
|
||||
console.log('[Import-Test] importScripts succeeded');
|
||||
} catch (e) {
|
||||
console.log('[Import-Test] importScripts FAILED:', e.message);
|
||||
}
|
||||
|
||||
self.onconnect = function(e) {
|
||||
var port = e.ports[0];
|
||||
port.postMessage('worker_ready');
|
||||
port.onmessage = function(e) {
|
||||
if (e.data === 'ping') {
|
||||
port.postMessage('pong');
|
||||
} else if (e.data === 'get_info') {
|
||||
port.postMessage(JSON.stringify({
|
||||
href: self.location.href,
|
||||
origin: self.location.origin,
|
||||
protocol: self.location.protocol
|
||||
}));
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SharedWorker Test</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; background: #1a1a2e; color: #eee; padding: 40px; }
|
||||
h1 { margin-bottom: 10px; }
|
||||
.pass { color: #0f0; }
|
||||
.fail { color: #f00; }
|
||||
.info { color: #ff0; }
|
||||
#output { background: #16213e; padding: 16px; border-radius: 8px; margin-top: 20px; font-family: monospace; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔄 SharedWorker Test</h1>
|
||||
<p>Tests whether <code>SharedWorker</code> works when loaded from a <code>local://</code> URI.</p>
|
||||
|
||||
<div id="output">Running tests...</div>
|
||||
|
||||
<script>
|
||||
var output = document.getElementById('output');
|
||||
function log(msg, cls) {
|
||||
output.innerHTML += '<span class="' + (cls || '') + '">' + msg + '</span>\n';
|
||||
}
|
||||
|
||||
log('=== SharedWorker Test ===\n', 'info');
|
||||
|
||||
// Test 1: Is SharedWorker supported?
|
||||
if (typeof SharedWorker !== 'undefined') {
|
||||
log('✓ SharedWorker is supported by this browser', 'pass');
|
||||
} else {
|
||||
log('✗ SharedWorker is NOT supported by this browser', 'fail');
|
||||
}
|
||||
|
||||
// Test 2: Try to create a SharedWorker
|
||||
try {
|
||||
var worker = new SharedWorker('shared-worker-test.js');
|
||||
log('✓ SharedWorker constructor succeeded', 'pass');
|
||||
|
||||
worker.port.onmessage = function(e) {
|
||||
log('✓ Received message from worker: ' + e.data, 'pass');
|
||||
};
|
||||
|
||||
worker.port.onerror = function(e) {
|
||||
log('✗ Worker error: ' + (e.message || 'unknown'), 'fail');
|
||||
};
|
||||
|
||||
// Send a ping
|
||||
worker.port.postMessage('ping');
|
||||
|
||||
// Timeout after 3 seconds
|
||||
setTimeout(function() {
|
||||
log('\n--- Timeout reached ---', 'info');
|
||||
log('If you see "Received message from worker" above, SharedWorker works.', 'info');
|
||||
log('If not, SharedWorker failed to load or communicate.', 'info');
|
||||
}, 3000);
|
||||
|
||||
} catch (e) {
|
||||
log('✗ SharedWorker constructor threw: ' + e.message, 'fail');
|
||||
}
|
||||
|
||||
// Test 3: What is our current origin?
|
||||
log('\n=== Origin Info ===', 'info');
|
||||
log('location.href: ' + location.href, 'info');
|
||||
log('location.origin: ' + (location.origin || 'null'), 'info');
|
||||
log('location.protocol: ' + location.protocol, 'info');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Minimal SharedWorker test script.
|
||||
* Responds to 'ping' with 'pong' and reports its own URL and origin.
|
||||
*/
|
||||
|
||||
console.log('[SharedWorker-Test] Worker script executing...');
|
||||
console.log('[SharedWorker-Test] self.location.href:', self.location.href);
|
||||
console.log('[SharedWorker-Test] self.location.origin:', self.location.origin);
|
||||
console.log('[SharedWorker-Test] self.location.protocol:', self.location.protocol);
|
||||
|
||||
self.onconnect = function(e) {
|
||||
var port = e.ports[0];
|
||||
console.log('[SharedWorker-Test] Connected from port');
|
||||
|
||||
port.postMessage('worker_ready');
|
||||
|
||||
port.onmessage = function(e) {
|
||||
console.log('[SharedWorker-Test] Received:', e.data);
|
||||
if (e.data === 'ping') {
|
||||
port.postMessage('pong');
|
||||
} else if (e.data === 'get_info') {
|
||||
port.postMessage(JSON.stringify({
|
||||
href: self.location.href,
|
||||
origin: self.location.origin,
|
||||
protocol: self.location.protocol
|
||||
}));
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user