···11+# Wails Desktop App — Roadmap
22+33+## Milestone 1 — Scaffold & Tooling
44+55+- [x] Install Tailwind v4 (`tailwindcss`, `@tailwindcss/vite`) and wire `vite.config.ts`
66+- [x] Install Fontsource packages (`@fontsource/jetbrains-mono`, `@fontsource-variable/geist`, `@fontsource-variable/lora`)
77+- [x] Create `frontend/src/index.css` with `@import "tailwindcss"` and `@theme` tokens (palette, fonts)
88+- [x] Put font CSS imports in `App.svelte`
99+- [x] Set up `Taskfile.yml` for build tasks
1010+- [x] Verify `wails dev` hot-reloads a "Hello World" page with correct fonts and theme
1111+1212+## Milestone 2 — Database Layer
1313+1414+- [x] Implement `database.go` — `Open()`, `Close()`, embedded migrations via `//go:embed`
1515+- [x] Copy existing migrations from CLI (`000`–`003`) and add `004_add_facets_column.sql` (`ALTER TABLE posts ADD COLUMN facets TEXT`)
1616+- [x] Enable WAL mode (`PRAGMA journal_mode=WAL`) on connection open
1717+- [x] Implement `models.go` — `Post`, `Auth`, `SearchResult` structs (add `Facets` field to `Post`)
1818+- [x] Implement `PostExists`, `InsertPost`, `UpsertAuth`, `GetAuth`, `SearchPosts`, `CountPosts`
1919+- [x] Verify the desktop app and CLI can read/write the same database concurrently
2020+2121+## Milestone 3 — Authentication
2222+2323+- [ ] Implement `AuthService` struct with Wails service binding
2424+- [ ] `Login(handle)` — loopback OAuth via `oauth.NewLocalhostConfig`, persist tokens to shared DB
2525+- [ ] `Whoami(force)` — load auth from DB, optionally resolve handle from DID
2626+- [ ] `IsAuthenticated()` — check for valid auth row
2727+- [ ] Automatic token refresh on `OnStartup` lifecycle hook
2828+- [ ] Frontend: login view with handle input, "Login" button, and status display
2929+3030+## Milestone 4 — Indexing & Progress
3131+3232+- [ ] Implement `IndexService` struct with Wails service binding
3333+- [ ] `Refresh(limit)` — concurrent bookmark + like fetch, batch insert (port `RefreshAndIndex` logic)
3434+- [ ] Populate `facets` column from `FeedPost.Facets` during `convertPostView`
3535+- [ ] `IsIndexing()` — thread-safe boolean guard to prevent concurrent refreshes
3636+- [ ] Emit Wails events: `index:started`, `index:progress`, `index:done`
3737+- [ ] Frontend: "Refresh" button in header, optional limit input
3838+- [ ] Frontend: bottom-pinned progress bar component driven by `index:*` events
3939+4040+## Milestone 5 — Search & Data Table
4141+4242+- [ ] Implement `SearchService` struct with Wails service binding
4343+- [ ] `Search(query, source)` — FTS5 query with BM25 ranking and source filter
4444+- [ ] `CountPosts()` — total indexed post count
4545+- [ ] Frontend: search bar with query input and source filter (All / Saved / Liked segmented control)
4646+- [ ] Frontend: tabbed data table component (Saved / Liked / All tabs)
4747+- [ ] Columns: Author Handle, Text (truncated), Created At, ♥ Likes, 🔁 Reposts, 💬 Replies, Source
4848+- [ ] Client-side column sorting (click header to toggle asc/desc)
4949+- [ ] Row click → open post URL in default browser via `runtime.BrowserOpenURL`
5050+5151+## Milestone 6 — Facets & Log Viewer
5252+5353+- [ ] Frontend: facet parser — convert UTF-8 byte offsets to JS string indices
5454+- [ ] Frontend: facet renderer — links (`<a>`), mentions (`@handle`), hashtags (`#tag`)
5555+- [ ] Integrate rendered facets into post text in data table rows
5656+- [ ] Implement `LogService` — custom `io.Writer` that emits `log:line` events
5757+- [ ] Wire `LogService` writer into `log.NewWithOptions` alongside file writer
5858+- [ ] Frontend: log viewer panel with terminal-style dark background, monospace text
5959+- [ ] Auto-scroll to bottom with scroll-lock toggle
6060+- [ ] Level filter buttons: Debug, Info, Warn, Error
6161+6262+## Milestone 7 — Polish
6363+6464+- [ ] Empty state: show "No posts indexed" with prompt to refresh
6565+- [ ] Error handling: toast/notification for network failures, auth expiry
6666+- [ ] Keyboard shortcuts: `Cmd+K` focus search, `Cmd+R` refresh, `Cmd+L` toggle log viewer
6767+- [ ] Window title and app icon (`build/appicon.png`)
6868+- [ ] Production build verification (`wails3 build` → macOS `.app` bundle)
6969+- [ ] README with build instructions, screenshots, and usage
+32
Taskfile.yml
···11+version: '3'
22+33+tasks:
44+ dev:
55+ desc: Run the application in development mode with hot reload
66+ cmds:
77+ - wails dev
88+99+ build:
1010+ desc: Build the application for production
1111+ cmds:
1212+ - wails build
1313+1414+ build:clean:
1515+ desc: Clean and build the application for production
1616+ cmds:
1717+ - wails build -clean
1818+1919+ generate:
2020+ desc: Generate Wails bindings
2121+ cmds:
2222+ - wails generate module
2323+2424+ init:
2525+ desc: Initialize the project (install dependencies)
2626+ cmds:
2727+ - cd frontend && npm install
2828+2929+ check:
3030+ desc: Check TypeScript and Svelte
3131+ cmds:
3232+ - cd frontend && npm run check
+32-2
app.go
···33import (
44 "context"
55 "fmt"
66+ "os"
77+ "path/filepath"
68)
79810// App struct
···1517 return &App{}
1618}
17191818-// startup is called when the app starts. The context is saved
1919-// so we can call the runtime methods
2020+// startup is called when the app starts.
2121+//
2222+// The context is saved so we can call the runtime methods
2023func (a *App) startup(ctx context.Context) {
2124 a.ctx = ctx
2525+2626+ dbPath := getDBPath()
2727+ if err := Open(dbPath); err != nil {
2828+ fmt.Printf("failed to open database: %v\n", err)
2929+ }
3030+}
3131+3232+// shutdown is called when the app shuts down
3333+func (a *App) shutdown(ctx context.Context) {
3434+ if err := Close(); err != nil {
3535+ fmt.Printf("failed to close database: %v\n", err)
3636+ }
3737+}
3838+3939+// getDBPath returns the path to the shared database
4040+func getDBPath() string {
4141+ if dir := os.Getenv("BSKY_BROWSER_DATA"); dir != "" {
4242+ return filepath.Join(dir, "bsky-browser.db")
4343+ }
4444+4545+ configDir := os.Getenv("XDG_CONFIG_HOME")
4646+ if configDir == "" {
4747+ home, _ := os.UserHomeDir()
4848+ configDir = filepath.Join(home, ".config")
4949+ }
5050+5151+ return filepath.Join(configDir, "bsky-browser", "bsky-browser.db")
2252}
23532454// Greet returns a greeting for the given name
+323
database.go
···11+package main
22+33+import (
44+ "database/sql"
55+ "embed"
66+ "fmt"
77+ "os"
88+ "path/filepath"
99+ "time"
1010+1111+ _ "modernc.org/sqlite"
1212+)
1313+1414+var db *sql.DB
1515+1616+//go:embed migrations/*.sql
1717+var migrationsFS embed.FS
1818+1919+// Open opens the database connection and runs migrations
2020+func Open(dbPath string) error {
2121+ fmt.Printf("opening database: %s\n", dbPath)
2222+2323+ dir := filepath.Dir(dbPath)
2424+ if err := os.MkdirAll(dir, 0755); err != nil {
2525+ return fmt.Errorf("failed to create database directory: %w", err)
2626+ }
2727+2828+ var err error
2929+ db, err = sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)")
3030+ if err != nil {
3131+ return fmt.Errorf("failed to open database: %w", err)
3232+ }
3333+3434+ if err := db.Ping(); err != nil {
3535+ return fmt.Errorf("failed to ping database: %w", err)
3636+ }
3737+3838+ _, err = db.Exec("PRAGMA journal_mode=WAL")
3939+ if err != nil {
4040+ return fmt.Errorf("failed to enable WAL mode: %w", err)
4141+ }
4242+4343+ fmt.Println("database connection established with WAL mode")
4444+4545+ if err := runMigrations(); err != nil {
4646+ return fmt.Errorf("failed to run migrations: %w", err)
4747+ }
4848+4949+ fmt.Println("database migrations completed successfully")
5050+ return nil
5151+}
5252+5353+func runMigrations() error {
5454+ content, err := migrationsFS.ReadFile("migrations/000_initial_schema.sql")
5555+ if err != nil {
5656+ return fmt.Errorf("failed to read migration: %w", err)
5757+ }
5858+5959+ if _, err := db.Exec(string(content)); err != nil {
6060+ return fmt.Errorf("failed to execute migration: %w", err)
6161+ }
6262+6363+ return nil
6464+}
6565+6666+// Close closes the database connection
6767+func Close() error {
6868+ fmt.Println("closing database connection")
6969+ if db != nil {
7070+ err := db.Close()
7171+ if err != nil {
7272+ fmt.Printf("failed to close database: %v\n", err)
7373+ return err
7474+ }
7575+ fmt.Println("database connection closed")
7676+ }
7777+ return nil
7878+}
7979+8080+// PostExists checks if a post with the given URI already exists in the database
8181+func PostExists(uri string) (bool, error) {
8282+ var exists bool
8383+ err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM posts WHERE uri = ?)", uri).Scan(&exists)
8484+ if err != nil {
8585+ return false, err
8686+ }
8787+ return exists, nil
8888+}
8989+9090+// InsertPost inserts a post into the database
9191+func InsertPost(post *Post) error {
9292+ fmt.Printf("inserting post: %s by %s\n", post.URI, post.AuthorHandle)
9393+9494+ exists, err := PostExists(post.URI)
9595+ if err != nil {
9696+ fmt.Printf("failed to check if post exists: %s, error: %v\n", post.URI, err)
9797+ return err
9898+ }
9999+100100+ if exists {
101101+ fmt.Printf("skipping already indexed post: %s\n", post.URI)
102102+ return nil
103103+ }
104104+105105+ query := `
106106+ INSERT INTO posts (uri, cid, author_did, author_handle, text, created_at, like_count, repost_count, reply_count, source, facets)
107107+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
108108+ ON CONFLICT(uri) DO UPDATE SET
109109+ cid = excluded.cid,
110110+ author_did = excluded.author_did,
111111+ author_handle = excluded.author_handle,
112112+ text = excluded.text,
113113+ created_at = excluded.created_at,
114114+ like_count = excluded.like_count,
115115+ repost_count = excluded.repost_count,
116116+ reply_count = excluded.reply_count,
117117+ source = excluded.source,
118118+ facets = excluded.facets,
119119+ indexed_at = CURRENT_TIMESTAMP
120120+ `
121121+122122+ _, err = db.Exec(query,
123123+ post.URI,
124124+ post.CID,
125125+ post.AuthorDID,
126126+ post.AuthorHandle,
127127+ post.Text,
128128+ post.CreatedAt,
129129+ post.LikeCount,
130130+ post.RepostCount,
131131+ post.ReplyCount,
132132+ post.Source,
133133+ post.Facets,
134134+ )
135135+136136+ if err != nil {
137137+ fmt.Printf("failed to insert post: %s, error: %v\n", post.URI, err)
138138+ }
139139+140140+ return err
141141+}
142142+143143+// UpsertAuth inserts or updates auth information
144144+func UpsertAuth(auth *Auth) error {
145145+ fmt.Printf("upserting auth: %s (%s)\n", auth.DID, auth.Handle)
146146+147147+ query := `
148148+ INSERT INTO auth (did, handle, access_jwt, refresh_jwt, pds_url, session_id,
149149+ auth_server_url, auth_server_token_endpoint, auth_server_revocation_endpoint,
150150+ dpop_auth_nonce, dpop_host_nonce, dpop_private_key, updated_at)
151151+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
152152+ ON CONFLICT(did) DO UPDATE SET
153153+ handle = excluded.handle,
154154+ access_jwt = excluded.access_jwt,
155155+ refresh_jwt = excluded.refresh_jwt,
156156+ pds_url = excluded.pds_url,
157157+ session_id = excluded.session_id,
158158+ auth_server_url = excluded.auth_server_url,
159159+ auth_server_token_endpoint = excluded.auth_server_token_endpoint,
160160+ auth_server_revocation_endpoint = excluded.auth_server_revocation_endpoint,
161161+ dpop_auth_nonce = excluded.dpop_auth_nonce,
162162+ dpop_host_nonce = excluded.dpop_host_nonce,
163163+ dpop_private_key = excluded.dpop_private_key,
164164+ updated_at = CURRENT_TIMESTAMP
165165+ `
166166+167167+ _, err := db.Exec(query,
168168+ auth.DID,
169169+ auth.Handle,
170170+ auth.AccessJWT,
171171+ auth.RefreshJWT,
172172+ auth.PDSURL,
173173+ auth.SessionID,
174174+ auth.AuthServerURL,
175175+ auth.AuthServerTokenEndpoint,
176176+ auth.AuthServerRevocationEndpoint,
177177+ auth.DPoPAuthNonce,
178178+ auth.DPoPHostNonce,
179179+ auth.DPoPPrivateKey,
180180+ )
181181+182182+ if err != nil {
183183+ fmt.Printf("failed to upsert auth: %s, error: %v\n", auth.DID, err)
184184+ }
185185+186186+ return err
187187+}
188188+189189+// GetAuth loads the auth record from the database
190190+func GetAuth() (*Auth, error) {
191191+ fmt.Println("loading auth from database")
192192+193193+ query := `SELECT did, handle, access_jwt, refresh_jwt, pds_url, session_id,
194194+ auth_server_url, auth_server_token_endpoint, auth_server_revocation_endpoint,
195195+ dpop_auth_nonce, dpop_host_nonce, dpop_private_key, updated_at
196196+ FROM auth LIMIT 1`
197197+198198+ var auth Auth
199199+ var updatedAt string
200200+201201+ var sessionID, authServerURL, authServerTokenEndpoint, authServerRevocationEndpoint, dpopAuthNonce, dpopHostNonce, dpopPrivateKey sql.NullString
202202+203203+ err := db.QueryRow(query).Scan(
204204+ &auth.DID,
205205+ &auth.Handle,
206206+ &auth.AccessJWT,
207207+ &auth.RefreshJWT,
208208+ &auth.PDSURL,
209209+ &sessionID,
210210+ &authServerURL,
211211+ &authServerTokenEndpoint,
212212+ &authServerRevocationEndpoint,
213213+ &dpopAuthNonce,
214214+ &dpopHostNonce,
215215+ &dpopPrivateKey,
216216+ &updatedAt,
217217+ )
218218+219219+ if sessionID.Valid {
220220+ auth.SessionID = sessionID.String
221221+ }
222222+ if authServerURL.Valid {
223223+ auth.AuthServerURL = authServerURL.String
224224+ }
225225+ if authServerTokenEndpoint.Valid {
226226+ auth.AuthServerTokenEndpoint = authServerTokenEndpoint.String
227227+ }
228228+ if authServerRevocationEndpoint.Valid {
229229+ auth.AuthServerRevocationEndpoint = authServerRevocationEndpoint.String
230230+ }
231231+ if dpopAuthNonce.Valid {
232232+ auth.DPoPAuthNonce = dpopAuthNonce.String
233233+ }
234234+ if dpopHostNonce.Valid {
235235+ auth.DPoPHostNonce = dpopHostNonce.String
236236+ }
237237+ if dpopPrivateKey.Valid {
238238+ auth.DPoPPrivateKey = dpopPrivateKey.String
239239+ }
240240+241241+ if err == sql.ErrNoRows {
242242+ fmt.Println("no auth record found in database")
243243+ return nil, nil
244244+ }
245245+ if err != nil {
246246+ fmt.Printf("failed to load auth: %v\n", err)
247247+ return nil, err
248248+ }
249249+250250+ auth.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
251251+ fmt.Printf("auth loaded successfully: %s (%s)\n", auth.DID, auth.Handle)
252252+ return &auth, nil
253253+}
254254+255255+// SearchPosts searches posts using FTS5
256256+func SearchPosts(query string, source string) ([]SearchResult, error) {
257257+ fmt.Printf("searching posts: query=%s, source=%s\n", query, source)
258258+259259+ sqlQuery := `
260260+ SELECT p.uri, p.cid, p.author_did, p.author_handle, p.text, p.created_at,
261261+ p.like_count, p.repost_count, p.reply_count, p.source, p.indexed_at,
262262+ bm25(posts_fts, 5.0, 1.0) AS rank
263263+ FROM posts_fts
264264+ JOIN posts p ON posts_fts.rowid = p.rowid
265265+ WHERE posts_fts MATCH ?
266266+ AND (? = '' OR p.source = ?)
267267+ ORDER BY rank
268268+ LIMIT 25
269269+ `
270270+271271+ rows, err := db.Query(sqlQuery, query, source, source)
272272+ if err != nil {
273273+ fmt.Printf("failed to execute search query: %v\n", err)
274274+ return nil, err
275275+ }
276276+ defer rows.Close()
277277+278278+ var results []SearchResult
279279+ for rows.Next() {
280280+ var r SearchResult
281281+ var createdAt, indexedAt string
282282+283283+ err := rows.Scan(
284284+ &r.URI,
285285+ &r.CID,
286286+ &r.AuthorDID,
287287+ &r.AuthorHandle,
288288+ &r.Text,
289289+ &createdAt,
290290+ &r.LikeCount,
291291+ &r.RepostCount,
292292+ &r.ReplyCount,
293293+ &r.Source,
294294+ &indexedAt,
295295+ &r.Rank,
296296+ )
297297+ if err != nil {
298298+ return nil, err
299299+ }
300300+301301+ r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
302302+ r.IndexedAt, _ = time.Parse("2006-01-02 15:04:05", indexedAt)
303303+ results = append(results, r)
304304+ }
305305+306306+ fmt.Printf("search completed: %d results\n", len(results))
307307+ return results, rows.Err()
308308+}
309309+310310+// CountPosts returns the total number of posts in the database
311311+func CountPosts() (int, error) {
312312+ fmt.Println("counting posts in database")
313313+314314+ var count int
315315+ err := db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&count)
316316+ if err != nil {
317317+ fmt.Printf("failed to count posts: %v\n", err)
318318+ return 0, err
319319+ }
320320+321321+ fmt.Printf("post count: %d\n", count)
322322+ return count, nil
323323+}
···11+-- Combined initial schema for bsky-browser-desktop
22+-- Includes all migrations: auth, posts, FTS5, OAuth fields, and facets
33+44+-- Auth table with all OAuth fields
55+CREATE TABLE IF NOT EXISTS auth (
66+ did TEXT PRIMARY KEY,
77+ handle TEXT NOT NULL,
88+ access_jwt TEXT NOT NULL,
99+ refresh_jwt TEXT NOT NULL,
1010+ pds_url TEXT NOT NULL,
1111+ session_id TEXT,
1212+ auth_server_url TEXT,
1313+ auth_server_token_endpoint TEXT,
1414+ auth_server_revocation_endpoint TEXT,
1515+ dpop_auth_nonce TEXT,
1616+ dpop_host_nonce TEXT,
1717+ dpop_private_key TEXT,
1818+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
1919+);
2020+2121+-- Posts table with facets support
2222+CREATE TABLE IF NOT EXISTS posts (
2323+ uri TEXT PRIMARY KEY,
2424+ cid TEXT NOT NULL,
2525+ author_did TEXT NOT NULL,
2626+ author_handle TEXT NOT NULL,
2727+ text TEXT NOT NULL DEFAULT '',
2828+ created_at DATETIME NOT NULL,
2929+ like_count INTEGER DEFAULT 0,
3030+ repost_count INTEGER DEFAULT 0,
3131+ reply_count INTEGER DEFAULT 0,
3232+ source TEXT NOT NULL CHECK(source IN ('saved', 'liked')),
3333+ facets TEXT,
3434+ indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP
3535+);
3636+3737+-- FTS5 virtual table for full-text search
3838+CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
3939+ text,
4040+ author_handle,
4141+ content='posts',
4242+ content_rowid='rowid',
4343+ tokenize='unicode61'
4444+);
4545+4646+-- Triggers to keep FTS5 index in sync with posts table
4747+CREATE TRIGGER IF NOT EXISTS posts_ai AFTER INSERT ON posts BEGIN
4848+ INSERT INTO posts_fts(rowid, text, author_handle)
4949+ VALUES (new.rowid, new.text, new.author_handle);
5050+END;
5151+5252+CREATE TRIGGER IF NOT EXISTS posts_ad AFTER DELETE ON posts BEGIN
5353+ INSERT INTO posts_fts(posts_fts, rowid, text, author_handle)
5454+ VALUES ('delete', old.rowid, old.text, old.author_handle);
5555+END;
5656+5757+CREATE TRIGGER IF NOT EXISTS posts_au AFTER UPDATE ON posts BEGIN
5858+ INSERT INTO posts_fts(posts_fts, rowid, text, author_handle)
5959+ VALUES ('delete', old.rowid, old.text, old.author_handle);
6060+ INSERT INTO posts_fts(rowid, text, author_handle)
6161+ VALUES (new.rowid, new.text, new.author_handle);
6262+END;
+44
models.go
···11+package main
22+33+import (
44+ "time"
55+)
66+77+// Post represents a Bluesky post in the database
88+type Post struct {
99+ URI string `json:"uri"`
1010+ CID string `json:"cid"`
1111+ AuthorDID string `json:"author_did"`
1212+ AuthorHandle string `json:"author_handle"`
1313+ Text string `json:"text"`
1414+ CreatedAt time.Time `json:"created_at"`
1515+ LikeCount int `json:"like_count"`
1616+ RepostCount int `json:"repost_count"`
1717+ ReplyCount int `json:"reply_count"`
1818+ Source string `json:"source"` // 'saved' or 'liked'
1919+ Facets string `json:"facets"` // JSON-encoded facets
2020+ IndexedAt time.Time `json:"indexed_at"`
2121+}
2222+2323+// Auth represents OAuth authentication information
2424+type Auth struct {
2525+ DID string `json:"did"`
2626+ Handle string `json:"handle"`
2727+ AccessJWT string `json:"access_jwt"`
2828+ RefreshJWT string `json:"refresh_jwt"`
2929+ PDSURL string `json:"pds_url"`
3030+ SessionID string `json:"session_id"`
3131+ AuthServerURL string `json:"auth_server_url"`
3232+ AuthServerTokenEndpoint string `json:"auth_server_token_endpoint"`
3333+ AuthServerRevocationEndpoint string `json:"auth_server_revocation_endpoint"`
3434+ DPoPAuthNonce string `json:"dpop_auth_nonce"`
3535+ DPoPHostNonce string `json:"dpop_host_nonce"`
3636+ DPoPPrivateKey string `json:"dpop_private_key"`
3737+ UpdatedAt time.Time `json:"updated_at"`
3838+}
3939+4040+// SearchResult represents a search result with BM25 ranking
4141+type SearchResult struct {
4242+ Post
4343+ Rank float64 `json:"rank"`
4444+}