diff --git a/Makefile b/Makefile --- a/Makefile +++ b/Makefile @@ -1,11 +1,20 @@ -.PHONY: build install build-windows +.PHONY: build install build-linux build-darwin build-windows +# Default build (native platform) build: go build -o ./blup ./cmd/blup install: go install ./cmd/blup +# Linux build (Wayland support, requires libwayland-dev) +build-linux: + CGO_ENABLED=1 GOOS=linux go build -o ./blup ./cmd/blup + +# macOS build (requires Xcode Command Line Tools for CGO) +build-darwin: + CGO_ENABLED=1 GOOS=darwin go build -o ./blup ./cmd/blup + +# Windows build (no CGO required for clipboard) build-windows: GOOS=windows go build -o ./blup.exe ./cmd/blup - osslsigncode sign -pkcs12 ../mycert.pfx -askpass -n "Blup" -i "https://blup.imgs.blue" -in ./blup.exe -out ./blup-signed.exe diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -4,25 +4,31 @@ require ( github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e + github.com/gen2brain/beeep v0.11.2 github.com/godbus/dbus/v5 v5.1.0 github.com/ipfs/go-cid v0.5.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.9.1 github.com/zalando/go-keyring v0.2.6 + golang.design/x/clipboard v0.7.1 golang.org/x/term v0.38.0 ) require ( al.essio.dev/pkg/shellescape v1.6.0 // indirect + git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect + github.com/esiqveland/notify v0.13.3 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mr-tron/base58 v1.2.0 // indirect @@ -32,17 +38,24 @@ github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.0.7 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.63.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/sergeymakinen/go-bmp v1.0.0 // indirect + github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/whyrusleeping/cbor-gen v0.3.1 // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect golang.org/x/crypto v0.39.0 // indirect + golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect + golang.org/x/image v0.28.0 // indirect + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/time v0.8.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= +git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e h1:dEM6bfzMfkRI39GLinuhQan47HzdrkqIzJkl/zRvz8s= @@ -9,10 +11,18 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= +github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= +github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= +github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA= +github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= @@ -30,6 +40,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= +github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= +github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= @@ -48,8 +60,11 @@ github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= @@ -61,16 +76,28 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= +github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= +github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= +github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= @@ -79,8 +106,16 @@ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= +golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= +golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= +golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -94,6 +129,7 @@ google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= diff --git a/cmd/blup/main.go b/cmd/blup/main.go --- a/cmd/blup/main.go +++ b/cmd/blup/main.go @@ -260,35 +260,42 @@ return "", fmt.Errorf("authentication failed: %w", err) } - // Get API client from session - apiClient := sess.APIClient() + // Upload blob with automatic re-authentication on token refresh failure + var out atproto.RepoUploadBlob_Output + sess, err = auth.ExecuteWithReauth(ctx, sess, func(s *auth.ClientSession) error { + apiClient := s.APIClient() - // Upload blob using APIRequest for file upload - uploadReq := atclient.NewAPIRequest("POST", "com.atproto.repo.uploadBlob", file) - uploadReq.Headers.Set("Content-Type", contentType) - // Enable retryability - uploadReq.GetBody = func() (io.ReadCloser, error) { + // Open fresh file handle for this attempt f, err := os.Open(imagePath) if err != nil { - return nil, err + return fmt.Errorf("failed to open file: %w", err) } - return f, nil - } + defer f.Close() - resp, err := apiClient.Do(ctx, uploadReq) + uploadReq := atclient.NewAPIRequest("POST", "com.atproto.repo.uploadBlob", f) + uploadReq.Headers.Set("Content-Type", contentType) + uploadReq.GetBody = func() (io.ReadCloser, error) { + return os.Open(imagePath) + } + + resp, err := apiClient.Do(ctx, uploadReq) + if err != nil { + return fmt.Errorf("failed to upload blob: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(body)) + } + + return json.NewDecoder(resp.Body).Decode(&out) + }) if err != nil { - return "", fmt.Errorf("failed to upload blob: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(body)) - } - - var out atproto.RepoUploadBlob_Output - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", fmt.Errorf("failed to decode upload response: %w", err) + if !ui.IsInteractive() { + ui.NotifyError(err.Error()) + } + return "", err } // Resolve handle from DID for CDN URL @@ -297,29 +304,35 @@ return "", fmt.Errorf("failed to resolve handle: %w", err) } - // Create record - record := map[string]interface{}{ - "$type": fmt.Sprintf("blue.imgs.%s.image", Name), - "blob": out.Blob, - "createdAt": time.Now().Format(time.RFC3339), - "expiresAt": time.Now().Add(24 * time.Hour).Format(time.RFC3339), - "filename": imagename, - "contentType": contentType, - "size": fileSize, - "metadata": map[string]interface{}{ - "uploadedFrom": Name, - "version": "1.0", - }, - } - - reqBody := map[string]interface{}{ - "repo": sess.Data.AccountDID.String(), - "collection": record["$type"], - "record": record, - } - + // Create record with automatic re-authentication on token refresh failure var recordOut atproto.RepoCreateRecord_Output - if err := apiClient.Post(ctx, "com.atproto.repo.createRecord", reqBody, &recordOut); err != nil { + sess, err = auth.ExecuteWithReauth(ctx, sess, func(s *auth.ClientSession) error { + record := map[string]interface{}{ + "$type": fmt.Sprintf("blue.imgs.%s.image", Name), + "blob": out.Blob, + "createdAt": time.Now().Format(time.RFC3339), + "expiresAt": time.Now().Add(24 * time.Hour).Format(time.RFC3339), + "filename": imagename, + "contentType": contentType, + "size": fileSize, + "metadata": map[string]interface{}{ + "uploadedFrom": Name, + "version": "1.0", + }, + } + + reqBody := map[string]interface{}{ + "repo": s.Data.AccountDID.String(), + "collection": record["$type"], + "record": record, + } + + return s.APIClient().Post(ctx, "com.atproto.repo.createRecord", reqBody, &recordOut) + }) + if err != nil { + if !ui.IsInteractive() { + ui.NotifyError(err.Error()) + } return "", fmt.Errorf("failed to create record: %w", err) } diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -22,6 +22,9 @@ // is saved. Callers should prompt the user for their handle and retry. var ErrNoLoginIdentifier = errors.New("no active session and no login identifier provided") +// ClientSession is a type alias for oauth.ClientSession to simplify external usage. +type ClientSession = oauth.ClientSession + // SSEAuthData holds the parsed auth completion data from SSE events. type SSEAuthData struct { Code string `json:"code"` @@ -108,8 +111,8 @@ savedState string // Injectable dependencies (nil means use defaults) - httpClient HTTPDoer - openBrowser BrowserOpener + httpClient HTTPDoer + openBrowser BrowserOpener } // OAuthFlowOption configures an OAuthFlow. @@ -391,4 +394,58 @@ // Clear current session reference and login identifier store.ClearLoginIdentifier() return store.ClearCurrentSession() +} + +// IsTokenRefreshError checks if an error indicates OAuth token refresh failure. +// This includes HTTP 400 invalid_grant errors and general token refresh failures +// from the Indigo OAuth library. +func IsTokenRefreshError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "failed to refresh OAuth tokens") || + strings.Contains(errStr, "token refresh failed") || + strings.Contains(errStr, "invalid_grant") +} + +// ReauthenticateWithSavedIdentifier attempts silent re-authentication using the saved login identifier. +// Returns the new session on success, or an error if no saved identifier exists or auth fails. +func ReauthenticateWithSavedIdentifier(ctx context.Context) (*oauth.ClientSession, error) { + _, store := NewClientApp() + loginIdentifier, err := store.GetLoginIdentifier() + if err != nil { + return nil, ErrNoLoginIdentifier + } + + // Clear old session before re-authenticating + store.ClearCurrentSession() + + return AuthenticateAndResume(ctx, loginIdentifier) +} + +// ExecuteWithReauth runs an operation, automatically re-authenticating on token refresh failure. +// Returns the (possibly new) session and any error. If re-authentication succeeds, the operation +// is retried with the new session. +func ExecuteWithReauth(ctx context.Context, sess *oauth.ClientSession, op func(*oauth.ClientSession) error) (*oauth.ClientSession, error) { + // First attempt + err := op(sess) + if err == nil { + return sess, nil + } + + // Check for token refresh failure + if !IsTokenRefreshError(err) { + return sess, err + } + + slog.Debug("Token refresh failed, attempting re-authentication", "error", err) + + // Re-authenticate and retry + newSess, authErr := ReauthenticateWithSavedIdentifier(ctx) + if authErr != nil { + return sess, fmt.Errorf("re-authentication failed: %w (original: %v)", authErr, err) + } + + return newSess, op(newSess) } diff --git a/internal/auth/oauth_test.go b/internal/auth/oauth_test.go --- a/internal/auth/oauth_test.go +++ b/internal/auth/oauth_test.go @@ -1,6 +1,8 @@ package auth import ( + "context" + "fmt" "net/http" "testing" ) @@ -243,4 +245,101 @@ err := Logout() // We can't easily test this without mocking, but we can verify it doesn't panic _ = err +} + +func TestIsTokenRefreshError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "regular error", + err: fmt.Errorf("some random error"), + expected: false, + }, + { + name: "network error", + err: fmt.Errorf("connection refused"), + expected: false, + }, + { + name: "indigo token refresh error", + err: fmt.Errorf("failed to refresh OAuth tokens: token refresh failed (HTTP 400): invalid_grant"), + expected: true, + }, + { + name: "wrapped token refresh error", + err: fmt.Errorf("upload failed: %w", fmt.Errorf("failed to refresh OAuth tokens: something")), + expected: true, + }, + { + name: "invalid_grant only", + err: fmt.Errorf("invalid_grant"), + expected: true, + }, + { + name: "token refresh failed", + err: fmt.Errorf("token refresh failed"), + expected: true, + }, + { + name: "partial match - failed to refresh", + err: fmt.Errorf("failed to refresh OAuth tokens"), + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsTokenRefreshError(tt.err) + if result != tt.expected { + t.Errorf("IsTokenRefreshError(%v) = %v, want %v", tt.err, result, tt.expected) + } + }) + } +} + +func TestExecuteWithReauth_NoError(t *testing.T) { + // Test that when the operation succeeds, no re-auth is attempted + operationCalls := 0 + + // We can't easily mock oauth.ClientSession, so we test with nil + // and an operation that doesn't use the session + _, err := ExecuteWithReauth(context.TODO(), nil, func(s *ClientSession) error { + operationCalls++ + return nil + }) + + if err != nil { + t.Errorf("ExecuteWithReauth() unexpected error: %v", err) + } + + if operationCalls != 1 { + t.Errorf("operation called %d times, want 1", operationCalls) + } +} + +func TestExecuteWithReauth_NonTokenError(t *testing.T) { + // Test that non-token errors are returned without retry + operationCalls := 0 + expectedErr := fmt.Errorf("some other error") + + _, err := ExecuteWithReauth(context.TODO(), nil, func(s *ClientSession) error { + operationCalls++ + return expectedErr + }) + + if err != expectedErr { + t.Errorf("ExecuteWithReauth() error = %v, want %v", err, expectedErr) + } + + if operationCalls != 1 { + t.Errorf("operation called %d times, want 1 (no retry for non-token errors)", operationCalls) + } } diff --git a/internal/auth/storage.go b/internal/auth/storage.go --- a/internal/auth/storage.go +++ b/internal/auth/storage.go @@ -4,6 +4,7 @@ "context" "encoding/json" "fmt" + "log/slog" "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" @@ -56,12 +57,22 @@ // SaveSession stores a session in the keyring func (s *KeyringAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { + slog.Debug("SaveSession called", "did", sess.AccountDID, "sessionID", sess.SessionID) data, err := json.Marshal(sess) if err != nil { + slog.Error("SaveSession marshal failed", "err", err) return err } - return s.keyring.Set(keyringService, sessionKey(sess.AccountDID, sess.SessionID), string(data)) + key := sessionKey(sess.AccountDID, sess.SessionID) + slog.Debug("SaveSession saving", "key", key, "dataLen", len(data)) + err = s.keyring.Set(keyringService, key, string(data)) + if err != nil { + slog.Error("SaveSession keyring.Set failed", "err", err) + } else { + slog.Debug("SaveSession success") + } + return err } // DeleteSession removes a session from the keyring @@ -148,6 +159,7 @@ // SetCurrentSession sets the current active session reference func (s *KeyringAuthStore) SetCurrentSession(ctx context.Context, sess *oauth.ClientSessionData) error { + slog.Debug("SetCurrentSession called", "did", sess.AccountDID, "sessionID", sess.SessionID) ref := CurrentSessionRef{ DID: sess.AccountDID.String(), SessionID: sess.SessionID, @@ -155,10 +167,18 @@ data, err := json.Marshal(ref) if err != nil { + slog.Error("SetCurrentSession marshal failed", "err", err) return err } - return s.keyring.Set(keyringService, currentSessionKey, string(data)) + slog.Debug("SetCurrentSession saving", "data", string(data)) + err = s.keyring.Set(keyringService, currentSessionKey, string(data)) + if err != nil { + slog.Error("SetCurrentSession keyring.Set failed", "err", err) + } else { + slog.Debug("SetCurrentSession success") + } + return err } // ClearCurrentSession removes the current session reference diff --git a/internal/clipboard/clipboard_darwin.go b/internal/clipboard/clipboard_darwin.go new file mode 100644 --- /dev/null +++ b/internal/clipboard/clipboard_darwin.go @@ -0,0 +1,21 @@ +//go:build darwin + +package clipboard + +import ( + "golang.design/x/clipboard" +) + +var clipboardInitialized bool + +func copyTextPlatform(text string) error { + if !clipboardInitialized { + if err := clipboard.Init(); err != nil { + return err + } + clipboardInitialized = true + } + + clipboard.Write(clipboard.FmtText, []byte(text)) + return nil +} diff --git a/internal/clipboard/clipboard_windows.go b/internal/clipboard/clipboard_windows.go new file mode 100644 --- /dev/null +++ b/internal/clipboard/clipboard_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package clipboard + +import ( + "golang.design/x/clipboard" +) + +var clipboardInitialized bool + +func copyTextPlatform(text string) error { + if !clipboardInitialized { + if err := clipboard.Init(); err != nil { + return err + } + clipboardInitialized = true + } + + clipboard.Write(clipboard.FmtText, []byte(text)) + return nil +} diff --git a/internal/ui/notification.go b/internal/ui/notification.go --- a/internal/ui/notification.go +++ b/internal/ui/notification.go @@ -1,35 +1,15 @@ package ui import ( - "github.com/godbus/dbus/v5" + "github.com/gen2brain/beeep" ) -const ( - notifyService = "org.freedesktop.Notifications" - notifyPath = "/org/freedesktop/Notifications" - notifyInterface = "org.freedesktop.Notifications" -) - -// Notify sends a desktop notification via DBus +// Notify sends a cross-platform desktop notification. +// On Linux: uses D-Bus with notify-send fallback +// On macOS: uses osascript or terminal-notifier +// On Windows: uses Windows Runtime COM API or PowerShell func Notify(title, message, icon string) error { - conn, err := dbus.ConnectSessionBus() - if err != nil { - return err - } - defer conn.Close() - - obj := conn.Object(notifyService, notifyPath) - call := obj.Call(notifyInterface+".Notify", 0, - "blup", // app_name - uint32(0), // replaces_id - icon, // app_icon - title, // summary - message, // body - []string{}, // actions - map[string]dbus.Variant{}, // hints - int32(5000), // expire_timeout (ms) - ) - return call.Err + return beeep.Notify(title, message, icon) } // NotifyLoginRequired shows a notification telling user to run blup login @@ -37,11 +17,11 @@ return Notify( "blup: Login Required", "Run 'blup login' in a terminal to authenticate.", - "dialog-password", + "", ) } // NotifyError shows an error notification func NotifyError(message string) error { - return Notify("blup: Error", message, "dialog-error") + return Notify("blup: Error", message, "") }