Files
sovereign_browser/tests/local-site/LOCAL-FILE-BROWSING-REPORT.md

23 KiB

Local File Browsing Assessment — sovereign_browser

Date: 2026-07-16 Test site: tests/local-site/ Browser: sovereign_browser (WebKitGTK + libsoup-3.0) Goal: Verify that sovereign_browser can load and operate a multipage website directly from the local filesystem (file://) with no web server, allowing cross-origin access between local files — as required by the "security moved to the Qube level" design.


Summary

Local file browsing in sovereign_browser works remarkably well. The vast majority of edge cases pass. WebKitGTK's file:// handler, combined with sovereign_browser's permissive local-file policy, allows a full multipage website to be opened and operated from disk just as if it were served over HTTP. Only a handful of minor issues were found, and most are WebKit engine quirks rather than sovereign_browser bugs.

Verdict: No blocking fixes are required for local-file browsing to work. The issues below are polish/UX improvements.


Test Matrix

# Edge case Page Result Notes
1 Relative links between sibling pages index.htmlabout.html PASS Links resolve to correct file:// URLs
2 Links into subdirectories index.htmlsubdir/page.html PASS
3 Links back out with ../ subdir/page.htmlindex.html PASS
4 Deeply nested paths (3 levels) subdir/deep/deeper/deepest.html PASS ../../../ resolves correctly
5 External stylesheet (relative) assets/site.css PASS 21 CSS rules loaded on every page
6 SVG favicon assets/favicon.svg PASS link[rel=icon] href resolved
7 SVG image via <img> assets/logo.svg PASS naturalWidth correct, complete=true
8 <picture> + <source srcset> gallery.html PASS currentSrc selected correctly
9 <img srcset> with 1x/2x gallery.html PASS
10 Lazy-loaded image (loading="lazy") gallery.html PASS
11 External <script src> (sibling) assets/home.js PASS Script ran, updated DOM
12 External <script src> (../) assets/external.js PASS Loaded from parent dir
13 fetch() a local JSON file data.htmlassets/data.json PASS status=0 (file:// convention), body returned
14 XMLHttpRequest a local file data.html PASS status=0, responseText populated
15 fetch() from a subdirectory data.htmlsubdir/sub.json PASS
16 fetch() with query string data.htmlassets/data.json?cache=bust PASS Query ignored, file loaded
17 fetch() a missing file data.htmlassets/does-not-exist.json ⚠️ MINOR fetch() throws "Load failed" instead of returning a 404 response. This is standard WebKit file:// behavior — there is no HTTP status for local files. Not a bug.
18 <iframe> loading a sibling page iframe.htmlabout.html PASS load event fired, readyState=complete
19 <iframe> loading a subdirectory page iframe.htmlsubdir/page.html PASS
20 Cross-iframe DOM access (same-origin) iframe.html PASS Critical finding: contentDocument.title readable — all file:// URLs are treated as same-origin. This is exactly what we want for local development.
21 <iframe srcdoc> iframe.html PASS
22 <iframe src="data:..."> iframe.html PASS
23 GET form submission to local file form.htmlform-target.html PASS Navigated with ?name=Alice&age=30
24 POST form submission to local file form.htmlform-target.html PASS Navigated without query; POST body silently dropped (no server). Correct behavior.
25 Form targeting a new window (target="_blank") form.html PASS Opens in new tab
26 Form targeting an iframe (target="form-frame") form.html PASS
27 localStorage storage-test.html PASS Set/get/remove all work; length tracking correct
28 sessionStorage storage-test.html PASS Set/get/remove all work
29 document.cookie storage-test.html PASS Set/get work; multiple cookies visible
30 IndexedDB storage-test.html PASS Open, upgrade (create object store), put, get, count — all work
30b Cache API (CacheStorage) storage-test.html FAIL cache.put() rejects: "Request url is not HTTP/HTTPS". See Issue #5.
31 window.open() a sibling page popup.htmlabout.html PASS Opens in new tab
32 window.open() with features popup.html PASS
33 history.pushState() history.html PASS URL updated with ?pushed=1
34 history.replaceState() history.html PASS URL updated with ?replaced=1
35 history.back() / forward() browser back/forward MCP tools PASS Navigates correctly between file:// pages
36 Hash fragment in URL hash.html#section-2 PASS location.hash = #section-2
37 hashchange event hash.html PASS (page wired correctly)
38 URL-encoded filename (encoded%20name.html) encoded name.html PASS Percent-encoding decoded, correct file loaded
39 Query string on file:// URL query.html?foo=bar&baz=qux PASS File loaded, location.search preserved, URLSearchParams works
40 Missing page (404 equivalent) does-not-exist.html FIXED URL bar now shows failed URL (Issue #1 fixed)
41 <video> with local MP4 source media.html PASS "can play", readyState=3, duration=596.5s, 640x360, no errors
42 <audio> with local M4A source media.html PASS "can play", readyState=4, duration=3277s, no errors
42b Missing video source (error event test) media.html ⚠️ MINOR loadstart fires but error event does not. See Issue #2.
43 <object> / <embed> with SVG media.html PASS data/src resolved to file:// URL
44 Inline SVG media.html PASS
45 Clicking a link to navigate index.htmlabout.html PASS click MCP tool triggered navigation
46 ES Modules (import()) advanced.html PASS Dynamic import() works; exports accessible (greet, add, PI)
47 Web Workers advanced.html PASS new Worker() created; postMessage round-trip works (7*6=42)
47b Shared Workers advanced.html PASS new SharedWorker() created; port.onmessage works; ping/pong round-trip works
48 Service Workers advanced.html FAIL register() rejects: "must be called with HTTP or HTTPS". See Issue #6.
49 WebAssembly advanced.html PASS WebAssembly.instantiate() works; add(3,4)=7, add(100,200)=300
50 Cross-origin fetch (file:// → https://) advanced.html PASS fetch('https://example.com') returns status=200, type=basic, 559 chars. No CORS blocking!
51 Cross-origin remote image advanced.html PASS Remote images from placehold.co and google.com load correctly
52 Cross-origin remote script advanced.html PASS Remote <script src> from jsdelivr CDN loaded and executed
53 Cross-origin remote stylesheet advanced.html PASS Remote <link rel=stylesheet> from jsdelivr CDN loaded and applied

Issues Found

Issue #1 — Missing page falls back to about:blank (FIXED )

What was happening: When navigating to a file:// URL that does not exist, the browser displayed an error message but the URL bar showed about:blank instead of the requested URL.

Fix applied: Updated on_load_failed() in src/tab_manager.c to:

  1. Accept the tab_info_t* as user_data (changed signal connection from NULL to tab).
  2. Set the URL bar text to failing_uri (skipping about:blank).
  3. Update tab->current_url to the failing URI.
  4. Set the tab title to the error message so the tab bar shows something meaningful instead of "Loading…".

Verified: After the fix, navigating to a missing file shows the failed file:// URL in the address bar (confirmed via get_url MCP tool).


Issue #2 — <video>/<audio> error events don't fire for missing sources (MINOR / WebKit quirk)

Note: Media playback with valid files works perfectly. Tested with a real MP4 video (Big Buck Bunny, 596s, 640x360) and M4A audio (Neon Dream, 3277s). Both reach can play state with correct duration and dimensions. This issue only affects the error path when a media file is missing.

What happens: When a <video> or <audio> element references a local file that doesn't exist, the error event does not fire. The loadstart event fires, but then the load silently fails without an error event. The element stays in a "not loaded" state indefinitely.

Why it matters: Web apps that rely on error events to show fallback content or retry logic won't work correctly for missing media files.

Likely cause: This is a WebKitGTK engine behavior, not a sovereign_browser bug. The file:// loader may not emit media element errors the same way the HTTP loader does.

Suggested fix: None required — this is upstream WebKit behavior. If it becomes a problem, a workaround would be to check networkState or readyState after a timeout. Not worth fixing for the local-file use case.

Severity: Very low. Only affects missing media files, which is an edge case.


Issue #3 — fetch() for missing files throws instead of returning an error response (MINOR / WebKit quirk)

What happens: fetch('missing.json') on a file:// page throws a TypeError: Load failed exception rather than returning a Response with ok=false and a status code.

Why it matters: Code using fetch().then(r => r.ok ? ... : ...) will hit the catch branch instead of the error-handling branch. This is different from HTTP behavior.

Likely cause: Standard WebKit file:// behavior — there's no HTTP status code to return for a missing local file, so the fetch promise rejects.

Suggested fix: None required — this is upstream WebKit behavior and is consistent with Safari and other WebKit-based browsers. Document it as a known difference from HTTP.

Severity: Very low. Developers writing local-file apps should use try/catch with fetch.


Issue #4 — Page title shows (none) in browser log on initial load (FIXED )

What was happening: The browser log frequently showed [loaded] file://... -- title: (none) even for pages that have a <title>. The title arrived slightly after the LOAD_FINISHED event.

Fix applied:

  1. Added a new on_title_changed() handler connected to the webview's notify::title signal. It logs the title as [title] URL -- Title when the title actually becomes available, and updates the tab title.
  2. Simplified the [loaded] log line in on_load_changed() to just log the URL (no more title: (none) noise). The title is now logged separately via the notify::title handler.

Verified: After the fix, the log shows:

[title] file:///...index.html -- Local Site — Home
[loaded] file:///...index.html

instead of the old [loaded] ... -- title: (none).


Issue #5 — Cache API (CacheStorage) rejects non-HTTP/HTTPS URLs (MINOR / WebKit restriction)

What happens: caches.open('my_cache') succeeds, but cache.put(request, response) rejects with the error: "Request url is not HTTP/HTTPS" when the request URL is not an HTTP/HTTPS URL. This means the Cache API cannot be used to store entries keyed by file:// URLs or custom schemes.

Why it matters: Web apps that use the Cache API for offline storage (e.g., service workers caching responses) will not work on file:// pages. The Cache API is the only standard browser storage that has this restriction — localStorage, sessionStorage, IndexedDB, and cookies all work fine on file://.

Likely cause: This is a WebKit engine restriction. The Cache API spec requires that request URLs be HTTP or HTTPS. WebKit enforces this at the cache.put() level. This is consistent with Chrome and Firefox behavior — the Cache API is designed for service worker contexts which only exist on HTTP/HTTPS origins.

Suggested fix: None required — this is a fundamental WebKit/W3C specification restriction, not a sovereign_browser bug. Local-file web apps that need key-value storage should use IndexedDB instead, which works perfectly on file://.

Severity: Low. Only affects apps specifically using the Cache API, which is a niche use case for local-file apps. IndexedDB is the recommended alternative and works fully.


Issue #6 — Service Workers don't work on file:// (MINOR / WebKit restriction)

What happens: navigator.serviceWorker.register() rejects with the error: "serviceWorker.register() must be called with a script URL whose protocol is either HTTP or HTTPS".

Why it matters: Service Workers cannot be used on file:// pages. This means PWA features like offline caching via service workers, background sync, and push notifications won't work for local-file apps.

Likely cause: This is a W3C specification restriction — Service Workers are only allowed on HTTP/HTTPS origins. WebKit enforces this at the register() level. This is consistent with all major browsers.

Suggested fix: None required — this is a fundamental specification restriction. Web Workers (regular, non-service workers) DO work on file:// and can be used for background computation. For offline storage, use IndexedDB.

Severity: Low. Service Workers are a niche use case for local-file apps. Web Workers are the alternative for background processing and work fully.


What Works Surprisingly Well

These are the things that could have been broken but weren't:

  1. Cross-origin access between all file:// URLs — iframes can access their parent's DOM and vice versa, window.open() popups can be accessed, and fetch()/XHR work across directories. This is the single most important requirement for local-file web apps and it works perfectly. WebKitGTK treats all file:// URLs as same-origin.

  2. Relative path resolution../, ../../, and ../../../ all work correctly for links, images, stylesheets, scripts, and fetch targets.

  3. URL-encoded filenamesencoded%20name.html (file on disk: encoded name.html) loads correctly.

  4. Query strings and hash fragments — preserved in location.href, accessible via location.search / location.hash, and don't break file loading.

  5. history.pushState / replaceState — work on file:// URLs, allowing SPA-style routing for local apps.

  6. All browser storage except Cache APIlocalStorage, sessionStorage, cookies, and IndexedDB all work fully on file://. IndexedDB supports open, upgrade, object stores, put, get, and count operations. Only the Cache API (CacheStorage) fails because WebKit requires HTTP/HTTPS URLs for cache entries (Issue #5). Use IndexedDB as the recommended storage for local-file apps.

  7. <picture> / srcset — responsive image selection works.

  8. Form submissions — both GET and POST navigate to the target local file. GET appends query strings; POST silently drops the body (correct, since there's no server).

  9. Video and audio playback — both MP4 video and M4A audio play correctly from file:// URLs. The <video> element reports correct dimensions (640x360), duration (596.5s), and reaches can play state. The <audio> element similarly works with correct duration. Media playback is fully functional for local files.

  10. ES Modules — dynamic import() works on file:// pages, allowing modern modular JavaScript with local module files.

  11. Web Workers and Shared Workersnew Worker() and new SharedWorker() with local script files both work, enabling background computation threads and cross-tab shared workers from file:// pages.

  12. WebAssemblyWebAssembly.instantiate() works, enabling high-performance compiled code on file:// pages.

  13. Cross-origin fetch to remote HTTPS URLsfetch('https://example.com') from a file:// page returns status=200, type=basic with no CORS blocking. This confirms the "deprecated web security" design goal is working — local web apps can freely call remote APIs.

  14. Cross-origin remote resources — remote <script src>, <link rel=stylesheet>, and <img> from HTTPS CDNs all load and execute correctly from file:// pages.


No blocking fixes needed

The local-file browsing feature works well enough for production use as-is. The issues found are minor UX/engine-quirk issues, not functional blockers.

Optional improvements (in priority order)

  1. Fix Issue #1 (about:blank on load failure) FIXED. The failed URL now stays in the address bar, and the tab title shows the error message.

  2. Document the fetch()-throws-on-missing-file behavior — Add a note to the docs or a FAQ that fetch() on file:// rejects (throws) for missing files rather than returning an HTTP error response.

  3. Document the Cache API limitation (Issue #5) — Note in the docs that the Cache API (CacheStorage) does not work on file:// pages because WebKit requires HTTP/HTTPS URLs for cache entries. Recommend IndexedDB as the alternative for local-file apps needing structured storage. (Not a real problem for local files since everything is already local — no need to cache.)

  4. Fix Issue #4 (title logging) FIXED. Added notify::title handler; log now shows [title] URL -- Title when the title arrives, and [loaded] URL without the noisy title: (none).

  5. Issue #2 (media error events) — No action needed; upstream WebKit behavior.


How to Reproduce

# Start the browser with auto-login and open the test site
./browser.sh start --login-method generate \
  --url "file:///home/user/lt/sovereign_browser/tests/local-site/index.html"

# Navigate via MCP (examples):
curl -s -X POST http://localhost:17777/mcp -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"open","arguments":{"url":"file:///home/user/lt/sovereign_browser/tests/local-site/data.html"}}}'

# Check fetch results:
curl -s -X POST http://localhost:17777/mcp -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"eval","arguments":{"script":"document.getElementById(\"fetch-out\").textContent"}}}'

# Test a missing page:
curl -s -X POST http://localhost:17777/mcp -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"open","arguments":{"url":"file:///home/user/lt/sovereign_browser/tests/local-site/does-not-exist.html"}}}'
# Then check: get_url returns "about:blank" (Issue #1)

Test Site Structure

tests/local-site/
├── index.html              # Home page with links to all pages
├── about.html              # Sibling page, anchor links
├── contact.html            # Parent-relative link test
├── gallery.html            # Images, picture, srcset, lazy load
├── data.html               # fetch() + XHR + JSON + missing file + query
├── media.html              # video, audio, object, embed, inline SVG
├── iframe.html             # iframes (sibling, subdir, srcdoc, data:), cross-frame access
├── form.html               # GET/POST forms, target=_blank, target=iframe
├── form-target.html        # Form submission target
├── storage.html            # Storage page with buttons (localStorage, sessionStorage, IndexedDB, cookies)
├── storage-test.html       # Automated storage test (all types incl. Cache API, writes results to DOM)
├── popup.html              # window.open() tests
├── history.html            # pushState, replaceState, back, hashchange
├── hash.html               # Hash fragment navigation
├── query.html              # Query string handling
├── encoded name.html       # URL-encoded filename (literal space)
├── 404.html                # Placeholder (not actually missing)
├── advanced.html           # Advanced JS test (ES Modules, Workers, WASM, cross-origin fetch)
├── assets/
│   ├── site.css            # Shared stylesheet
│   ├── favicon.svg         # SVG favicon
│   ├── logo.svg            # SVG image
│   ├── pic.svg             # SVG image
│   ├── data.json           # JSON for fetch/XHR tests
│   ├── home.js             # External script for index.html
│   ├── external.js         # External script for scripts/external.html
│   ├── advanced-runner.js  # Advanced JS test runner
│   ├── es-module-test.js   # ES Module (import/export) test
│   ├── worker-test.js      # Web Worker script
│   ├── shared-worker-test.js # Shared Worker script
│   ├── sw-test.js          # Service Worker script (expected to fail on file://)
│   ├── sample.mp4          # Test video (Big Buck Bunny, gitignored)
│   └── Neon Dream.m4a      # Test audio (gitignored)
├── scripts/
│   └── external.html       # Page in a subdirectory loading ../assets/external.js
└── subdir/
    ├── page.html           # Subdirectory page with ../ links
    ├── sub.json            # JSON for subdir fetch test
    └── deep/
        └── deeper/
            └── deepest.html # 3-levels-deep page with ../../../ links