Skip to content

Commit 0ff64fc

Browse files
wesmclaude
andauthored
Windows support: installer fix, sqlite_scanner elimination, CI, test fixes (#66)
## Summary - **Fix Windows installer** to use `.zip` archives (matching release workflow) with `Expand-Archive`, including error handling and PS version guard - **Eliminate sqlite_scanner dependency on Windows**: skip DuckDB's sqlite extension entirely, route detail queries through direct SQLite, use CSV intermediate path for cache building with explicit TIMESTAMP type overrides - **Graceful sqlite_scanner fallback on Linux/macOS**: log and continue instead of hard-failing, supporting air-gapped/offline environments - **Add Windows CI job** (`test-windows` on `windows-latest`) with artifact upload - **Fix all Windows test failures**: skip Unix permission checks (0600/0755), fix `validateRelativePath` for rooted paths, exclude `....` filename on Windows - **Fix CSV temp dir permissions**: use database parent directory instead of system temp (restricted on Windows for downloaded executables) - **README**: remove Windows known-issues warning, upgrade pre-alpha → alpha ## Test plan - [x] `make test` passes on macOS - [x] `make lint` passes - [x] Windows CI job passes (test-windows) - [x] Manual test: `msgvault tui` works on Windows with CSV fallback path - [x] Manual test: `install.ps1` downloads and extracts `.zip` correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 84f254a commit 0ff64fc

File tree

14 files changed

+471
-77
lines changed

14 files changed

+471
-77
lines changed

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,27 @@ jobs:
2323

2424
- name: Lint
2525
run: make lint
26+
27+
test-windows:
28+
runs-on: windows-latest
29+
steps:
30+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
31+
32+
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
33+
with:
34+
go-version-file: go.mod
35+
36+
- name: Build
37+
env:
38+
CGO_ENABLED: "1"
39+
run: go build -tags fts5 -o msgvault.exe ./cmd/msgvault
40+
41+
- name: Test
42+
env:
43+
CGO_ENABLED: "1"
44+
run: go test -tags fts5 ./...
45+
46+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
47+
with:
48+
name: msgvault-windows-amd64
49+
path: msgvault.exe

.roborev.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@ We are using the latest production version of Go
55
Please be pragmatic about reviews, raising theoretical or highly
66
pedantic concerns will result in unnecessary code churn and wasted
77
review-fix cycles.
8+
9+
The Windows CSV fallback path uses \\N as a NULL sentinel (PostgreSQL
10+
convention) with DuckDB's nullstr option. This is an accepted design
11+
choice — do not flag it as a concern.
812
"""

README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66

77
[Documentation](https://msgvault.io) · [Setup Guide](https://msgvault.io/guides/oauth-setup/) · [Interactive TUI](https://msgvault.io/usage/tui/)
88

9-
> **Pre-alpha software.** APIs, storage format, and CLI flags may change without notice. Back up your data.
10-
11-
> **Windows users:** There are a number of known issues on Windows that I am actively working to resolve. Fixes are coming within the next few days. Thank you for your patience.
9+
> **Alpha software.** APIs, storage format, and CLI flags may change without notice. Back up your data.
1210
1311
Archive a lifetime of email. Analytics and search in milliseconds, entirely offline.
1412

cmd/msgvault/cmd/build_cache.go

Lines changed: 157 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package cmd
22

33
import (
44
"database/sql"
5+
"encoding/csv"
56
"encoding/json"
67
"fmt"
78
"os"
89
"path/filepath"
10+
"runtime"
911
"strings"
1012
"time"
1113

@@ -126,16 +128,13 @@ func buildCache(dbPath, analyticsDir string, fullRebuild bool) (*buildResult, er
126128
}
127129
defer db.Close()
128130

129-
// Install and load SQLite extension
130-
if _, err := db.Exec("INSTALL sqlite; LOAD sqlite;"); err != nil {
131-
return nil, fmt.Errorf("load sqlite extension: %w", err)
132-
}
133-
134-
// Attach SQLite database
135-
escapedPath := strings.ReplaceAll(dbPath, "'", "''")
136-
if _, err := db.Exec(fmt.Sprintf("ATTACH '%s' AS sqlite_db (TYPE sqlite, READ_ONLY)", escapedPath)); err != nil {
137-
return nil, fmt.Errorf("attach sqlite: %w", err)
131+
// Set up sqlite_db tables — either via DuckDB's sqlite extension (Linux/macOS)
132+
// or via CSV intermediate files (Windows, where sqlite_scanner is unavailable).
133+
cleanup, err := setupSQLiteSource(db, dbPath)
134+
if err != nil {
135+
return nil, err
138136
}
137+
defer cleanup()
139138

140139
// On full rebuild, clear existing cache
141140
if fullRebuild {
@@ -487,6 +486,155 @@ var cacheStatsCmd = &cobra.Command{
487486
},
488487
}
489488

489+
// setupSQLiteSource makes SQLite tables available to DuckDB as sqlite_db.*.
490+
// On Linux/macOS it uses DuckDB's sqlite extension (ATTACH).
491+
// On Windows it exports tables to CSV and creates DuckDB views, since the
492+
// sqlite_scanner extension is not available for MinGW builds.
493+
func setupSQLiteSource(duckDB *sql.DB, dbPath string) (cleanup func(), err error) {
494+
if runtime.GOOS != "windows" {
495+
// Try sqlite_scanner extension; fall back to CSV if unavailable
496+
// (e.g. air-gapped environment with no internet for extension download).
497+
if _, err := duckDB.Exec("INSTALL sqlite; LOAD sqlite;"); err != nil {
498+
fmt.Fprintf(os.Stderr, " sqlite_scanner unavailable, using CSV fallback: %v\n", err)
499+
} else {
500+
escapedPath := strings.ReplaceAll(dbPath, "'", "''")
501+
if _, err := duckDB.Exec(fmt.Sprintf("ATTACH '%s' AS sqlite_db (TYPE sqlite, READ_ONLY)", escapedPath)); err != nil {
502+
fmt.Fprintf(os.Stderr, " sqlite attach failed, using CSV fallback: %v\n", err)
503+
} else {
504+
return func() {}, nil
505+
}
506+
}
507+
}
508+
509+
// CSV fallback: export SQLite tables to CSV, create DuckDB views.
510+
// Use the database's parent directory for temp files instead of the
511+
// system temp dir, which can have restricted permissions on Windows
512+
// (e.g. for downloaded executables).
513+
tmpDir, err := os.MkdirTemp(filepath.Dir(dbPath), ".cache-tmp-*")
514+
if err != nil {
515+
return nil, fmt.Errorf("create temp dir: %w", err)
516+
}
517+
518+
sqliteDB, err := sql.Open("sqlite3", dbPath+"?mode=ro")
519+
if err != nil {
520+
os.RemoveAll(tmpDir)
521+
return nil, fmt.Errorf("open sqlite for CSV export: %w", err)
522+
}
523+
524+
// Tables and the SELECT queries to export them.
525+
// Column lists match what the COPY-to-Parquet queries expect.
526+
tables := []struct {
527+
name string
528+
query string
529+
typeOverrides string // DuckDB types parameter for read_csv_auto (empty = infer all)
530+
}{
531+
{"messages", "SELECT id, source_id, source_message_id, conversation_id, subject, snippet, sent_at, size_estimate, has_attachments, deleted_from_source_at FROM messages WHERE sent_at IS NOT NULL",
532+
"types={'sent_at': 'TIMESTAMP', 'deleted_from_source_at': 'TIMESTAMP'}"},
533+
{"message_recipients", "SELECT message_id, participant_id, recipient_type, display_name FROM message_recipients", ""},
534+
{"message_labels", "SELECT message_id, label_id FROM message_labels", ""},
535+
{"attachments", "SELECT message_id, size, filename FROM attachments", ""},
536+
{"participants", "SELECT id, email_address, domain, display_name FROM participants", ""},
537+
{"labels", "SELECT id, name FROM labels", ""},
538+
{"sources", "SELECT id, identifier FROM sources", ""},
539+
}
540+
541+
for _, t := range tables {
542+
csvPath := filepath.Join(tmpDir, t.name+".csv")
543+
if err := exportToCSV(sqliteDB, t.query, csvPath); err != nil {
544+
sqliteDB.Close()
545+
os.RemoveAll(tmpDir)
546+
return nil, fmt.Errorf("export %s to CSV: %w", t.name, err)
547+
}
548+
}
549+
sqliteDB.Close()
550+
551+
// Create sqlite_db schema with views pointing to CSV files.
552+
// This lets the existing COPY queries reference sqlite_db.tablename unchanged.
553+
if _, err := duckDB.Exec("CREATE SCHEMA sqlite_db"); err != nil {
554+
os.RemoveAll(tmpDir)
555+
return nil, fmt.Errorf("create sqlite_db schema: %w", err)
556+
}
557+
for _, t := range tables {
558+
csvPath := filepath.Join(tmpDir, t.name+".csv")
559+
// DuckDB handles both forward and backslash paths, but normalize to forward.
560+
escaped := strings.ReplaceAll(csvPath, "\\", "/")
561+
escaped = strings.ReplaceAll(escaped, "'", "''")
562+
csvOpts := "header=true, nullstr='\\N'"
563+
if t.typeOverrides != "" {
564+
csvOpts += ", " + t.typeOverrides
565+
}
566+
viewSQL := fmt.Sprintf(
567+
`CREATE VIEW sqlite_db."%s" AS SELECT * FROM read_csv_auto('%s', %s)`,
568+
t.name, escaped, csvOpts,
569+
)
570+
if _, err := duckDB.Exec(viewSQL); err != nil {
571+
os.RemoveAll(tmpDir)
572+
return nil, fmt.Errorf("create view sqlite_db.%s: %w", t.name, err)
573+
}
574+
}
575+
576+
return func() { os.RemoveAll(tmpDir) }, nil
577+
}
578+
579+
// csvNullStr is written for NULL values in CSV exports so DuckDB can
580+
// distinguish NULL from empty string via the nullstr option.
581+
const csvNullStr = `\N`
582+
583+
// exportToCSV exports the results of a SQL query to a CSV file.
584+
// NULL values are written as \N (PostgreSQL convention).
585+
func exportToCSV(db *sql.DB, query string, dest string) error {
586+
rows, err := db.Query(query)
587+
if err != nil {
588+
return err
589+
}
590+
defer rows.Close()
591+
592+
f, err := os.Create(dest)
593+
if err != nil {
594+
return err
595+
}
596+
defer f.Close()
597+
598+
w := csv.NewWriter(f)
599+
600+
cols, err := rows.Columns()
601+
if err != nil {
602+
return err
603+
}
604+
if err := w.Write(cols); err != nil {
605+
return err
606+
}
607+
608+
values := make([]sql.NullString, len(cols))
609+
ptrs := make([]interface{}, len(cols))
610+
for i := range values {
611+
ptrs[i] = &values[i]
612+
}
613+
614+
for rows.Next() {
615+
if err := rows.Scan(ptrs...); err != nil {
616+
return err
617+
}
618+
record := make([]string, len(cols))
619+
for i, v := range values {
620+
if v.Valid {
621+
record[i] = v.String
622+
} else {
623+
record[i] = csvNullStr
624+
}
625+
}
626+
if err := w.Write(record); err != nil {
627+
return err
628+
}
629+
}
630+
631+
w.Flush()
632+
if err := w.Error(); err != nil {
633+
return err
634+
}
635+
return rows.Err()
636+
}
637+
490638
func init() {
491639
rootCmd.AddCommand(buildCacheCmd)
492640
rootCmd.AddCommand(cacheStatsCmd)

0 commit comments

Comments
 (0)