Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions Sources/SumbeeKit/Services/HTMLFeatureScanner.swift
Original file line number Diff line number Diff line change
@@ -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 `<script>`/`<iframe>`/…" does not require a DOM. Plain anchor
/// links (`<a href>`) are normal content and never count - notably the centered grey source link
/// the app stamps into YouTube HTML (`HTMLMetaCodec.insertSourceLink`) must not trip the detector.
public enum HTMLFeatureScanner {
public struct Result: Equatable {
/// True when at least one advanced construct was found.
public let hasAdvancedFeatures: Bool
/// Friendly, deduped, stable-ordered labels (e.g. ["JavaScript", "Media"]) for tooltips.
public let features: [String]

public init(hasAdvancedFeatures: Bool, features: [String]) {
self.hasAdvancedFeatures = hasAdvancedFeatures
self.features = features
}
}

/// One detectable category: a friendly label plus the lowercased tokens that imply it.
private struct Category {
let label: String
let tokens: [String]
}

// Order here is the stable order of `features` in the result.
private static let categories: [Category] = [
Category(label: "JavaScript", tokens: ["<script"]),
Category(label: "Embedded content", tokens: ["<iframe", "<embed", "<object"]),
Category(label: "Media", tokens: ["<video", "<audio"]),
Category(label: "Graphics", tokens: ["<canvas"]),
Category(label: "Interactive controls",
tokens: ["<form", "<input", "<button", "<select", "<textarea"]),
]

/// Matches an inline event handler attribute (`onclick=`, `onload=`, …). The leading boundary
/// (whitespace, quote, or `<`/tag char) keeps it from matching words that merely contain "on".
private static let eventHandler = try! NSRegularExpression(
pattern: "[\\s\"'/]on[a-z]+\\s*=", options: [.caseInsensitive])

public static func scan(_ html: String) -> Result {
let lower = html.lowercased()
var found: [String] = []

for category in categories where category.tokens.contains(where: { lower.contains($0) }) {
found.append(category.label)
}

let range = NSRange(lower.startIndex..<lower.endIndex, in: lower)
if eventHandler.firstMatch(in: lower, options: [], range: range) != nil {
found.append("Scripted handlers")
}

return Result(hasAdvancedFeatures: !found.isEmpty, features: found)
}
}
150 changes: 150 additions & 0 deletions Sources/SumbeeKit/Views/AssetBrowser/HTMLWebView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import SwiftUI
import WebKit
import AppKit

/// A basic, read-only, *static* in-app viewer for an HTML summary (FR-047).
///
/// It renders the model's self-contained HTML with its own styling, but deliberately is **not** a
/// browser (FR-048): JavaScript is disabled, remote network loads are blocked, nothing is persisted
/// to disk, and link clicks open in the user's real browser. Documents that need more (scripts,
/// embeds, media, forms) are flagged by `HTMLFeatureScanner` so the UI can offer "View in Browser".
///
/// Threading note (this was a real "first HTML renders blank" bug): every `WKWebView` /
/// `WKUserContentController` mutation, `loadHTMLString` included, MUST happen on the main thread. The
/// remote-load rule list compiles asynchronously and its completion handler is not guaranteed to run
/// on main, so we hop to main before installing the rule and issuing the load. The load stays gated
/// behind rule installation, so the document is never painted before the remote-load block is active.
struct HTMLWebView: NSViewRepresentable {
let html: String
/// The preview toolbar's base font size; mapped to page zoom (16pt == 1.0). (FR-049)
var baseSize: Double

private var zoom: CGFloat { max(0.6, min(2.0, CGFloat(baseSize / 16.0))) }

func makeCoordinator() -> Coordinator { Coordinator() }

func makeNSView(context: Context) -> WKWebView {
// Keep a strong reference to the content controller we create: `WKWebView.configuration`
// returns a *copy*, so a rule list added to it post-init wouldn't reach the live view.
let controller = WKUserContentController()

let config = WKWebViewConfiguration()
config.userContentController = controller
config.websiteDataStore = .nonPersistent() // no cookies/cache on disk
config.defaultWebpagePreferences.allowsContentJavaScript = false // static, JS-free render

let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.pageZoom = zoom

context.coordinator.controller = controller
context.coordinator.render(html, on: webView)
return webView
}

func updateNSView(_ webView: WKWebView, context: Context) {
webView.pageZoom = zoom
context.coordinator.render(html, on: webView)
}

final class Coordinator: NSObject, WKNavigationDelegate {
private var currentHTML = ""
private var loadedHTML: String?
var controller: WKUserContentController?
private var didInitialLoad = false
private var ruleState: RuleState = .pending

private enum RuleState { case pending, installing, ready }

/// Record the latest requested document and, once the remote-load block is installed on this
/// controller, load it. The load is always issued on the main thread (callers are the SwiftUI
/// make/update cycle; the install completion hops to main), which fixes the blank-first-render
/// bug, and stays gated behind installation so nothing paints before remote loads are blocked.
func render(_ html: String, on webView: WKWebView) {
currentHTML = html
switch ruleState {
case .ready:
loadIfNeeded(into: webView)
case .installing:
break // the install completion loads the latest doc
case .pending:
ruleState = .installing
guard let controller else { ruleState = .ready; loadIfNeeded(into: webView); return }
RemoteResourceBlock.install(on: controller) { [weak self, weak webView] in
guard let self, let webView else { return } // always invoked on the main thread
self.ruleState = .ready
self.loadIfNeeded(into: webView)
}
}
}

/// Load the current document once per distinct, non-empty value: skips the transient "" that
/// appears while the file is read, and avoids reloading the same doc on a zoom change.
private func loadIfNeeded(into webView: WKWebView) {
let html = currentHTML
guard !html.isEmpty, html != loadedHTML else { return }
loadedHTML = html
didInitialLoad = false
// baseURL nil: model HTML is self-contained; no surprise relative/remote resolution.
webView.loadHTMLString(html, baseURL: nil)
}

func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
// A clicked hyperlink opens in the user's browser; the in-app pane never navigates away.
if navigationAction.navigationType == .linkActivated {
if let url = navigationAction.request.url,
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" || scheme == "mailto" {
NSWorkspace.shared.open(url)
}
decisionHandler(.cancel) // (in-doc about:blank#anchor clicks just no-op)
return
}
// Allow exactly the first programmatic document load; cancel any later navigation.
if didInitialLoad {
decisionHandler(.cancel)
} else {
didInitialLoad = true
decisionHandler(.allow)
}
}
}
}

/// Best-effort `WKContentRuleList` that blocks remote (`http`/`https`) loads so the static preview
/// never auto-fetches remote images/fonts/trackers (FR-048, privacy). The filter is scoped to
/// `http(s)` so the in-memory document (`about:blank`/`data:`) is never matched and never blanked.
/// JavaScript is disabled regardless, so a compile failure degrades safely. Compiled once, cached.
///
/// All state here is read/written on the main thread only: `install` is called from the SwiftUI
/// make/update cycle, and the (possibly off-main) compile completion hops to main before touching
/// `cached` or the controller. `completion` is therefore always invoked on the main thread.
private enum RemoteResourceBlock {
private static let identifier = "sumbee.preview.block-remote.v1"
private static let encodedRules =
#"[{"trigger":{"url-filter":"^https?://"},"action":{"type":"block"}}]"#
private static var cached: WKContentRuleList?

static func install(on controller: WKUserContentController, completion: @escaping () -> Void) {
if let cached {
controller.add(cached)
completion()
return
}
guard let store = WKContentRuleListStore.default() else { completion(); return }
store.compileContentRuleList(forIdentifier: identifier,
encodedContentRuleList: encodedRules) { [weak controller] list, _ in
// The compile completion thread is not guaranteed to be main; hop before any WebKit
// mutation or the gated content load. This is the core fix for the blank-first-render bug.
DispatchQueue.main.async {
if let list {
cached = list
controller?.add(list)
}
completion() // best-effort: render even if the rule list failed to compile
}
}
}
}
54 changes: 48 additions & 6 deletions Sources/SumbeeKit/Views/AssetBrowser/MarkdownPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ struct PreviewPane: View {

@EnvironmentObject private var state: AppState
@State private var content: String = ""
@State private var htmlRaw: String = ""
@State private var htmlFeatures = HTMLFeatureScanner.Result(hasAdvancedFeatures: false, features: [])
@State private var keyMonitor: Any?
@State private var showRegenerate = false

Expand All @@ -24,10 +26,16 @@ struct PreviewPane: View {
} else if let asset {
toolbar(asset)
Divider().overlay(Theme.hairline)
ScrollView {
MarkdownText(raw: content, baseSize: state.settings.previewFontSize)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
if asset.format == .html {
// Basic, static, private in-app HTML viewer (FR-047/048); it scrolls internally.
HTMLWebView(html: htmlRaw, baseSize: state.settings.previewFontSize)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
MarkdownText(raw: content, baseSize: state.settings.previewFontSize)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
} else {
placeholder
Expand Down Expand Up @@ -84,6 +92,12 @@ struct PreviewPane: View {
.font(.uiBody.weight(.semibold))
.lineLimit(1)
Spacer()
// For HTML with interactive/dynamic features the static viewer can't run, offer the
// browser (FR-051). Kept top-right and clearly labeled.
if asset.format == .html && htmlFeatures.hasAdvancedFeatures {
viewInBrowserButton(asset)
Divider().frame(height: 16).overlay(Theme.hairline)
}
iconButton("textformat.size.smaller", "Decrease font size") { adjustFont(-1) }
.disabled(state.settings.previewFontSize <= Self.minFont)
iconButton("textformat.size.larger", "Increase font size") { adjustFont(1) }
Expand All @@ -107,6 +121,29 @@ struct PreviewPane: View {
.padding(.vertical, 8)
}

/// A labeled, accent "View in Browser" action shown for HTML summaries with advanced features.
private func viewInBrowserButton(_ asset: Asset) -> some View {
Button { NSWorkspace.shared.open(asset.url) } label: {
HStack(spacing: 4) {
Image(systemName: "globe")
Text("View in Browser")
}
.font(.uiCallout.weight(.semibold))
}
.buttonStyle(.plain)
.foregroundStyle(Theme.accent)
.help(browserHelp)
.accessibilityLabel("View in Browser")
}

/// Tooltip naming the detected features so the user knows why the escape hatch is offered.
private var browserHelp: String {
let f = htmlFeatures.features
guard !f.isEmpty else { return "Open this summary in your browser" }
let list = f.map { $0.lowercased() }.joined(separator: ", ")
return "This summary uses \(list); open it in your browser for the full experience."
}

/// Adjust the sticky preview base font size (FR-036) and persist it.
private func adjustFont(_ delta: Double) {
let v = min(max(state.settings.previewFontSize + delta, Self.minFont), Self.maxFont)
Expand All @@ -131,6 +168,8 @@ struct PreviewPane: View {
}

private func load() {
htmlRaw = ""
htmlFeatures = HTMLFeatureScanner.Result(hasAdvancedFeatures: false, features: [])
guard let asset else { content = ""; return }
guard let raw = try? String(contentsOf: asset.url, encoding: .utf8) else {
content = "_Couldn’t read this file._"
Expand All @@ -140,8 +179,11 @@ struct PreviewPane: View {
case .markdown:
content = FrontmatterCodec.parse(raw).body
case .html:
content = "HTML summary. Use **Open** to view it styled in your browser.\n\n"
+ VTTParser.stripTags(raw) // reuse tag stripper for a plain-text fallback
// Render the document itself in the in-app web viewer (FR-047) and decide whether to
// surface "View in Browser" based on its features (FR-050/051).
content = ""
htmlRaw = raw
htmlFeatures = HTMLFeatureScanner.scan(raw)
}
}
}
Expand Down
Loading