search and/or read your saved and liked bluesky posts
wails go svelte sqlite desktop bluesky

feat: real-time log viewer and add facet parsing and rendering for post content

+838 -10
+8 -8
TODO.md
··· 50 50 51 51 ## Milestone 6 — Facets & Log Viewer 52 52 53 - - [ ] Frontend: facet parser — convert UTF-8 byte offsets to JS string indices 54 - - [ ] Frontend: facet renderer — links (`<a>`), mentions (`@handle`), hashtags (`#tag`) 55 - - [ ] Integrate rendered facets into post text in data table rows 56 - - [ ] Implement `LogService` — custom `io.Writer` that emits `log:line` events 57 - - [ ] Wire `LogService` writer into `log.NewWithOptions` alongside file writer 58 - - [ ] Frontend: log viewer panel with terminal-style dark background, monospace text 59 - - [ ] Auto-scroll to bottom with scroll-lock toggle 60 - - [ ] Level filter buttons: Debug, Info, Warn, Error 53 + - [x] Frontend: facet parser — convert UTF-8 byte offsets to JS string indices 54 + - [x] Frontend: facet renderer — links (`<a>`), mentions (`@handle`), hashtags (`#tag`) 55 + - [x] Integrate rendered facets into post text in data table rows 56 + - [x] Implement `LogService` — custom `io.Writer` that emits `log:line` events 57 + - [x] Wire `LogService` writer into `log.NewWithOptions` alongside file writer 58 + - [x] Frontend: log viewer panel with terminal-style dark background, monospace text 59 + - [x] Auto-scroll to bottom with scroll-lock toggle 60 + - [x] Level filter buttons: Debug, Info, Warn, Error 61 61 62 62 ## Milestone 7 — Polish 63 63
+15
app.go
··· 14 14 authService *AuthService 15 15 indexService *IndexService 16 16 searchService *SearchService 17 + logService *LogService 17 18 } 18 19 19 20 // NewApp creates a new App application struct ··· 22 23 authService: NewAuthService(), 23 24 indexService: NewIndexService(), 24 25 searchService: NewSearchService(), 26 + logService: NewLogService(), 25 27 } 26 28 } 27 29 ··· 31 33 a.ctx = ctx 32 34 33 35 a.indexService.SetContext(ctx) 36 + a.logService.SetContext(ctx) 37 + 38 + // Initialize log service first 39 + if err := a.logService.Initialize(); err != nil { 40 + runtime.LogErrorf(a.ctx, "failed to initialize log service: %v", err) 41 + } else { 42 + // Initialize the global logger with our log service 43 + InitLogger(a.logService) 44 + LogInfo("Application started") 45 + } 34 46 35 47 dbPath := getDBPath() 36 48 if err := Open(dbPath); err != nil { ··· 47 59 48 60 // shutdown is called when the app shuts down 49 61 func (a *App) shutdown(ctx context.Context) { 62 + if err := a.logService.Close(); err != nil { 63 + runtime.LogErrorf(ctx, "failed to close log service: %v", err) 64 + } 50 65 if err := Close(); err != nil { 51 66 runtime.LogErrorf(ctx, "failed to close database: %v", err) 52 67 }
+11
frontend/src/App.svelte
··· 9 9 import { EventsOn } from "../wailsjs/runtime/runtime"; 10 10 import SearchBar from "./lib/components/SearchBar.svelte"; 11 11 import DataTable from "./lib/components/DataTable.svelte"; 12 + import LogViewer from "./lib/components/LogViewer.svelte"; 12 13 import type { main } from "../wailsjs/go/models"; 13 14 14 15 type AuthInfo = { handle: string; did: string }; ··· 31 32 let sortColumn = $state("created_at"); 32 33 let sortDirection = $state<"asc" | "desc">("desc"); 33 34 let isSearching = $state(false); 35 + let showLogs = $state(false); 34 36 35 37 onMount(async () => { 36 38 await checkAuthStatus(); ··· 229 231 </div> 230 232 231 233 <div class="flex items-center gap-3"> 234 + <button 235 + onclick={() => showLogs = !showLogs} 236 + class="font-mono text-xs px-3 py-2 rounded bg-surface border border-outline hover:bg-outline text-bright transition-colors {showLogs ? 'bg-[#333]' : ''}"> 237 + {showLogs ? 'Hide Logs' : 'Show Logs'} 238 + </button> 239 + 232 240 <div class="flex items-center gap-2"> 233 241 <label for="refreshLimit" class="font-sans text-xs text-muted">Limit:</label> 234 242 <input ··· 269 277 <DataTable posts={searchResults} {sortColumn} {sortDirection} onSort={handleSort} /> 270 278 {/if} 271 279 </div> 280 + 281 + <!-- Log Viewer --> 282 + <LogViewer visible={showLogs} /> 272 283 273 284 <!-- Progress Bar (bottom pinned) --> 274 285 {#if showProgress}
+4 -1
frontend/src/lib/components/DataTable.svelte
··· 1 1 <script lang="ts"> 2 2 import { BrowserOpenURL } from "../../../wailsjs/runtime/runtime"; 3 3 import type { main } from "../../../wailsjs/go/models"; 4 + import PostText from "./PostText.svelte"; 4 5 5 6 interface Props { 6 7 posts: main.SearchResult[]; ··· 86 87 </td> 87 88 88 89 <td class="px-4 py-3 font-mono text-sm text-bright"> 89 - <div class="line-clamp-2">{truncateText(post.text)}</div> 90 + <div class="line-clamp-2"> 91 + <PostText text={post.text} facetsJson={post.facets} maxLength={120} /> 92 + </div> 90 93 </td> 91 94 92 95 <td class="px-4 py-3 font-mono text-xs text-muted">
+165
frontend/src/lib/components/LogViewer.svelte
··· 1 + <script lang="ts"> 2 + import { EventsOn } from "../../../wailsjs/runtime/runtime"; 3 + import { onMount } from "svelte"; 4 + 5 + type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"; 6 + 7 + interface LogEntry { 8 + level: LogLevel; 9 + message: string; 10 + timestamp: string; 11 + } 12 + 13 + interface Props { 14 + visible: boolean; 15 + } 16 + 17 + let { visible }: Props = $props(); 18 + 19 + let logs = $state<LogEntry[]>([]); 20 + let scrollLock = $state(false); 21 + let logContainer: HTMLDivElement | undefined = $state(undefined); 22 + let filterLevel = $state<LogLevel | "ALL">("ALL"); 23 + 24 + const levels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"]; 25 + 26 + const levelColors: Record<LogLevel, string> = { 27 + DEBUG: "text-gray-500", 28 + INFO: "text-blue-400", 29 + WARN: "text-yellow-400", 30 + ERROR: "text-red-400", 31 + }; 32 + 33 + const levelBgColors: Record<LogLevel | "ALL", string> = { 34 + ALL: "bg-gray-700", 35 + DEBUG: "bg-gray-600", 36 + INFO: "bg-blue-600", 37 + WARN: "bg-yellow-600", 38 + ERROR: "bg-red-600", 39 + }; 40 + 41 + function formatTimestamp(timestamp: string): string { 42 + const date = new Date(timestamp); 43 + return date.toLocaleTimeString("en-US", { 44 + hour12: false, 45 + hour: "2-digit", 46 + minute: "2-digit", 47 + second: "2-digit", 48 + }); 49 + } 50 + 51 + function scrollToBottom() { 52 + if (logContainer && !scrollLock) { 53 + logContainer.scrollTop = logContainer.scrollHeight; 54 + } 55 + } 56 + 57 + function toggleScrollLock() { 58 + scrollLock = !scrollLock; 59 + } 60 + 61 + function setFilterLevel(level: LogLevel | "ALL") { 62 + filterLevel = level; 63 + } 64 + 65 + function clearLogs() { 66 + logs = []; 67 + } 68 + 69 + function filteredLogs() { 70 + if (filterLevel === "ALL") { 71 + return logs; 72 + } 73 + return logs.filter((log) => log.level === filterLevel); 74 + } 75 + 76 + onMount(() => { 77 + EventsOn("log:line", (entry: LogEntry) => { 78 + logs = [...logs, entry]; 79 + 80 + if (logs.length > 1000) { 81 + logs = logs.slice(logs.length - 1000); 82 + } 83 + 84 + setTimeout(scrollToBottom, 0); 85 + }); 86 + 87 + EventsOn("log:cleared", () => { 88 + logs = []; 89 + }); 90 + }); 91 + 92 + $effect(() => { 93 + if (!scrollLock) { 94 + setTimeout(scrollToBottom, 0); 95 + } 96 + }); 97 + </script> 98 + 99 + {#if visible} 100 + <div class="border-t border-outline bg-black flex flex-col"> 101 + <!-- Header --> 102 + <div class="flex items-center justify-between px-4 py-2 bg-surface border-b border-outline"> 103 + <div class="flex items-center gap-2"> 104 + <span class="font-mono text-sm text-bright">Logs</span> 105 + <span class="font-mono text-xs text-muted">({logs.length})</span> 106 + </div> 107 + 108 + <div class="flex items-center gap-2"> 109 + <!-- Level Filter Buttons --> 110 + <div class="flex items-center gap-1 mr-4"> 111 + {#each ["ALL", ...levels] as level} 112 + <button 113 + onclick={() => setFilterLevel(level as LogLevel | "ALL")} 114 + class="font-mono text-xs px-2 py-1 rounded transition-colors {filterLevel === level 115 + ? levelBgColors[level] + ' text-white' 116 + : 'bg-black text-muted hover:text-bright'}"> 117 + {level} 118 + </button> 119 + {/each} 120 + </div> 121 + 122 + <!-- Scroll Lock Toggle --> 123 + <button 124 + onclick={toggleScrollLock} 125 + class="font-mono text-xs px-2 py-1 rounded transition-colors {scrollLock 126 + ? 'bg-yellow-600 text-white' 127 + : 'bg-black text-muted hover:text-bright'}" 128 + title={scrollLock ? "Scroll locked" : "Auto-scroll enabled"}> 129 + {#if scrollLock} 130 + <span class="flex items-center"> 131 + <i class="i-ri-lock-2-line"></i> 132 + </span> 133 + {:else} 134 + <span class="flex items-center"> 135 + <i class="i-ri-arrow-down-box-line"></i> 136 + </span> 137 + {/if} 138 + </button> 139 + 140 + <!-- Clear Button --> 141 + <button 142 + onclick={clearLogs} 143 + class="font-mono text-xs px-2 py-1 rounded bg-black text-muted hover:text-red-400 transition-colors"> 144 + Clear 145 + </button> 146 + </div> 147 + </div> 148 + 149 + <!-- Log Container --> 150 + <div 151 + bind:this={logContainer} 152 + class="flex-1 overflow-y-auto p-2 font-mono text-xs space-y-0.5" 153 + style="max-height: 200px;"> 154 + {#each filteredLogs() as log} 155 + <div class="flex items-start gap-2 hover:bg-white/5 px-1 rounded"> 156 + <span class="text-muted shrink-0">{formatTimestamp(log.timestamp)}</span> 157 + <span class="{levelColors[log.level]} shrink-0 w-12">[{log.level}]</span> 158 + <span class="text-bright break-all">{log.message}</span> 159 + </div> 160 + {:else} 161 + <div class="text-muted text-center py-4">No logs</div> 162 + {/each} 163 + </div> 164 + </div> 165 + {/if}
+61
frontend/src/lib/components/PostText.svelte
··· 1 + <script lang="ts"> 2 + import { parseFacets, renderFacets, truncateRenderedFacets, type Facet } from "../facets"; 3 + 4 + interface Props { 5 + text: string; 6 + facetsJson?: string; 7 + maxLength?: number; 8 + class?: string; 9 + } 10 + 11 + let { text, facetsJson = "", maxLength = 0, class: className = "" }: Props = $props(); 12 + 13 + function getRenderedFacets(text: string, facetsJson: string, maxLength: number) { 14 + const facets = parseFacets(facetsJson); 15 + const rendered = renderFacets(text, facets); 16 + 17 + if (maxLength > 0 && maxLength < text.length) { 18 + const { facets: truncated } = truncateRenderedFacets(rendered, maxLength); 19 + return truncated; 20 + } 21 + 22 + return rendered; 23 + } 24 + 25 + let renderedFacets = $derived(getRenderedFacets(text, facetsJson, maxLength)); 26 + </script> 27 + 28 + <span class="{className}"> 29 + {#each renderedFacets as facet} 30 + {#if facet.type === 'link'} 31 + <a 32 + href={facet.href} 33 + target="_blank" 34 + rel="noopener noreferrer" 35 + class="text-blue-400 hover:text-blue-300 hover:underline" 36 + onclick={(e) => e.stopPropagation()}> 37 + {facet.text} 38 + </a> 39 + {:else if facet.type === 'mention'} 40 + <a 41 + href={facet.href} 42 + target="_blank" 43 + rel="noopener noreferrer" 44 + class="text-blue-400 hover:text-blue-300 hover:underline" 45 + onclick={(e) => e.stopPropagation()}> 46 + {facet.text} 47 + </a> 48 + {:else if facet.type === 'tag'} 49 + <a 50 + href={facet.href} 51 + target="_blank" 52 + rel="noopener noreferrer" 53 + class="text-pink-400 hover:text-pink-300 hover:underline" 54 + onclick={(e) => e.stopPropagation()}> 55 + {facet.text} 56 + </a> 57 + {:else} 58 + <span>{facet.text}</span> 59 + {/if} 60 + {/each} 61 + </span>
+161
frontend/src/lib/facets.ts
··· 1 + export type FacetFeature = { [key: string]: any; $type: string }; 2 + 3 + export type FacetByteSlice = { byteStart: number; byteEnd: number }; 4 + 5 + export interface Facet { 6 + index: FacetByteSlice; 7 + features: FacetFeature[]; 8 + } 9 + 10 + type FacetKind = "link" | "mention" | "tag" | "text"; 11 + 12 + export type RenderedFacet = { type: FacetKind; text: string; href?: string; did?: string; tag?: string }; 13 + 14 + /** 15 + * Convert UTF-8 byte offsets to JS string indices (UTF-16 code units) 16 + */ 17 + function byteOffsetToCharIndex(text: string, byteOffset: number): number { 18 + const encoder = new TextEncoder(); 19 + let currentByte = 0; 20 + 21 + for (const [i, char] of Array.from(text).entries()) { 22 + const charBytes = encoder.encode(char).length; 23 + 24 + if (currentByte >= byteOffset) { 25 + return i; 26 + } 27 + 28 + currentByte += charBytes; 29 + } 30 + 31 + return text.length; 32 + } 33 + 34 + /** 35 + * Parse a facets JSON string and return parsed Facet objects 36 + */ 37 + export function parseFacets(facetsJson: string): Facet[] { 38 + if (!facetsJson) return []; 39 + 40 + try { 41 + const parsed = JSON.parse(facetsJson); 42 + if (Array.isArray(parsed)) { 43 + return parsed as Facet[]; 44 + } 45 + } catch (e) { 46 + console.warn("Failed to parse facets:", e); 47 + } 48 + 49 + return []; 50 + } 51 + 52 + /** 53 + * Render facets into an array of RenderedFacet objects 54 + * This converts byte offsets to JS string indices and extracts the text segments 55 + */ 56 + export function renderFacets(text: string, facets: Facet[]): RenderedFacet[] { 57 + if (!facets || facets.length === 0) { 58 + return [{ type: "text", text }]; 59 + } 60 + 61 + const sortedFacets = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart); 62 + 63 + const result: RenderedFacet[] = []; 64 + let lastByteEnd = 0; 65 + 66 + for (const facet of sortedFacets) { 67 + if (facet.index.byteStart > lastByteEnd) { 68 + const beforeStart = byteOffsetToCharIndex(text, lastByteEnd); 69 + const beforeEnd = byteOffsetToCharIndex(text, facet.index.byteStart); 70 + const beforeText = text.slice(beforeStart, beforeEnd); 71 + if (beforeText) { 72 + result.push({ type: "text", text: beforeText }); 73 + } 74 + } 75 + 76 + const facetStart = byteOffsetToCharIndex(text, facet.index.byteStart); 77 + const facetEnd = byteOffsetToCharIndex(text, facet.index.byteEnd); 78 + const facetText = text.slice(facetStart, facetEnd); 79 + 80 + for (const feature of facet.features) { 81 + const type = feature.$type; 82 + 83 + if (type === "app.bsky.richtext.facet#link") { 84 + result.push({ type: "link", text: facetText, href: feature.uri }); 85 + } else if (type === "app.bsky.richtext.facet#mention") { 86 + result.push({ 87 + type: "mention", 88 + text: facetText, 89 + did: feature.did, 90 + href: `https://bsky.app/profile/${feature.did}`, 91 + }); 92 + } else if (type === "app.bsky.richtext.facet#tag") { 93 + result.push({ 94 + type: "tag", 95 + text: facetText, 96 + tag: feature.tag, 97 + href: `https://bsky.app/search?q=%23${encodeURIComponent(feature.tag)}`, 98 + }); 99 + } else { 100 + result.push({ type: "text", text: facetText }); 101 + } 102 + 103 + // TODO: parse ALL features 104 + break; 105 + } 106 + 107 + lastByteEnd = facet.index.byteEnd; 108 + } 109 + 110 + const encoder = new TextEncoder(); 111 + const textBytes = encoder.encode(text).length; 112 + if (lastByteEnd < textBytes) { 113 + const remainingStart = byteOffsetToCharIndex(text, lastByteEnd); 114 + const remainingText = text.slice(remainingStart); 115 + if (remainingText) { 116 + result.push({ type: "text", text: remainingText }); 117 + } 118 + } 119 + 120 + return result; 121 + } 122 + 123 + /** 124 + * Get plain text with facets stripped (for truncation) 125 + */ 126 + export function getPlainText(text: string, facets: Facet[]): string { 127 + return text; 128 + } 129 + 130 + /** 131 + * Truncate rendered facets to a maximum length while preserving facet boundaries 132 + */ 133 + export function truncateRenderedFacets( 134 + rendered: RenderedFacet[], 135 + maxLen: number, 136 + ): { facets: RenderedFacet[]; truncated: boolean } { 137 + let currentLength = 0; 138 + const result: RenderedFacet[] = []; 139 + let truncated = false; 140 + 141 + for (const facet of rendered) { 142 + const remaining = maxLen - currentLength; 143 + 144 + if (remaining <= 0) { 145 + truncated = true; 146 + break; 147 + } 148 + 149 + if (facet.text.length <= remaining) { 150 + result.push(facet); 151 + currentLength += facet.text.length; 152 + } else { 153 + const truncatedText = facet.text.slice(0, remaining) + "..."; 154 + result.push({ ...facet, text: truncatedText }); 155 + truncated = true; 156 + break; 157 + } 158 + } 159 + 160 + return { facets: result, truncated }; 161 + }
+278
log_service.go
··· 1 + package main 2 + 3 + import ( 4 + "bufio" 5 + "context" 6 + "fmt" 7 + "io" 8 + "os" 9 + "path/filepath" 10 + "strings" 11 + "sync" 12 + "time" 13 + 14 + "github.com/wailsapp/wails/v2/pkg/runtime" 15 + ) 16 + 17 + // LogLevel represents the severity of a log message 18 + type LogLevel string 19 + 20 + const ( 21 + DebugLevel LogLevel = "DEBUG" 22 + InfoLevel LogLevel = "INFO" 23 + WarnLevel LogLevel = "WARN" 24 + ErrorLevel LogLevel = "ERROR" 25 + ) 26 + 27 + // LogEntry represents a single log entry 28 + type LogEntry struct { 29 + Level string `json:"level"` 30 + Message string `json:"message"` 31 + Timestamp time.Time `json:"timestamp"` 32 + } 33 + 34 + // LogService provides logging functionality via Wails bindings 35 + type LogService struct { 36 + ctx context.Context 37 + mu sync.RWMutex 38 + entries []LogEntry 39 + maxEntries int 40 + file *os.File 41 + writer *LogWriter 42 + level LogLevel 43 + } 44 + 45 + // LogWriter implements io.Writer and emits Wails events 46 + type LogWriter struct { 47 + service *LogService 48 + mu sync.Mutex 49 + } 50 + 51 + func (w *LogWriter) Write(p []byte) (n int, err error) { 52 + w.mu.Lock() 53 + defer w.mu.Unlock() 54 + 55 + lines := strings.Split(string(p), "\n") 56 + for _, line := range lines { 57 + line = strings.TrimSpace(line) 58 + if line == "" { 59 + continue 60 + } 61 + 62 + // Parse the log level from the line 63 + level := w.parseLevel(line) 64 + 65 + entry := LogEntry{ 66 + Level: string(level), 67 + Message: line, 68 + Timestamp: time.Now(), 69 + } 70 + 71 + w.service.addEntry(entry) 72 + w.service.emitLogLine(entry) 73 + } 74 + 75 + return len(p), nil 76 + } 77 + 78 + func (w *LogWriter) parseLevel(line string) LogLevel { 79 + upper := strings.ToUpper(line) 80 + if strings.Contains(upper, "ERROR") || strings.Contains(upper, "ERR") { 81 + return ErrorLevel 82 + } 83 + if strings.Contains(upper, "WARN") || strings.Contains(upper, "WARNING") { 84 + return WarnLevel 85 + } 86 + if strings.Contains(upper, "DEBUG") || strings.Contains(upper, "DBG") { 87 + return DebugLevel 88 + } 89 + return InfoLevel 90 + } 91 + 92 + // NewLogService creates a new LogService instance 93 + func NewLogService() *LogService { 94 + return &LogService{ 95 + entries: make([]LogEntry, 0), 96 + maxEntries: 1000, 97 + level: InfoLevel, 98 + } 99 + } 100 + 101 + // SetContext sets the Wails context for event emission 102 + func (s *LogService) SetContext(ctx context.Context) { 103 + s.ctx = ctx 104 + } 105 + 106 + // Initialize sets up the log service with a file writer 107 + func (s *LogService) Initialize() error { 108 + // Open log file 109 + logPath := s.getLogPath() 110 + logDir := filepath.Dir(logPath) 111 + if err := os.MkdirAll(logDir, 0755); err != nil { 112 + return fmt.Errorf("failed to create log directory: %w", err) 113 + } 114 + 115 + file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 116 + if err != nil { 117 + return fmt.Errorf("failed to open log file: %w", err) 118 + } 119 + s.file = file 120 + 121 + // Create the log writer 122 + s.writer = &LogWriter{service: s} 123 + 124 + return nil 125 + } 126 + 127 + // Close closes the log file 128 + func (s *LogService) Close() error { 129 + if s.file != nil { 130 + return s.file.Close() 131 + } 132 + return nil 133 + } 134 + 135 + // GetWriter returns the io.Writer for logging 136 + func (s *LogService) GetWriter() io.Writer { 137 + if s.writer == nil { 138 + return io.Discard 139 + } 140 + return s.writer 141 + } 142 + 143 + // GetMultiWriter returns a MultiWriter that writes to both the log file and the event emitter 144 + func (s *LogService) GetMultiWriter() io.Writer { 145 + if s.file == nil || s.writer == nil { 146 + return io.Discard 147 + } 148 + return io.MultiWriter(s.file, s.writer) 149 + } 150 + 151 + // GetEntries returns all log entries 152 + func (s *LogService) GetEntries() []LogEntry { 153 + s.mu.RLock() 154 + defer s.mu.RUnlock() 155 + 156 + result := make([]LogEntry, len(s.entries)) 157 + copy(result, s.entries) 158 + return result 159 + } 160 + 161 + // GetEntriesByLevel returns log entries filtered by level 162 + func (s *LogService) GetEntriesByLevel(level string) []LogEntry { 163 + s.mu.RLock() 164 + defer s.mu.RUnlock() 165 + 166 + var result []LogEntry 167 + for _, entry := range s.entries { 168 + if entry.Level == level { 169 + result = append(result, entry) 170 + } 171 + } 172 + return result 173 + } 174 + 175 + // Clear clears all log entries 176 + func (s *LogService) Clear() { 177 + s.mu.Lock() 178 + defer s.mu.Unlock() 179 + 180 + s.entries = make([]LogEntry, 0) 181 + s.emitLogCleared() 182 + } 183 + 184 + // SetLevel sets the minimum log level 185 + func (s *LogService) SetLevel(level string) { 186 + s.mu.Lock() 187 + defer s.mu.Unlock() 188 + 189 + s.level = LogLevel(level) 190 + } 191 + 192 + // GetLevel returns the current log level 193 + func (s *LogService) GetLevel() string { 194 + s.mu.RLock() 195 + defer s.mu.RUnlock() 196 + 197 + return string(s.level) 198 + } 199 + 200 + func (s *LogService) addEntry(entry LogEntry) { 201 + s.mu.Lock() 202 + defer s.mu.Unlock() 203 + 204 + // Only add if level is >= current level 205 + if !s.shouldLog(entry.Level) { 206 + return 207 + } 208 + 209 + s.entries = append(s.entries, entry) 210 + 211 + // Trim if exceeding max entries 212 + if len(s.entries) > s.maxEntries { 213 + s.entries = s.entries[len(s.entries)-s.maxEntries:] 214 + } 215 + } 216 + 217 + func (s *LogService) shouldLog(entryLevel string) bool { 218 + levels := map[LogLevel]int{ 219 + DebugLevel: 0, 220 + InfoLevel: 1, 221 + WarnLevel: 2, 222 + ErrorLevel: 3, 223 + } 224 + 225 + entryIdx := levels[LogLevel(entryLevel)] 226 + currentIdx := levels[s.level] 227 + 228 + return entryIdx >= currentIdx 229 + } 230 + 231 + func (s *LogService) emitLogLine(entry LogEntry) { 232 + if s.ctx != nil { 233 + runtime.EventsEmit(s.ctx, "log:line", entry) 234 + } 235 + } 236 + 237 + func (s *LogService) emitLogCleared() { 238 + if s.ctx != nil { 239 + runtime.EventsEmit(s.ctx, "log:cleared", map[string]any{}) 240 + } 241 + } 242 + 243 + func (s *LogService) getLogPath() string { 244 + if path := os.Getenv("BSKY_BROWSER_LOG"); path != "" { 245 + return path 246 + } 247 + 248 + configDir := os.Getenv("XDG_CONFIG_HOME") 249 + if configDir == "" { 250 + home, _ := os.UserHomeDir() 251 + configDir = filepath.Join(home, ".config") 252 + } 253 + 254 + appDir := filepath.Join(configDir, "bsky-browser", "logs") 255 + timestamp := time.Now().Format("2006-01-02_15-04-05") 256 + return filepath.Join(appDir, fmt.Sprintf("bsky-browser_%s.log", timestamp)) 257 + } 258 + 259 + // BufferedLogWriter wraps a writer with buffering for better performance 260 + type BufferedLogWriter struct { 261 + writer *bufio.Writer 262 + service *LogService 263 + } 264 + 265 + func NewBufferedLogWriter(service *LogService) *BufferedLogWriter { 266 + return &BufferedLogWriter{ 267 + writer: bufio.NewWriter(service.GetMultiWriter()), 268 + service: service, 269 + } 270 + } 271 + 272 + func (w *BufferedLogWriter) Write(p []byte) (n int, err error) { 273 + return w.writer.Write(p) 274 + } 275 + 276 + func (w *BufferedLogWriter) Flush() error { 277 + return w.writer.Flush() 278 + }
+134
logger.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "os" 7 + ) 8 + 9 + // AppLogger provides application logging that writes to both file and Wails events 10 + type AppLogger struct { 11 + stdLogger *log.Logger 12 + service *LogService 13 + } 14 + 15 + var appLogger *AppLogger 16 + 17 + // InitLogger initializes the application logger with the log service 18 + func InitLogger(service *LogService) { 19 + multiWriter := service.GetMultiWriter() 20 + appLogger = &AppLogger{ 21 + stdLogger: log.New(multiWriter, "", log.LstdFlags|log.Lshortfile), 22 + service: service, 23 + } 24 + 25 + // Redirect standard log to our logger 26 + log.SetOutput(multiWriter) 27 + } 28 + 29 + // GetLogger returns the global app logger 30 + func GetLogger() *AppLogger { 31 + if appLogger == nil { 32 + // Fallback to stdout if not initialized 33 + return &AppLogger{ 34 + stdLogger: log.New(os.Stdout, "", log.LstdFlags), 35 + } 36 + } 37 + return appLogger 38 + } 39 + 40 + // Debug logs a debug message 41 + func (l *AppLogger) Debug(msg string) { 42 + l.stdLogger.Printf("[DEBUG] %s", msg) 43 + } 44 + 45 + // Debugf logs a formatted debug message 46 + func (l *AppLogger) Debugf(format string, args ...interface{}) { 47 + l.stdLogger.Printf("[DEBUG] "+format, args...) 48 + } 49 + 50 + // Info logs an info message 51 + func (l *AppLogger) Info(msg string) { 52 + l.stdLogger.Printf("[INFO] %s", msg) 53 + } 54 + 55 + // Infof logs a formatted info message 56 + func (l *AppLogger) Infof(format string, args ...interface{}) { 57 + l.stdLogger.Printf("[INFO] "+format, args...) 58 + } 59 + 60 + // Warn logs a warning message 61 + func (l *AppLogger) Warn(msg string) { 62 + l.stdLogger.Printf("[WARN] %s", msg) 63 + } 64 + 65 + // Warnf logs a formatted warning message 66 + func (l *AppLogger) Warnf(format string, args ...interface{}) { 67 + l.stdLogger.Printf("[WARN] "+format, args...) 68 + } 69 + 70 + // Error logs an error message 71 + func (l *AppLogger) Error(msg string) { 72 + l.stdLogger.Printf("[ERROR] %s", msg) 73 + } 74 + 75 + // Errorf logs a formatted error message 76 + func (l *AppLogger) Errorf(format string, args ...interface{}) { 77 + l.stdLogger.Printf("[ERROR] "+format, args...) 78 + } 79 + 80 + // Fatal logs a fatal message and exits 81 + func (l *AppLogger) Fatal(msg string) { 82 + l.stdLogger.Fatalf("[FATAL] %s", msg) 83 + } 84 + 85 + // Fatalf logs a formatted fatal message and exits 86 + func (l *AppLogger) Fatalf(format string, args ...interface{}) { 87 + l.stdLogger.Fatalf("[FATAL] "+format, args...) 88 + } 89 + 90 + // Convenience functions that use the global logger 91 + func LogDebug(msg string) { 92 + GetLogger().Debug(msg) 93 + } 94 + 95 + func LogDebugf(format string, args ...interface{}) { 96 + GetLogger().Debugf(format, args...) 97 + } 98 + 99 + func LogInfo(msg string) { 100 + GetLogger().Info(msg) 101 + } 102 + 103 + func LogInfof(format string, args ...interface{}) { 104 + GetLogger().Infof(format, args...) 105 + } 106 + 107 + func LogWarn(msg string) { 108 + GetLogger().Warn(msg) 109 + } 110 + 111 + func LogWarnf(format string, args ...interface{}) { 112 + GetLogger().Warnf(format, args...) 113 + } 114 + 115 + func LogError(msg string) { 116 + GetLogger().Error(msg) 117 + } 118 + 119 + func LogErrorf(format string, args ...interface{}) { 120 + GetLogger().Errorf(format, args...) 121 + } 122 + 123 + func LogFatal(msg string) { 124 + GetLogger().Fatal(msg) 125 + } 126 + 127 + func LogFatalf(format string, args ...interface{}) { 128 + GetLogger().Fatalf(format, args...) 129 + } 130 + 131 + // String returns a string representation of the logger 132 + func (l *AppLogger) String() string { 133 + return fmt.Sprintf("AppLogger{initialized: %v}", l.service != nil) 134 + }
+1 -1
main.go
··· 22 22 BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 1}, 23 23 OnStartup: app.startup, 24 24 OnShutdown: app.shutdown, 25 - Bind: []any{app, app.authService, app.indexService, app.searchService}, 25 + Bind: []any{app, app.authService, app.indexService, app.searchService, app.logService}, 26 26 }) 27 27 28 28 if err != nil {