diff --git a/README.md b/README.md index f077b48..5ffc387 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 @@ -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 @@ -227,6 +228,23 @@ 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 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 +{ + "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 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 @@ -236,7 +254,7 @@ Select your project in the Projects sidebar — the dashboard renders immediatel 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 65427ea..963a6ba 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 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 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/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/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/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/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index e2bbfc5..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,16 +218,23 @@ struct ProjectsView: View { struct DashboardSectionView: View { let section: DashboardSection + /// Filter out webview widgets — those are rendered in the Site tab instead. + private var displayWidgets: [DashboardWidget] { + section.widgets.filter { $0.type != "webview" } + } + 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 !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(displayWidgets) { widget in + WidgetView(widget: widget) + } } } } @@ -188,6 +261,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..c1d68ac --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift @@ -0,0 +1,116 @@ +import SwiftUI +import WebKit + +struct WebviewWidgetView: View { + let widget: DashboardWidget + var fullCanvas: Bool = false + + 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 { + 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 { + 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)") + } + } +} 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 } }