+11
-2
Makefile
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
}