···11-# BlueSky Browser (Desktop)
11+<!-- markdownlint-disable MD033 -->
22+# bsky-browser
33+44+Desktop app for searching your Bluesky bookmarks and likes with a local SQLite index, full-text search, rich-text facet rendering, and a built-in log viewer.
55+66+## What It Does
77+88+- Authenticates with Bluesky using loopback OAuth.
99+- Stores session state, tokens, and DPoP metadata in a shared SQLite database.
1010+- Indexes bookmarks and likes into a local FTS5-backed search index.
1111+- Lets you browse recent indexed posts or search by text.
1212+- Renders rich-text facets for links, mentions, and hashtags.
1313+- Includes desktop-side refresh progress events and a frontend log viewer.
1414+1515+## Screenshots
1616+1717+
1818+1919+
2020+2121+
2222+2323+
2424+2525+## Usage
2626+2727+1. Launch the app.
2828+2. Enter your Bluesky handle and complete OAuth in the browser.
2929+3. Click `Refresh` to index bookmarks and likes.
3030+4. Use the search box to run FTS queries, or leave it empty to browse recent posts.
3131+5. Filter results by `All`, `Saved`, or `Liked`.
3232+6. Click a row to open the original post on `bsky.app`.
3333+3434+## Keyboard Shortcuts
3535+3636+- `Cmd+K` or `Ctrl+K`: focus the search input
3737+- `Cmd+R` or `Ctrl+R`: refresh indexed data
3838+- `Cmd+L` or `Ctrl+L`: toggle the log viewer
3939+4040+## Project
4141+4242+### Requirements
4343+4444+- Go
4545+- [Wails v2](https://wails.io/)
4646+- Node.js
4747+- `pnpm`
4848+4949+### Install
5050+5151+```bash
5252+git clone <your-repo-url>
5353+cd bsky-browser-gui
5454+pnpm --dir frontend install
5555+```
5656+5757+If you prefer `task`, the same setup is available through:
5858+5959+```bash
6060+task init
6161+```
6262+6363+### Development
6464+6565+Start the desktop app with hot reload:
6666+6767+```bash
6868+wails dev
6969+```
7070+7171+Or:
7272+7373+```bash
7474+task dev
7575+```
7676+7777+Useful checks:
7878+7979+```bash
8080+go test ./...
8181+pnpm --dir frontend check
8282+```
8383+8484+<details>
8585+<summary>OAuth and Local Data</summary>
8686+8787+- OAuth callback URL: `http://127.0.0.1:8787/callback`
8888+- Default database path: `~/.config/bsky-browser/bsky-browser.db`
8989+- Default log directory: `~/.config/bsky-browser/logs/`
9090+9191+You can override paths with:
9292+9393+- `BSKY_BROWSER_DATA`
9494+- `BSKY_BROWSER_LOG`
9595+- `XDG_CONFIG_HOME`
9696+9797+</details>
9898+9999+<details>
100100+<summary>Project Structure</summary>
101101+102102+- [app.go](/Users/owais/Desktop/bsky-browser-gui/app.go): app startup/shutdown wiring
103103+- [auth_service.go](/Users/owais/Desktop/bsky-browser-gui/auth_service.go): Bluesky OAuth flow and session refresh
104104+- [database.go](/Users/owais/Desktop/bsky-browser-gui/database.go): SQLite access, migrations, FTS search
105105+- [index_service.go](/Users/owais/Desktop/bsky-browser-gui/index_service.go): bookmark/like indexing
106106+- [search_service.go](/Users/owais/Desktop/bsky-browser-gui/search_service.go): Wails search bindings
107107+- [log_service.go](/Users/owais/Desktop/bsky-browser-gui/log_service.go): log event streaming
108108+- [frontend/src/App.svelte](/Users/owais/Desktop/bsky-browser-gui/frontend/src/App.svelte): main UI shell
109109+110110+</details>
111111+112112+<details>
113113+<summary>Notes</summary>
114114+115115+- Session state is persisted so token refreshes and DPoP nonce updates survive app restarts.
116116+- Empty searches intentionally return recent posts instead of sending an invalid FTS wildcard query.
117117+118118+</details>
119119+120120+### Production Build
121121+122122+Create a macOS app bundle:
123123+124124+```bash
125125+wails build
126126+```
127127+128128+Verified output:
129129+130130+```text
131131+build/bin/bsky-browser-gui.app
132132+```
133133+134134+Equivalent task:
135135+136136+```bash
137137+task build
138138+```
+3-3
TODO.md
···6565- [x] Empty state: show "No posts indexed" with prompt to refresh
6666- [x] Error handling: toast/notification for network failures, auth expiry
6767- [x] Keyboard shortcuts: `Cmd+K` focus search, `Cmd+R` refresh, `Cmd+L` toggle log viewer
6868-- [ ] Window title and app icon (`build/appicon.png`)
6969-- [ ] Production build verification (`wails build` → macOS `.app` bundle)
7070-- [ ] README with build instructions, screenshots, and usage
6868+- [x] Window title and app icon (`build/appicon.png`)
6969+- [x] Production build verification (`wails build` → macOS `.app` bundle)
7070+- [~] README with build instructions, screenshots, and usage
+2-2
Taskfile.yml
···2424 init:
2525 desc: Initialize the project (install dependencies)
2626 cmds:
2727- - cd frontend && npm install
2727+ - cd frontend && pnpm install
28282929 check:
3030 desc: Check TypeScript and Svelte
3131 cmds:
3232- - cd frontend && npm run check
3232+ - cd frontend && pnpm check
+3-2
app.go
···3232func (a *App) startup(ctx context.Context) {
3333 a.ctx = ctx
34343535- a.indexService.SetContext(ctx)
3636- a.logService.SetContext(ctx)
3535+ a.authService.setContext(ctx)
3636+ a.indexService.setContext(ctx)
3737+ a.logService.setContext(ctx)
37383839 // Initialize log service first
3940 if err := a.logService.Initialize(); err != nil {
+10-3
auth_service.go
···1212 "github.com/bluesky-social/indigo/atproto/auth/oauth"
1313 "github.com/bluesky-social/indigo/atproto/identity"
1414 "github.com/bluesky-social/indigo/atproto/syntax"
1515+ "github.com/wailsapp/wails/v2/pkg/runtime"
1516)
16171718// AuthService provides authentication functionality via Wails bindings
1819type AuthService struct {
2020+ ctx context.Context
1921 app *oauth.ClientApp
2022 server *http.Server
2123 listener net.Listener
···3032 codeChan: make(chan string, 1),
3133 errChan: make(chan error, 1),
3234 }
3535+}
3636+3737+func (s *AuthService) setContext(ctx context.Context) {
3838+ s.ctx = ctx
3339}
34403541// Login initiates OAuth login flow for the given handle
···151157 return nil
152158}
153159154154-// Whoami returns the current authenticated user, optionally resolving handle from DID
155155-//
156156-// TODO: store [context.Context] in [AuthService] to be able to use wails' runtime.LogWarningf
160160+// Whoami returns the current authenticated user, optionally resolving handle from DID.
157161func (s *AuthService) Whoami(force bool) (*Auth, error) {
158162 auth, err := GetAuth()
159163 if err != nil {
···172176 dir := &identity.BaseDirectory{}
173177 ident, err := dir.LookupDID(context.Background(), did)
174178 if err != nil {
179179+ if s.ctx != nil {
180180+ runtime.LogWarningf(s.ctx, "failed to resolve handle for %s: %v", auth.DID, err)
181181+ }
175182 return auth, nil
176183 }
177184
···11+export type IndexStats = { fetched: number; inserted: number; errors: number; total: number };
-3
frontend/wailsjs/go/main/IndexService.d.ts
···11// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
22// This file is automatically generated. DO NOT EDIT
33-import {context} from '../models';
4354export function IsIndexing():Promise<boolean>;
6576export function Refresh(arg1:number):Promise<void>;
88-99-export function SetContext(arg1:context.Context):Promise<void>;
-4
frontend/wailsjs/go/main/IndexService.js
···99export function Refresh(arg1) {
1010 return window['go']['main']['IndexService']['Refresh'](arg1);
1111}
1212-1313-export function SetContext(arg1) {
1414- return window['go']['main']['IndexService']['SetContext'](arg1);
1515-}
+1-2
index_service.go
···4949 return &IndexService{}
5050}
51515252-// SetContext sets the Wails context for event emission
5353-func (s *IndexService) SetContext(ctx context.Context) {
5252+func (s *IndexService) setContext(ctx context.Context) {
5453 s.ctx = ctx
5554}
5655
+1-8
log_service.go
···5959 continue
6060 }
61616262- // Parse the log level from the line
6362 level := w.parseLevel(line)
64636564 entry := LogEntry{
···9897 }
9998}
10099101101-// SetContext sets the Wails context for event emission
102102-func (s *LogService) SetContext(ctx context.Context) {
100100+func (s *LogService) setContext(ctx context.Context) {
103101 s.ctx = ctx
104102}
105103106104// Initialize sets up the log service with a file writer
107105func (s *LogService) Initialize() error {
108108- // Open log file
109106 logPath := s.getLogPath()
110107 logDir := filepath.Dir(logPath)
111108 if err := os.MkdirAll(logDir, 0755); err != nil {
···118115 }
119116 s.file = file
120117121121- // Create the log writer
122118 s.writer = &LogWriter{service: s}
123123-124119 return nil
125120}
126121···201196 s.mu.Lock()
202197 defer s.mu.Unlock()
203198204204- // Only add if level is >= current level
205199 if !s.shouldLog(entry.Level) {
206200 return
207201 }
208202209203 s.entries = append(s.entries, entry)
210204211211- // Trim if exceeding max entries
212205 if len(s.entries) > s.maxEntries {
213206 s.entries = s.entries[len(s.entries)-s.maxEntries:]
214207 }
-2
logger.go
···2222 service: service,
2323 }
24242525- // Redirect standard log to our logger
2625 log.SetOutput(multiWriter)
2726}
28272928// GetLogger returns the global app logger
3029func GetLogger() *AppLogger {
3130 if appLogger == nil {
3232- // Fallback to stdout if not initialized
3331 return &AppLogger{
3432 stdLogger: log.New(os.Stdout, "", log.LstdFlags),
3533 }