From 563f5a702cef26a4d95761a4147f0b1e0f9efdb3 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 2 Apr 2026 03:15:03 -0400 Subject: [PATCH 1/3] Improve code quality: error logging, constants, path validation, safe defaults - Replace try? with do/catch and [Scarf] error logging in all service-layer JSON decoding, file writes, and directory creation - Extract sqliteTransient constant replacing raw unsafeBitCast(-1, ...) pattern - Add QueryDefaults and FileSizeUnit enums for all magic numbers - Guard HOME env var with NSHomeDirectory() fallback instead of force-unwrap - Add path traversal validation to loadSkillContent() - Add SessionStats.empty and use it across all initialization sites - Replace KnownPlatforms array indexing with named .cli constant Co-Authored-By: Claude Opus 4.6 (1M context) --- scarf/scarf/Core/Models/HermesConstants.swift | 34 +++++++++++++-- scarf/scarf/Core/Models/HermesTool.swift | 3 +- .../Core/Services/HermesDataService.swift | 42 +++++++++---------- .../Core/Services/HermesFileService.swift | 32 +++++++++++--- .../Core/Services/HermesLogService.swift | 8 +++- .../Services/ProjectDashboardService.swift | 22 ++++++++-- .../ViewModels/DashboardViewModel.swift | 5 +-- .../ViewModels/SessionsViewModel.swift | 6 +-- .../ViewModels/SettingsViewModel.swift | 7 +++- .../Tools/ViewModels/ToolsViewModel.swift | 17 +++++--- 10 files changed, 126 insertions(+), 50 deletions(-) diff --git a/scarf/scarf/Core/Models/HermesConstants.swift b/scarf/scarf/Core/Models/HermesConstants.swift index 7a19bbd..3d733e2 100644 --- a/scarf/scarf/Core/Models/HermesConstants.swift +++ b/scarf/scarf/Core/Models/HermesConstants.swift @@ -1,8 +1,11 @@ import Foundation +import SQLite3 enum HermesPaths: Sendable { - // Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory - nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes" + private nonisolated static let userHome: String = ProcessInfo.processInfo.environment["HOME"] + ?? NSHomeDirectory() + + nonisolated static let home: String = userHome + "/.hermes" nonisolated static let stateDB: String = home + "/state.db" nonisolated static let configYAML: String = home + "/config.yaml" nonisolated static let memoriesDir: String = home + "/memories" @@ -15,7 +18,32 @@ enum HermesPaths: Sendable { nonisolated static let skillsDir: String = home + "/skills" nonisolated static let errorsLog: String = home + "/logs/errors.log" nonisolated static let gatewayLog: String = home + "/logs/gateway.log" - nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes" + nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes" nonisolated static let scarfDir: String = home + "/scarf" nonisolated static let projectsRegistry: String = scarfDir + "/projects.json" } + +// MARK: - SQLite Constants + +/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data. +/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift. +nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + +// MARK: - Query Defaults + +enum QueryDefaults: Sendable { + nonisolated static let sessionLimit = 100 + nonisolated static let messageSearchLimit = 50 + nonisolated static let toolCallLimit = 50 + nonisolated static let sessionPreviewLimit = 10 + nonisolated static let previewContentLength = 100 + nonisolated static let logLineLimit = 200 + nonisolated static let defaultSilenceThreshold = 200 +} + +// MARK: - File Size Formatting + +enum FileSizeUnit: Sendable { + nonisolated static let kilobyte = 1_024.0 + nonisolated static let megabyte = 1_048_576.0 +} diff --git a/scarf/scarf/Core/Models/HermesTool.swift b/scarf/scarf/Core/Models/HermesTool.swift index 21f6943..e240876 100644 --- a/scarf/scarf/Core/Models/HermesTool.swift +++ b/scarf/scarf/Core/Models/HermesTool.swift @@ -16,8 +16,9 @@ struct HermesToolPlatform: Identifiable, Sendable { } enum KnownPlatforms { + static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal") static let all: [HermesToolPlatform] = [ - HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"), + cli, HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"), HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"), HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"), diff --git a/scarf/scarf/Core/Services/HermesDataService.swift b/scarf/scarf/Core/Services/HermesDataService.swift index fa5fbbb..a10a955 100644 --- a/scarf/scarf/Core/Services/HermesDataService.swift +++ b/scarf/scarf/Core/Services/HermesDataService.swift @@ -24,7 +24,7 @@ actor HermesDataService { db = nil } - func fetchSessions(limit: Int = 100) -> [HermesSession] { + func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] { guard let db else { return [] } let sql = """ SELECT id, source, user_id, model, title, parent_session_id, @@ -59,7 +59,7 @@ actor HermesDataService { var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } defer { sqlite3_finalize(stmt) } - sqlite3_bind_text(stmt, 1, sessionId, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient) var messages: [HermesMessage] = [] while sqlite3_step(stmt) == SQLITE_ROW { @@ -68,7 +68,7 @@ actor HermesDataService { return messages } - func searchMessages(query: String, limit: Int = 50) -> [HermesMessage] { + func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] { guard let db else { return [] } let sql = """ SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, @@ -82,7 +82,7 @@ actor HermesDataService { var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } defer { sqlite3_finalize(stmt) } - sqlite3_bind_text(stmt, 1, query, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + sqlite3_bind_text(stmt, 1, query, -1, sqliteTransient) sqlite3_bind_int(stmt, 2, Int32(limit)) var messages: [HermesMessage] = [] @@ -92,7 +92,7 @@ actor HermesDataService { return messages } - func fetchRecentToolCalls(limit: Int = 50) -> [HermesMessage] { + func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] { guard let db else { return [] } let sql = """ SELECT id, session_id, role, content, tool_call_id, tool_calls, @@ -114,10 +114,10 @@ actor HermesDataService { return messages } - func fetchSessionPreviews(limit: Int = 10) -> [String: String] { + func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] { guard let db else { return [:] } let sql = """ - SELECT m.session_id, substr(m.content, 1, 100) + SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength)) FROM messages m INNER JOIN ( SELECT session_id, MIN(id) as min_id @@ -149,13 +149,15 @@ actor HermesDataService { let totalInputTokens: Int let totalOutputTokens: Int let totalCostUSD: Double + + static let empty = SessionStats( + totalSessions: 0, totalMessages: 0, totalToolCalls: 0, + totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0 + ) } func fetchStats() -> SessionStats { - guard let db else { - return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0, - totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0) - } + guard let db else { return .empty } let sql = """ SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0), COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), @@ -163,16 +165,9 @@ actor HermesDataService { FROM sessions """ var stmt: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { - return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0, - totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0) - } + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty } defer { sqlite3_finalize(stmt) } - - guard sqlite3_step(stmt) == SQLITE_ROW else { - return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0, - totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0) - } + guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty } return SessionStats( totalSessions: Int(sqlite3_column_int(stmt, 0)), totalMessages: Int(sqlite3_column_int(stmt, 1)), @@ -344,7 +339,12 @@ actor HermesDataService { private func parseToolCalls(_ json: String?) -> [HermesToolCall] { guard let json, !json.isEmpty, let data = json.data(using: .utf8) else { return [] } - return (try? JSONDecoder().decode([HermesToolCall].self, from: data)) ?? [] + do { + return try JSONDecoder().decode([HermesToolCall].self, from: data) + } catch { + print("[Scarf] Failed to decode tool calls: \(error.localizedDescription)") + return [] + } } private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String { diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 0f1d2ef..32be55c 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -44,7 +44,7 @@ struct HermesFileService: Sendable { showReasoning: values["display.show_reasoning"] == "true", verbose: values["agent.verbose"] == "true", autoTTS: values["voice.auto_tts"] != "false", - silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? 200 + silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold ) } @@ -52,7 +52,12 @@ struct HermesFileService: Sendable { func loadGatewayState() -> GatewayState? { guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil } - return try? JSONDecoder().decode(GatewayState.self, from: data) + do { + return try JSONDecoder().decode(GatewayState.self, from: data) + } catch { + print("[Scarf] Failed to decode gateway state: \(error.localizedDescription)") + return nil + } } // MARK: - Memory @@ -77,8 +82,13 @@ struct HermesFileService: Sendable { func loadCronJobs() -> [HermesCronJob] { guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] } - let file = try? JSONDecoder().decode(CronJobsFile.self, from: data) - return file?.jobs ?? [] + do { + let file = try JSONDecoder().decode(CronJobsFile.self, from: data) + return file.jobs + } catch { + print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)") + return [] + } } func loadCronOutput(jobId: String) -> String? { @@ -123,7 +133,13 @@ struct HermesFileService: Sendable { } func loadSkillContent(path: String) -> String { - readFile(path) ?? "" + // Validate path stays within the skills directory to prevent traversal + guard !path.contains(".."), + path.hasPrefix(HermesPaths.skillsDir) else { + print("[Scarf] Rejected skill path outside skills directory: \(path)") + return "" + } + return readFile(path) ?? "" } // MARK: - Hermes Process @@ -156,6 +172,10 @@ struct HermesFileService: Sendable { } private func writeFile(_ path: String, content: String) { - try? content.write(toFile: path, atomically: true, encoding: .utf8) + do { + try content.write(toFile: path, atomically: true, encoding: .utf8) + } catch { + print("[Scarf] Failed to write \(path): \(error.localizedDescription)") + } } } diff --git a/scarf/scarf/Core/Services/HermesLogService.swift b/scarf/scarf/Core/Services/HermesLogService.swift index b05199d..59887dd 100644 --- a/scarf/scarf/Core/Services/HermesLogService.swift +++ b/scarf/scarf/Core/Services/HermesLogService.swift @@ -39,12 +39,16 @@ actor HermesLogService { } func closeLog() { - try? fileHandle?.close() + do { + try fileHandle?.close() + } catch { + print("[Scarf] Failed to close log handle: \(error.localizedDescription)") + } fileHandle = nil currentPath = nil } - func readLastLines(count: Int = 200) -> [LogEntry] { + func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] { guard let path = currentPath, let data = FileManager.default.contents(atPath: path) else { return [] } let content = String(data: data, encoding: .utf8) ?? "" diff --git a/scarf/scarf/Core/Services/ProjectDashboardService.swift b/scarf/scarf/Core/Services/ProjectDashboardService.swift index dbc3fe6..c17a50a 100644 --- a/scarf/scarf/Core/Services/ProjectDashboardService.swift +++ b/scarf/scarf/Core/Services/ProjectDashboardService.swift @@ -8,14 +8,23 @@ struct ProjectDashboardService: Sendable { guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else { return ProjectRegistry(projects: []) } - return (try? JSONDecoder().decode(ProjectRegistry.self, from: data)) - ?? ProjectRegistry(projects: []) + do { + return try JSONDecoder().decode(ProjectRegistry.self, from: data) + } catch { + print("[Scarf] Failed to decode project registry: \(error.localizedDescription)") + return ProjectRegistry(projects: []) + } } func saveRegistry(_ registry: ProjectRegistry) { let dir = HermesPaths.scarfDir if !FileManager.default.fileExists(atPath: dir) { - try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + do { + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + } catch { + print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)") + return + } } guard let data = try? JSONEncoder().encode(registry) else { return } // Pretty-print for readability (agents may read this file) @@ -33,7 +42,12 @@ struct ProjectDashboardService: Sendable { guard let data = FileManager.default.contents(atPath: project.dashboardPath) else { return nil } - return try? JSONDecoder().decode(ProjectDashboard.self, from: data) + do { + return try JSONDecoder().decode(ProjectDashboard.self, from: data) + } catch { + print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)") + return nil + } } func dashboardExists(for project: ProjectEntry) -> Bool { diff --git a/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift b/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift index fbaf879..9c15c3a 100644 --- a/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift +++ b/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift @@ -5,10 +5,7 @@ final class DashboardViewModel { private let dataService = HermesDataService() private let fileService = HermesFileService() - var stats = HermesDataService.SessionStats( - totalSessions: 0, totalMessages: 0, totalToolCalls: 0, - totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0 - ) + var stats = HermesDataService.SessionStats.empty var recentSessions: [HermesSession] = [] var sessionPreviews: [String: String] = [:] var config = HermesConfig.empty diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift index 6aad57c..f76ede3 100644 --- a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift +++ b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift @@ -158,10 +158,10 @@ final class SessionsViewModel { let fileSize: String if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath), let size = attrs[.size] as? Int { - if size >= 1_048_576 { - fileSize = String(format: "%.1f MB", Double(size) / 1_048_576) + if Double(size) >= FileSizeUnit.megabyte { + fileSize = String(format: "%.1f MB", Double(size) / FileSizeUnit.megabyte) } else { - fileSize = String(format: "%.0f KB", Double(size) / 1_024) + fileSize = String(format: "%.0f KB", Double(size) / FileSizeUnit.kilobyte) } } else { fileSize = "unknown" diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index 83f71b1..0ecbf89 100644 --- a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -18,7 +18,12 @@ final class SettingsViewModel { config = fileService.loadConfig() gatewayState = fileService.loadGatewayState() hermesRunning = fileService.isHermesRunning() - rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? "" + do { + rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) + } catch { + print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)") + rawConfigYAML = "" + } personalities = parsePersonalities() } diff --git a/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift index db31c22..95949e8 100644 --- a/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift +++ b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift @@ -2,7 +2,7 @@ import Foundation @Observable final class ToolsViewModel { - var selectedPlatform: HermesToolPlatform = KnownPlatforms.all[0] + var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli var toolsets: [HermesToolset] = [] var mcpStatus: String = "" var isLoading = false @@ -30,7 +30,13 @@ final class ToolsViewModel { } private func loadPlatforms() { - let config = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? "" + let config: String + do { + config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) + } catch { + print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)") + config = "" + } var platforms: [HermesToolPlatform] = [] var inSection = false for line in config.components(separatedBy: "\n") { @@ -54,9 +60,10 @@ final class ToolsViewModel { } } } - availablePlatforms = platforms.isEmpty ? [KnownPlatforms.all[0]] : platforms - if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }) { - selectedPlatform = availablePlatforms[0] + availablePlatforms = platforms.isEmpty ? [KnownPlatforms.cli] : platforms + if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }), + let first = availablePlatforms.first { + selectedPlatform = first } } From 2a3e8b142226be82593ac73b10699cf62aa9f879 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 2 Apr 2026 10:29:05 -0400 Subject: [PATCH 2/3] Add webview widget for embedded web browser in project dashboards New widget type that renders any URL (local dev servers, HTML reports) directly in the dashboard via WKWebView. Sections with webviews automatically split layout: grid widgets left, webview right. Configurable height, non-persistent data store, navigation error logging. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 19 +++- scarf/docs/DASHBOARD_SCHEMA.md | 18 +++- .../scarf/Core/Models/ProjectDashboard.swift | 4 + .../Projects/Views/ProjectsView.swift | 51 +++++++++-- .../Views/Widgets/WebviewWidgetView.swift | 87 +++++++++++++++++++ 5 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift diff --git a/README.md b/README.md index f077b48..64807ff 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ - **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke) - **Cron Manager** — View scheduled jobs, their status, prompts, and output - **Log Viewer** — Real-time log tailing with level filtering and text search -- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, and rich text in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically +- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically - **Settings** — Structured config editor for all Hermes settings - **Menu Bar** — Status icon showing Hermes running state with quick actions @@ -144,7 +144,7 @@ The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` an ## Project Dashboards -Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, and rich text — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically. +Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically. ### What You Can Build @@ -227,6 +227,21 @@ Select your project in the Projects sidebar — the dashboard renders immediatel | `table` | Data table with headers | `columns`, `rows` | | `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) | | `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) | +| `webview` | Embedded web browser | `url`, `height` (default 400) | + +The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates. When a section contains a webview alongside other widgets, Scarf automatically splits the layout: widgets on the left, webview on the right. If the section only has a webview, it takes the full width. + +```json +{ + "type": "webview", + "title": "Project Report", + "url": "http://localhost:8000/dashboard", + "height": 500 +} +``` + +- `url`: Any URL — typically a local server (`http://localhost:...`) or file path +- `height`: Height in points (default: 400) **Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray diff --git a/scarf/docs/DASHBOARD_SCHEMA.md b/scarf/docs/DASHBOARD_SCHEMA.md index 65427ea..fa4250c 100644 --- a/scarf/docs/DASHBOARD_SCHEMA.md +++ b/scarf/docs/DASHBOARD_SCHEMA.md @@ -141,13 +141,29 @@ Create `.scarf/dashboard.json` in your project root: - `status`: "done" (checkmark), "active" (filled circle), "pending" (empty circle) +### webview — Embedded web browser + +```json +{ + "type": "webview", + "title": "Project Dashboard", + "url": "http://localhost:8000", + "height": 500 +} +``` + +- `url`: Any URL — local servers, file paths, or remote pages +- `height`: Height in points (optional, default: 400) + +When a section contains a webview alongside other widgets, Scarf splits the layout automatically: grid widgets on the left, webview on the right. If the section contains only a webview, it uses the full width. + ## Agent Instructions To have your Hermes agent generate a dashboard, include these instructions: > Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics, > status indicators, and visualizations. Use the Scarf dashboard schema with sections -> containing stat, progress, text, table, chart, and list widgets. Register the project +> containing stat, progress, text, table, chart, list, and webview widgets. Register the project > in `~/.hermes/scarf/projects.json` if not already registered. The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically. diff --git a/scarf/scarf/Core/Models/ProjectDashboard.swift b/scarf/scarf/Core/Models/ProjectDashboard.swift index 681908c..8d8aa86 100644 --- a/scarf/scarf/Core/Models/ProjectDashboard.swift +++ b/scarf/scarf/Core/Models/ProjectDashboard.swift @@ -69,6 +69,10 @@ struct DashboardWidget: Codable, Sendable, Identifiable { // List let items: [ListItem]? + + // Webview + let url: String? + let height: Double? } // MARK: - Widget Value (String or Number) diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index e2bbfc5..43a272d 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -152,16 +152,53 @@ struct ProjectsView: View { struct DashboardSectionView: View { let section: DashboardSection + private var gridWidgets: [DashboardWidget] { + section.widgets.filter { $0.type != "webview" } + } + + private var webviewWidgets: [DashboardWidget] { + section.widgets.filter { $0.type == "webview" } + } + + private var hasWebview: Bool { !webviewWidgets.isEmpty } + var body: some View { VStack(alignment: .leading, spacing: 8) { Text(section.title) .font(.headline) - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount), - spacing: 12 - ) { - ForEach(section.widgets) { widget in - WidgetView(widget: widget) + if hasWebview && !gridWidgets.isEmpty { + // Split layout: widgets on left, webview on right + HStack(alignment: .top, spacing: 12) { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: max(1, section.columnCount / 2)), + spacing: 12 + ) { + ForEach(gridWidgets) { widget in + WidgetView(widget: widget) + } + } + .frame(maxWidth: .infinity) + VStack(spacing: 12) { + ForEach(webviewWidgets) { widget in + WebviewWidgetView(widget: widget) + } + } + .frame(maxWidth: .infinity) + } + } else if hasWebview { + // Webview only — full width + ForEach(webviewWidgets) { widget in + WebviewWidgetView(widget: widget) + } + } else { + // Standard grid + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount), + spacing: 12 + ) { + ForEach(gridWidgets) { widget in + WidgetView(widget: widget) + } } } } @@ -188,6 +225,8 @@ struct WidgetView: View { ChartWidgetView(widget: widget) case "list": ListWidgetView(widget: widget) + case "webview": + WebviewWidgetView(widget: widget) default: VStack { Image(systemName: "questionmark.square.dashed") diff --git a/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift new file mode 100644 index 0000000..99ba506 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift @@ -0,0 +1,87 @@ +import SwiftUI +import WebKit + +struct WebviewWidgetView: View { + let widget: DashboardWidget + + private var webURL: URL? { + guard let urlString = widget.url else { return nil } + return URL(string: urlString) + } + + private var viewHeight: CGFloat { + CGFloat(widget.height ?? 400) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + if let icon = widget.icon { + Image(systemName: icon) + .foregroundStyle(.secondary) + .font(.caption) + } + Text(widget.title) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if let urlString = widget.url { + Text(urlString) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + if let url = webURL { + WebViewRepresentable(url: url) + .frame(height: viewHeight) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + ContentUnavailableView { + Label("Invalid URL", systemImage: "globe") + } description: { + Text(widget.url ?? "No URL provided") + } + .frame(height: viewHeight) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - WKWebView Wrapper + +private struct WebViewRepresentable: NSViewRepresentable { + let url: URL + + func makeNSView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = context.coordinator + webView.load(URLRequest(url: url)) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + if webView.url != url { + webView.load(URLRequest(url: url)) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject, WKNavigationDelegate { + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + print("[Scarf] WebView navigation failed: \(error.localizedDescription)") + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + print("[Scarf] WebView failed to load: \(error.localizedDescription)") + } + } +} From ef53ac1c93e028c4b105dd4070823d8bccbd22e8 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 2 Apr 2026 12:03:50 -0400 Subject: [PATCH 3/3] Replace webview split layout with tabbed Dashboard/Site interface Dashboards with a webview widget now show a tab bar: Dashboard tab renders all normal widgets, Site tab displays the web content full-canvas with even margins. Cleaner UX than the split layout. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 9 +- scarf/docs/DASHBOARD_SCHEMA.md | 2 +- .../Projects/Views/ProjectsView.swift | 124 +++++++++++------- .../Views/Widgets/WebviewWidgetView.swift | 29 ++++ 4 files changed, 116 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 64807ff..5ffc387 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Project Dashboards turn Scarf into a customizable monitoring hub for all your pr - **Deployment monitors** — deploy history tables, uptime stats, error rate charts - **Research dashboards** — experiment results, key findings, paper status checklists - **Agent activity views** — cron job results, content generation stats, task completion rates +- **Embedded web apps** — local dev servers, HTML reports, Grafana dashboards, any web-based tool your agent generates - **Any project status** — if your agent can measure it, Scarf can display it ### Quick Start @@ -229,7 +230,9 @@ Select your project in the Projects sidebar — the dashboard renders immediatel | `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) | | `webview` | Embedded web browser | `url`, `height` (default 400) | -The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates. When a section contains a webview alongside other widgets, Scarf automatically splits the layout: widgets on the left, webview on the right. If the section only has a webview, it takes the full width. +The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates. + +When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows your normal widgets, **Site** shows the web content full-canvas with clean margins — using the entire available space in the app. This gives you the best of both worlds: compact metrics at a glance, and a full embedded browser when you need it. ```json { @@ -241,7 +244,7 @@ The `webview` widget embeds a live web browser directly in your dashboard — pe ``` - `url`: Any URL — typically a local server (`http://localhost:...`) or file path -- `height`: Height in points (default: 400) +- `height`: Height in points when displayed as an inline widget card (default: 400). The Site tab always uses full available space regardless of this setting. **Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray @@ -251,7 +254,7 @@ The `webview` widget embeds a live web browser directly in your dashboard — pe The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context: -> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, and lists for task tracking. Register the project in `~/.hermes/scarf/projects.json` if not already registered. +> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, lists for task tracking, and a webview widget if the project has a local web server or HTML reports. Register the project in `~/.hermes/scarf/projects.json` if not already registered. Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time. diff --git a/scarf/docs/DASHBOARD_SCHEMA.md b/scarf/docs/DASHBOARD_SCHEMA.md index fa4250c..963a6ba 100644 --- a/scarf/docs/DASHBOARD_SCHEMA.md +++ b/scarf/docs/DASHBOARD_SCHEMA.md @@ -155,7 +155,7 @@ Create `.scarf/dashboard.json` in your project root: - `url`: Any URL — local servers, file paths, or remote pages - `height`: Height in points (optional, default: 400) -When a section contains a webview alongside other widgets, Scarf splits the layout automatically: grid widgets on the left, webview on the right. If the section contains only a webview, it uses the full width. +When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows all normal widgets, **Site** displays the web content full-canvas. The webview widget is automatically filtered out of the Dashboard tab's grid layout. ## Agent Instructions diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index 43a272d..c84b451 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -1,10 +1,16 @@ import SwiftUI +private enum DashboardTab: String, CaseIterable { + case dashboard = "Dashboard" + case site = "Site" +} + struct ProjectsView: View { @State private var viewModel = ProjectsViewModel() @Environment(AppCoordinator.self) private var coordinator @Environment(HermesFileWatcher.self) private var fileWatcher @State private var showingAddSheet = false + @State private var selectedTab: DashboardTab = .dashboard var body: some View { HSplitView { @@ -76,18 +82,36 @@ struct ProjectsView: View { // MARK: - Dashboard Area + /// First webview widget found across all sections, if any. + private var siteWidget: DashboardWidget? { + viewModel.dashboard?.sections + .flatMap(\.widgets) + .first { $0.type == "webview" } + } + @ViewBuilder private var dashboardArea: some View { if let dashboard = viewModel.dashboard { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - dashboardHeader(dashboard) - ForEach(dashboard.sections) { section in - DashboardSectionView(section: section) + VStack(spacing: 0) { + dashboardHeader(dashboard) + .padding(.horizontal) + .padding(.top) + .padding(.bottom, 8) + if siteWidget != nil { + tabBar + .padding(.horizontal) + .padding(.bottom, 8) + } + switch selectedTab { + case .dashboard: + widgetsTab(dashboard) + case .site: + if let widget = siteWidget { + siteTab(widget) + } else { + widgetsTab(dashboard) } } - .padding() - .frame(maxWidth: .infinity, alignment: .topLeading) } } else if let error = viewModel.dashboardError { ContentUnavailableView { @@ -112,6 +136,48 @@ struct ProjectsView: View { } } + private var tabBar: some View { + HStack(spacing: 0) { + ForEach(DashboardTab.allCases, id: \.self) { tab in + Button { + selectedTab = tab + } label: { + HStack(spacing: 4) { + Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe") + .font(.caption) + Text(tab.rawValue) + .font(.subheadline) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear) + .foregroundStyle(selectedTab == tab ? .primary : .secondary) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + Spacer() + } + } + + private func widgetsTab(_ dashboard: ProjectDashboard) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + ForEach(dashboard.sections) { section in + DashboardSectionView(section: section) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + private func siteTab(_ widget: DashboardWidget) -> some View { + WebviewWidgetView(widget: widget, fullCanvas: true) + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func dashboardHeader(_ dashboard: ProjectDashboard) -> some View { HStack { VStack(alignment: .leading, spacing: 2) { @@ -152,51 +218,21 @@ struct ProjectsView: View { struct DashboardSectionView: View { let section: DashboardSection - private var gridWidgets: [DashboardWidget] { + /// Filter out webview widgets — those are rendered in the Site tab instead. + private var displayWidgets: [DashboardWidget] { section.widgets.filter { $0.type != "webview" } } - private var webviewWidgets: [DashboardWidget] { - section.widgets.filter { $0.type == "webview" } - } - - private var hasWebview: Bool { !webviewWidgets.isEmpty } - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(section.title) - .font(.headline) - if hasWebview && !gridWidgets.isEmpty { - // Split layout: widgets on left, webview on right - HStack(alignment: .top, spacing: 12) { - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: max(1, section.columnCount / 2)), - spacing: 12 - ) { - ForEach(gridWidgets) { widget in - WidgetView(widget: widget) - } - } - .frame(maxWidth: .infinity) - VStack(spacing: 12) { - ForEach(webviewWidgets) { widget in - WebviewWidgetView(widget: widget) - } - } - .frame(maxWidth: .infinity) - } - } else if hasWebview { - // Webview only — full width - ForEach(webviewWidgets) { widget in - WebviewWidgetView(widget: widget) - } - } else { - // Standard grid + if !displayWidgets.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text(section.title) + .font(.headline) LazyVGrid( columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount), spacing: 12 ) { - ForEach(gridWidgets) { widget in + ForEach(displayWidgets) { widget in WidgetView(widget: widget) } } diff --git a/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift index 99ba506..c1d68ac 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift @@ -3,6 +3,7 @@ import WebKit struct WebviewWidgetView: View { let widget: DashboardWidget + var fullCanvas: Bool = false private var webURL: URL? { guard let urlString = widget.url else { return nil } @@ -14,6 +15,34 @@ struct WebviewWidgetView: View { } var body: some View { + if fullCanvas { + fullCanvasView + } else { + cardView + } + } + + // MARK: - Full Canvas (Site tab) + + private var fullCanvasView: some View { + VStack(spacing: 0) { + if let url = webURL { + WebViewRepresentable(url: url) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + ContentUnavailableView { + Label("Invalid URL", systemImage: "globe") + } description: { + Text(widget.url ?? "No URL provided") + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Card (inline widget) + + private var cardView: some View { VStack(alignment: .leading, spacing: 6) { HStack { if let icon = widget.icon {