diff --git a/CHANGELOG.md b/CHANGELOG.md index af36953..2e8babc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to Sumbee are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added +- **In-app HTML viewer.** HTML summaries now render with their own styling inside the preview pane + instead of falling back to plain text. The viewer is deliberately basic and private: it does not + run the document's JavaScript, blocks remote network loads, persists no cookies or cache, and + opens link clicks in your browser; the preview font controls zoom it. When a summary uses + interactive or dynamic features (scripts, embeds, media, forms), a **View in Browser** button + appears in the top right for the full experience. (FR-047 to FR-052; see + `specs/003-html-preview-viewer/`) + ## [0.2.7] - 2026-06-22 ### Fixed diff --git a/README.md b/README.md index 04306ac..21ec780 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,11 @@ uploads or sees your library. No proprietary format, no lock-in. toolbar; the size sticks across sessions and scales headings proportionally. - **Secure key storage** in the macOS Keychain; summarizing is gated until a valid key is set, and re-gated automatically on an auth failure. -- **Markdown or HTML output**, with an optional shared HTML-styling prompt. The Markdown preview - renders tables and clickable links; drag a summary to Finder, or **space-bar Quick Look** it. +- **Markdown or HTML output**, with an optional shared HTML-styling prompt. Both render in-app: the + Markdown preview renders tables and clickable links, and HTML summaries render with their own + styling in a built-in viewer that stays basic and private (no scripts run, no remote loads, link + clicks open in your browser). Interactive HTML gets a one-click **View in Browser** button. Drag a + summary to Finder, or **space-bar Quick Look** it. - **Geek mode.** Flip it on in the bottom bar to preview the exact prompt and an estimated token count before each summary is sent. - **Model-capability aware:** defaults to the latest Claude model and only sends parameters a given diff --git a/Sources/SumbeeKit/Services/HTMLFeatureScanner.swift b/Sources/SumbeeKit/Services/HTMLFeatureScanner.swift new file mode 100644 index 0000000..b40cd6a --- /dev/null +++ b/Sources/SumbeeKit/Services/HTMLFeatureScanner.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Detects "advanced" constructs in a saved HTML summary that the in-app, static, JavaScript-free +/// preview won't execute (FR-050). When any are present, the UI offers a "View in Browser" escape +/// hatch (FR-051) so the user gets the full experience in their real browser. +/// +/// The scan is intentionally a cheap, case-insensitive text match on the raw document, not a full +/// HTML parse: deciding "is there a `") + XCTAssertTrue(r.hasAdvancedFeatures) + XCTAssertEqual(r.features, ["JavaScript"]) + } + + func testIframeEmbedObjectAreEmbeddedContent() { + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Embedded content"]) + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Embedded content"]) + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Embedded content"]) + } + + func testMediaIsAdvanced() { + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Media"]) + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Media"]) + } + + func testCanvasIsGraphics() { + XCTAssertEqual(HTMLFeatureScanner.scan("").features, ["Graphics"]) + } + + func testFormControlsAreInteractive() { + XCTAssertEqual(HTMLFeatureScanner.scan("
").features, + ["Interactive controls"]) + XCTAssertEqual(HTMLFeatureScanner.scan("").features, + ["Interactive controls"]) + } + + func testInlineEventHandlerIsScriptedHandler() { + let r = HTMLFeatureScanner.scan("
tap
") + XCTAssertTrue(r.hasAdvancedFeatures) + XCTAssertEqual(r.features, ["Scripted handlers"]) + } + + // "on" inside ordinary attribute values / words must not be mistaken for an event handler. + func testWordContainingOnIsNotAHandler() { + let r = HTMLFeatureScanner.scan("

Companion notes, onboarding.

") + XCTAssertFalse(r.hasAdvancedFeatures) + } + + // Multiple categories: labels are present, deduped, and in stable (declaration) order. + func testMultipleFeaturesAreDedupedAndOrdered() { + let html = "
" + let r = HTMLFeatureScanner.scan(html) + XCTAssertEqual(r.features, ["JavaScript", "Media", "Scripted handlers"]) + } + + func testDetectionIsCaseInsensitive() { + XCTAssertTrue(HTMLFeatureScanner.scan("").hasAdvancedFeatures) + } +} diff --git a/docs/swift-macos-learnings.md b/docs/swift-macos-learnings.md index cd4f430..dc169fe 100644 --- a/docs/swift-macos-learnings.md +++ b/docs/swift-macos-learnings.md @@ -73,6 +73,22 @@ permissions, the library list, fonts, icons, or shell scripts. Each entry: **Sym - **Symptom**: a `TextEditor` over a material looked like a flat white/gray box. - **Rule**: add `.scrollContentBackground(.hidden)` so the material/background shows through. +### 18. A `WKWebView` loaded off the main thread silently renders a blank page +- **Symptom**: the *first* HTML summary opened in a session showed a blank white page; the next one + rendered fine, and returning to the first then worked too (100% repro). +- **Cause**: the in-app HTML viewer gated its first `loadHTMLString` behind a one-time + `WKContentRuleListStore.compileContentRuleList(...)` (the remote-load privacy block). That + completion handler is **not guaranteed to run on the main thread**, so the first document's only + load (and the `WKUserContentController.add`) executed off-main. `WKWebView` is main-thread-only; + off-main calls are undefined behavior and here just painted nothing. Every later load came from the + SwiftUI `updateNSView` pass (main thread) once the rule list was cached, hence "only the first". +- **Rule**: treat every `WKWebView`/`WKUserContentController` mutation (including `loadHTMLString`) + as main-thread-only. Any async completion that drives them (rule-list compile, file read, network) + must `DispatchQueue.main.async` before touching WebKit. If you gate the first paint on async setup + for a good reason (we gate on the remote-load block for privacy), keep the gate but make the + resumed load run on main. Verified by independent adversarial review: the naive fix (load eagerly, + ungated) reintroduced a privacy hole by painting before the block installed. + ## Layout & typography ### 10. `.dynamicTypeSize` barely scales macOS's built-in text styles diff --git a/screenshots/screen-01-app.png b/screenshots/screen-01-app.png index 3f43d90..f491a5a 100644 Binary files a/screenshots/screen-01-app.png and b/screenshots/screen-01-app.png differ diff --git a/screenshots/screen-02-settings-generation.png b/screenshots/screen-02-settings-generation.png index bc80e60..4da058b 100644 Binary files a/screenshots/screen-02-settings-generation.png and b/screenshots/screen-02-settings-generation.png differ diff --git a/screenshots/screen-03-styles.png b/screenshots/screen-03-styles.png index adedad4..122a9ab 100644 Binary files a/screenshots/screen-03-styles.png and b/screenshots/screen-03-styles.png differ diff --git a/screenshots/screen-04-output.png b/screenshots/screen-04-output.png index a43446d..bfe562a 100644 Binary files a/screenshots/screen-04-output.png and b/screenshots/screen-04-output.png differ diff --git a/specs/003-html-preview-viewer/plan.md b/specs/003-html-preview-viewer/plan.md new file mode 100644 index 0000000..2ad019f --- /dev/null +++ b/specs/003-html-preview-viewer/plan.md @@ -0,0 +1,108 @@ +# Implementation Plan: In-app HTML preview viewer + +**Spec**: `spec.md` · **Decisions**: `research.md` · **Tasks**: `tasks.md` + +## Summary + +Replace the plain-text HTML fallback in the preview pane with a basic, static, private `WKWebView` +viewer, add a deterministic advanced-feature scan, and surface a top-right **"View in Browser"** +button when advanced features are present. No new dependency (WebKit is an Apple framework). + +## Architecture & touch points + +``` +Sources/SumbeeKit/ + Services/ + HTMLFeatureScanner.swift # NEW pure, testable: scan(html) -> Result{advanced, labels} + Views/AssetBrowser/ + HTMLWebView.swift # NEW NSViewRepresentable around WKWebView (JS off, no remote, + # nonPersistent store, link clicks -> browser, pageZoom) + MarkdownPreview.swift # EDIT PreviewPane: branch .html -> HTMLWebView; toolbar gains a + # conditional top-right "View in Browser" button +Tests/SumbeeKitTests/ + HTMLFeatureScannerTests.swift # NEW detector unit tests incl. anchor-link false-positive guard +``` + +Everything else (AppState, LibraryStore, Asset model, streaming, settings) is untouched. The +viewer reads the file the same way `load()` already does. + +## Component contracts + +### `HTMLFeatureScanner` (Services) + +```swift +public enum HTMLFeatureScanner { + public struct Result: Equatable { + public let hasAdvancedFeatures: Bool + public let features: [String] // friendly labels, deduped, stable order + } + public static func scan(_ html: String) -> Result +} +``` + +- Pure, synchronous, no I/O. Case-insensitive. Anchor `` never contributes. +- `features` drives the button's help/tooltip text; `hasAdvancedFeatures == !features.isEmpty`. + +### `HTMLWebView` (Views) + +```swift +struct HTMLWebView: NSViewRepresentable { // NSViewRepresentable + let html: String + var baseSize: Double // -> pageZoom = clamp(baseSize/16, 0.6...2.0) +} +``` + +- `makeNSView`: build `WKWebViewConfiguration` (JS off, `.nonPersistent()` store), attach the + cached/compiled `WKContentRuleList` blocking `^https?://` (best-effort), set `navigationDelegate`, + `loadHTMLString(html, baseURL: nil)`. +- `updateNSView`: apply `pageZoom`; reload only if `html` changed. +- `Coordinator: WKNavigationDelegate`: allow the first programmatic load; `.linkActivated` → + `NSWorkspace.shared.open(url)` + `.cancel`; any later main-frame navigation → `.cancel`. + +### `PreviewPane` (edits) + +- `load()`: for `.html`, store the **raw** file text (for the web view) and compute + `HTMLFeatureScanner.scan(...)`; for `.markdown`, unchanged. +- `body`: when `asset.format == .html` and not streaming, show `HTMLWebView(html:baseSize:)` inside + the existing scroll area instead of `MarkdownText`. Markdown path unchanged. +- `toolbar`: after the title `Spacer()`, add a labeled "View in Browser" button **only** when + `asset.format == .html && htmlFeatures.hasAdvancedFeatures`. Existing icon buttons remain. +- Font +/- buttons stay enabled for HTML (they now drive `pageZoom`). + +## Security / privacy checklist (maps to FR-048 / US3) + +- [ ] `allowsContentJavaScript = false` +- [ ] `websiteDataStore = .nonPersistent()` +- [ ] content rule list blocks `^https?://` (best-effort, scoped so the local doc never matches) +- [ ] link clicks + stray navigations cancelled; links open via `NSWorkspace` +- [ ] `baseURL: nil` (no implicit relative/remote resolution) + +## Testing + +- **Unit (added)**: `HTMLFeatureScannerTests` - plain styled HTML → not advanced; `