Upload images to your PDS and get instant CDN URLs via images.blue

try and make this work on mac and windows

evan.jarrett.net a26b3e62 c6f0e625

verified
+11 -2
Makefile
··· 1 - .PHONY: build install build-windows 1 + .PHONY: build install build-linux build-darwin build-windows 2 2 3 + # Default build (native platform) 3 4 build: 4 5 go build -o ./blup ./cmd/blup 5 6 6 7 install: 7 8 go install ./cmd/blup 8 9 10 + # Linux build (Wayland support, requires libwayland-dev) 11 + build-linux: 12 + CGO_ENABLED=1 GOOS=linux go build -o ./blup ./cmd/blup 13 + 14 + # macOS build (requires Xcode Command Line Tools for CGO) 15 + build-darwin: 16 + CGO_ENABLED=1 GOOS=darwin go build -o ./blup ./cmd/blup 17 + 18 + # Windows build (no CGO required for clipboard) 9 19 build-windows: 10 20 GOOS=windows go build -o ./blup.exe ./cmd/blup 11 - osslsigncode sign -pkcs12 ../mycert.pfx -askpass -n "Blup" -i "https://blup.imgs.blue" -in ./blup.exe -out ./blup-signed.exe
+56 -43
cmd/blup/main.go
··· 260 260 return "", fmt.Errorf("authentication failed: %w", err) 261 261 } 262 262 263 - // Get API client from session 264 - apiClient := sess.APIClient() 263 + // Upload blob with automatic re-authentication on token refresh failure 264 + var out atproto.RepoUploadBlob_Output 265 + sess, err = auth.ExecuteWithReauth(ctx, sess, func(s *auth.ClientSession) error { 266 + apiClient := s.APIClient() 265 267 266 - // Upload blob using APIRequest for file upload 267 - uploadReq := atclient.NewAPIRequest("POST", "com.atproto.repo.uploadBlob", file) 268 - uploadReq.Headers.Set("Content-Type", contentType) 269 - // Enable retryability 270 - uploadReq.GetBody = func() (io.ReadCloser, error) { 268 + // Open fresh file handle for this attempt 271 269 f, err := os.Open(imagePath) 272 270 if err != nil { 273 - return nil, err 271 + return fmt.Errorf("failed to open file: %w", err) 274 272 } 275 - return f, nil 276 - } 273 + defer f.Close() 277 274 278 - resp, err := apiClient.Do(ctx, uploadReq) 279 - if err != nil { 280 - return "", fmt.Errorf("failed to upload blob: %w", err) 281 - } 282 - defer resp.Body.Close() 275 + uploadReq := atclient.NewAPIRequest("POST", "com.atproto.repo.uploadBlob", f) 276 + uploadReq.Headers.Set("Content-Type", contentType) 277 + uploadReq.GetBody = func() (io.ReadCloser, error) { 278 + return os.Open(imagePath) 279 + } 283 280 284 - if resp.StatusCode != http.StatusOK { 285 - body, _ := io.ReadAll(resp.Body) 286 - return "", fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(body)) 287 - } 281 + resp, err := apiClient.Do(ctx, uploadReq) 282 + if err != nil { 283 + return fmt.Errorf("failed to upload blob: %w", err) 284 + } 285 + defer resp.Body.Close() 286 + 287 + if resp.StatusCode != http.StatusOK { 288 + body, _ := io.ReadAll(resp.Body) 289 + return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(body)) 290 + } 288 291 289 - var out atproto.RepoUploadBlob_Output 290 - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { 291 - return "", fmt.Errorf("failed to decode upload response: %w", err) 292 + return json.NewDecoder(resp.Body).Decode(&out) 293 + }) 294 + if err != nil { 295 + if !ui.IsInteractive() { 296 + ui.NotifyError(err.Error()) 297 + } 298 + return "", err 292 299 } 293 300 294 301 // Resolve handle from DID for CDN URL ··· 297 304 return "", fmt.Errorf("failed to resolve handle: %w", err) 298 305 } 299 306 300 - // Create record 301 - record := map[string]interface{}{ 302 - "$type": fmt.Sprintf("blue.imgs.%s.image", Name), 303 - "blob": out.Blob, 304 - "createdAt": time.Now().Format(time.RFC3339), 305 - "expiresAt": time.Now().Add(24 * time.Hour).Format(time.RFC3339), 306 - "filename": imagename, 307 - "contentType": contentType, 308 - "size": fileSize, 309 - "metadata": map[string]interface{}{ 310 - "uploadedFrom": Name, 311 - "version": "1.0", 312 - }, 313 - } 307 + // Create record with automatic re-authentication on token refresh failure 308 + var recordOut atproto.RepoCreateRecord_Output 309 + sess, err = auth.ExecuteWithReauth(ctx, sess, func(s *auth.ClientSession) error { 310 + record := map[string]interface{}{ 311 + "$type": fmt.Sprintf("blue.imgs.%s.image", Name), 312 + "blob": out.Blob, 313 + "createdAt": time.Now().Format(time.RFC3339), 314 + "expiresAt": time.Now().Add(24 * time.Hour).Format(time.RFC3339), 315 + "filename": imagename, 316 + "contentType": contentType, 317 + "size": fileSize, 318 + "metadata": map[string]interface{}{ 319 + "uploadedFrom": Name, 320 + "version": "1.0", 321 + }, 322 + } 314 323 315 - reqBody := map[string]interface{}{ 316 - "repo": sess.Data.AccountDID.String(), 317 - "collection": record["$type"], 318 - "record": record, 319 - } 324 + reqBody := map[string]interface{}{ 325 + "repo": s.Data.AccountDID.String(), 326 + "collection": record["$type"], 327 + "record": record, 328 + } 320 329 321 - var recordOut atproto.RepoCreateRecord_Output 322 - if err := apiClient.Post(ctx, "com.atproto.repo.createRecord", reqBody, &recordOut); err != nil { 330 + return s.APIClient().Post(ctx, "com.atproto.repo.createRecord", reqBody, &recordOut) 331 + }) 332 + if err != nil { 333 + if !ui.IsInteractive() { 334 + ui.NotifyError(err.Error()) 335 + } 323 336 return "", fmt.Errorf("failed to create record: %w", err) 324 337 } 325 338
+13
go.mod
··· 4 4 5 5 require ( 6 6 github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e 7 + github.com/gen2brain/beeep v0.11.2 7 8 github.com/godbus/dbus/v5 v5.1.0 8 9 github.com/ipfs/go-cid v0.5.0 9 10 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c 10 11 github.com/spf13/cobra v1.9.1 11 12 github.com/zalando/go-keyring v0.2.6 13 + golang.design/x/clipboard v0.7.1 12 14 golang.org/x/term v0.38.0 13 15 ) 14 16 15 17 require ( 16 18 al.essio.dev/pkg/shellescape v1.6.0 // indirect 19 + git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect 17 20 github.com/beorn7/perks v1.0.1 // indirect 18 21 github.com/cespare/xxhash/v2 v2.3.0 // indirect 19 22 github.com/danieljoos/wincred v1.2.2 // indirect 20 23 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 21 24 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 25 + github.com/esiqveland/notify v0.13.3 // indirect 26 + github.com/go-ole/go-ole v1.3.0 // indirect 22 27 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 23 28 github.com/google/go-querystring v1.1.0 // indirect 24 29 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 25 30 github.com/inconshreveable/mousetrap v1.1.0 // indirect 31 + github.com/jackmordaunt/icns/v3 v3.0.1 // indirect 26 32 github.com/klauspost/cpuid/v2 v2.2.10 // indirect 27 33 github.com/minio/sha256-simd v1.0.1 // indirect 28 34 github.com/mr-tron/base58 v1.2.0 // indirect ··· 32 38 github.com/multiformats/go-multihash v0.2.3 // indirect 33 39 github.com/multiformats/go-varint v0.0.7 // indirect 34 40 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 41 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect 35 42 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 36 43 github.com/prometheus/client_golang v1.22.0 // indirect 37 44 github.com/prometheus/client_model v0.6.2 // indirect 38 45 github.com/prometheus/common v0.63.0 // indirect 39 46 github.com/prometheus/procfs v0.16.1 // indirect 47 + github.com/sergeymakinen/go-bmp v1.0.0 // indirect 48 + github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect 40 49 github.com/spaolacci/murmur3 v1.1.0 // indirect 41 50 github.com/spf13/pflag v1.0.6 // indirect 51 + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect 42 52 github.com/whyrusleeping/cbor-gen v0.3.1 // indirect 43 53 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 44 54 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 45 55 golang.org/x/crypto v0.39.0 // indirect 56 + golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect 57 + golang.org/x/image v0.28.0 // indirect 58 + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect 46 59 golang.org/x/sys v0.39.0 // indirect 47 60 golang.org/x/time v0.8.0 // indirect 48 61 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
+36
go.sum
··· 1 1 al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= 2 2 al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= 3 + git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= 4 + git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= 3 5 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 6 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 7 github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e h1:dEM6bfzMfkRI39GLinuhQan47HzdrkqIzJkl/zRvz8s= ··· 9 11 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 10 12 github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= 11 13 github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= 14 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 16 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 13 17 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 18 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 15 19 github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 20 + github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= 21 + github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= 22 + github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA= 23 + github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc= 24 + github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 25 + github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 16 26 github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 17 27 github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 18 28 github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= ··· 30 40 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 31 41 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 32 42 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 43 + github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= 44 + github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= 33 45 github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 34 46 github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 35 47 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= ··· 48 60 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 49 61 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 50 62 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 63 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 64 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 51 65 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= 52 66 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 67 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 53 68 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 54 69 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 70 github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= ··· 61 76 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 62 77 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 63 78 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 79 + github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= 80 + github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= 81 + github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= 82 + github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= 64 83 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 65 84 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 66 85 github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 67 86 github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 68 87 github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 69 88 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 89 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 90 + github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 91 + github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 70 92 github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 71 93 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 94 + github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 95 + github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 96 + github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 72 97 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 73 98 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 99 + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= 100 + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= 74 101 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 75 102 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 76 103 github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= ··· 79 106 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 80 107 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 81 108 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 109 + golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= 110 + golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= 82 111 golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 83 112 golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 113 + golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= 114 + golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= 115 + golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= 116 + golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= 117 + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8= 118 + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= 84 119 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 120 golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 86 121 golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= ··· 94 129 google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 95 130 google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 96 131 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 132 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 97 133 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 98 134 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 99 135 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
+59 -2
internal/auth/oauth.go
··· 22 22 // is saved. Callers should prompt the user for their handle and retry. 23 23 var ErrNoLoginIdentifier = errors.New("no active session and no login identifier provided") 24 24 25 + // ClientSession is a type alias for oauth.ClientSession to simplify external usage. 26 + type ClientSession = oauth.ClientSession 27 + 25 28 // SSEAuthData holds the parsed auth completion data from SSE events. 26 29 type SSEAuthData struct { 27 30 Code string `json:"code"` ··· 108 111 savedState string 109 112 110 113 // Injectable dependencies (nil means use defaults) 111 - httpClient HTTPDoer 112 - openBrowser BrowserOpener 114 + httpClient HTTPDoer 115 + openBrowser BrowserOpener 113 116 } 114 117 115 118 // OAuthFlowOption configures an OAuthFlow. ··· 392 395 store.ClearLoginIdentifier() 393 396 return store.ClearCurrentSession() 394 397 } 398 + 399 + // IsTokenRefreshError checks if an error indicates OAuth token refresh failure. 400 + // This includes HTTP 400 invalid_grant errors and general token refresh failures 401 + // from the Indigo OAuth library. 402 + func IsTokenRefreshError(err error) bool { 403 + if err == nil { 404 + return false 405 + } 406 + errStr := err.Error() 407 + return strings.Contains(errStr, "failed to refresh OAuth tokens") || 408 + strings.Contains(errStr, "token refresh failed") || 409 + strings.Contains(errStr, "invalid_grant") 410 + } 411 + 412 + // ReauthenticateWithSavedIdentifier attempts silent re-authentication using the saved login identifier. 413 + // Returns the new session on success, or an error if no saved identifier exists or auth fails. 414 + func ReauthenticateWithSavedIdentifier(ctx context.Context) (*oauth.ClientSession, error) { 415 + _, store := NewClientApp() 416 + loginIdentifier, err := store.GetLoginIdentifier() 417 + if err != nil { 418 + return nil, ErrNoLoginIdentifier 419 + } 420 + 421 + // Clear old session before re-authenticating 422 + store.ClearCurrentSession() 423 + 424 + return AuthenticateAndResume(ctx, loginIdentifier) 425 + } 426 + 427 + // ExecuteWithReauth runs an operation, automatically re-authenticating on token refresh failure. 428 + // Returns the (possibly new) session and any error. If re-authentication succeeds, the operation 429 + // is retried with the new session. 430 + func ExecuteWithReauth(ctx context.Context, sess *oauth.ClientSession, op func(*oauth.ClientSession) error) (*oauth.ClientSession, error) { 431 + // First attempt 432 + err := op(sess) 433 + if err == nil { 434 + return sess, nil 435 + } 436 + 437 + // Check for token refresh failure 438 + if !IsTokenRefreshError(err) { 439 + return sess, err 440 + } 441 + 442 + slog.Debug("Token refresh failed, attempting re-authentication", "error", err) 443 + 444 + // Re-authenticate and retry 445 + newSess, authErr := ReauthenticateWithSavedIdentifier(ctx) 446 + if authErr != nil { 447 + return sess, fmt.Errorf("re-authentication failed: %w (original: %v)", authErr, err) 448 + } 449 + 450 + return newSess, op(newSess) 451 + }
+99
internal/auth/oauth_test.go
··· 1 1 package auth 2 2 3 3 import ( 4 + "context" 5 + "fmt" 4 6 "net/http" 5 7 "testing" 6 8 ) ··· 244 246 // We can't easily test this without mocking, but we can verify it doesn't panic 245 247 _ = err 246 248 } 249 + 250 + func TestIsTokenRefreshError(t *testing.T) { 251 + tests := []struct { 252 + name string 253 + err error 254 + expected bool 255 + }{ 256 + { 257 + name: "nil error", 258 + err: nil, 259 + expected: false, 260 + }, 261 + { 262 + name: "regular error", 263 + err: fmt.Errorf("some random error"), 264 + expected: false, 265 + }, 266 + { 267 + name: "network error", 268 + err: fmt.Errorf("connection refused"), 269 + expected: false, 270 + }, 271 + { 272 + name: "indigo token refresh error", 273 + err: fmt.Errorf("failed to refresh OAuth tokens: token refresh failed (HTTP 400): invalid_grant"), 274 + expected: true, 275 + }, 276 + { 277 + name: "wrapped token refresh error", 278 + err: fmt.Errorf("upload failed: %w", fmt.Errorf("failed to refresh OAuth tokens: something")), 279 + expected: true, 280 + }, 281 + { 282 + name: "invalid_grant only", 283 + err: fmt.Errorf("invalid_grant"), 284 + expected: true, 285 + }, 286 + { 287 + name: "token refresh failed", 288 + err: fmt.Errorf("token refresh failed"), 289 + expected: true, 290 + }, 291 + { 292 + name: "partial match - failed to refresh", 293 + err: fmt.Errorf("failed to refresh OAuth tokens"), 294 + expected: true, 295 + }, 296 + } 297 + 298 + for _, tt := range tests { 299 + t.Run(tt.name, func(t *testing.T) { 300 + result := IsTokenRefreshError(tt.err) 301 + if result != tt.expected { 302 + t.Errorf("IsTokenRefreshError(%v) = %v, want %v", tt.err, result, tt.expected) 303 + } 304 + }) 305 + } 306 + } 307 + 308 + func TestExecuteWithReauth_NoError(t *testing.T) { 309 + // Test that when the operation succeeds, no re-auth is attempted 310 + operationCalls := 0 311 + 312 + // We can't easily mock oauth.ClientSession, so we test with nil 313 + // and an operation that doesn't use the session 314 + _, err := ExecuteWithReauth(context.TODO(), nil, func(s *ClientSession) error { 315 + operationCalls++ 316 + return nil 317 + }) 318 + 319 + if err != nil { 320 + t.Errorf("ExecuteWithReauth() unexpected error: %v", err) 321 + } 322 + 323 + if operationCalls != 1 { 324 + t.Errorf("operation called %d times, want 1", operationCalls) 325 + } 326 + } 327 + 328 + func TestExecuteWithReauth_NonTokenError(t *testing.T) { 329 + // Test that non-token errors are returned without retry 330 + operationCalls := 0 331 + expectedErr := fmt.Errorf("some other error") 332 + 333 + _, err := ExecuteWithReauth(context.TODO(), nil, func(s *ClientSession) error { 334 + operationCalls++ 335 + return expectedErr 336 + }) 337 + 338 + if err != expectedErr { 339 + t.Errorf("ExecuteWithReauth() error = %v, want %v", err, expectedErr) 340 + } 341 + 342 + if operationCalls != 1 { 343 + t.Errorf("operation called %d times, want 1 (no retry for non-token errors)", operationCalls) 344 + } 345 + }
+22 -2
internal/auth/storage.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 + "log/slog" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/auth/oauth" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" ··· 56 57 57 58 // SaveSession stores a session in the keyring 58 59 func (s *KeyringAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 60 + slog.Debug("SaveSession called", "did", sess.AccountDID, "sessionID", sess.SessionID) 59 61 data, err := json.Marshal(sess) 60 62 if err != nil { 63 + slog.Error("SaveSession marshal failed", "err", err) 61 64 return err 62 65 } 63 66 64 - return s.keyring.Set(keyringService, sessionKey(sess.AccountDID, sess.SessionID), string(data)) 67 + key := sessionKey(sess.AccountDID, sess.SessionID) 68 + slog.Debug("SaveSession saving", "key", key, "dataLen", len(data)) 69 + err = s.keyring.Set(keyringService, key, string(data)) 70 + if err != nil { 71 + slog.Error("SaveSession keyring.Set failed", "err", err) 72 + } else { 73 + slog.Debug("SaveSession success") 74 + } 75 + return err 65 76 } 66 77 67 78 // DeleteSession removes a session from the keyring ··· 148 159 149 160 // SetCurrentSession sets the current active session reference 150 161 func (s *KeyringAuthStore) SetCurrentSession(ctx context.Context, sess *oauth.ClientSessionData) error { 162 + slog.Debug("SetCurrentSession called", "did", sess.AccountDID, "sessionID", sess.SessionID) 151 163 ref := CurrentSessionRef{ 152 164 DID: sess.AccountDID.String(), 153 165 SessionID: sess.SessionID, ··· 155 167 156 168 data, err := json.Marshal(ref) 157 169 if err != nil { 170 + slog.Error("SetCurrentSession marshal failed", "err", err) 158 171 return err 159 172 } 160 173 161 - return s.keyring.Set(keyringService, currentSessionKey, string(data)) 174 + slog.Debug("SetCurrentSession saving", "data", string(data)) 175 + err = s.keyring.Set(keyringService, currentSessionKey, string(data)) 176 + if err != nil { 177 + slog.Error("SetCurrentSession keyring.Set failed", "err", err) 178 + } else { 179 + slog.Debug("SetCurrentSession success") 180 + } 181 + return err 162 182 } 163 183 164 184 // ClearCurrentSession removes the current session reference
+21
internal/clipboard/clipboard_darwin.go
··· 1 + //go:build darwin 2 + 3 + package clipboard 4 + 5 + import ( 6 + "golang.design/x/clipboard" 7 + ) 8 + 9 + var clipboardInitialized bool 10 + 11 + func copyTextPlatform(text string) error { 12 + if !clipboardInitialized { 13 + if err := clipboard.Init(); err != nil { 14 + return err 15 + } 16 + clipboardInitialized = true 17 + } 18 + 19 + clipboard.Write(clipboard.FmtText, []byte(text)) 20 + return nil 21 + }
+21
internal/clipboard/clipboard_windows.go
··· 1 + //go:build windows 2 + 3 + package clipboard 4 + 5 + import ( 6 + "golang.design/x/clipboard" 7 + ) 8 + 9 + var clipboardInitialized bool 10 + 11 + func copyTextPlatform(text string) error { 12 + if !clipboardInitialized { 13 + if err := clipboard.Init(); err != nil { 14 + return err 15 + } 16 + clipboardInitialized = true 17 + } 18 + 19 + clipboard.Write(clipboard.FmtText, []byte(text)) 20 + return nil 21 + }
+8 -28
internal/ui/notification.go
··· 1 1 package ui 2 2 3 3 import ( 4 - "github.com/godbus/dbus/v5" 4 + "github.com/gen2brain/beeep" 5 5 ) 6 6 7 - const ( 8 - notifyService = "org.freedesktop.Notifications" 9 - notifyPath = "/org/freedesktop/Notifications" 10 - notifyInterface = "org.freedesktop.Notifications" 11 - ) 12 - 13 - // Notify sends a desktop notification via DBus 7 + // Notify sends a cross-platform desktop notification. 8 + // On Linux: uses D-Bus with notify-send fallback 9 + // On macOS: uses osascript or terminal-notifier 10 + // On Windows: uses Windows Runtime COM API or PowerShell 14 11 func Notify(title, message, icon string) error { 15 - conn, err := dbus.ConnectSessionBus() 16 - if err != nil { 17 - return err 18 - } 19 - defer conn.Close() 20 - 21 - obj := conn.Object(notifyService, notifyPath) 22 - call := obj.Call(notifyInterface+".Notify", 0, 23 - "blup", // app_name 24 - uint32(0), // replaces_id 25 - icon, // app_icon 26 - title, // summary 27 - message, // body 28 - []string{}, // actions 29 - map[string]dbus.Variant{}, // hints 30 - int32(5000), // expire_timeout (ms) 31 - ) 32 - return call.Err 12 + return beeep.Notify(title, message, icon) 33 13 } 34 14 35 15 // NotifyLoginRequired shows a notification telling user to run blup login ··· 37 17 return Notify( 38 18 "blup: Login Required", 39 19 "Run 'blup login' in a terminal to authenticate.", 40 - "dialog-password", 20 + "", 41 21 ) 42 22 } 43 23 44 24 // NotifyError shows an error notification 45 25 func NotifyError(message string) error { 46 - return Notify("blup: Error", message, "dialog-error") 26 + return Notify("blup: Error", message, "") 47 27 }