One-click backups for AT Protocol

make app prettier

Turtlepaw f98ca8c8 5d224480

Changed files
+190 -75
src
src-tauri
capabilities
+5 -1
src-tauri/capabilities/default.json
··· 11 11 "core:window:default", 12 12 "core:window:allow-start-dragging", 13 13 "core:event:default", 14 - "deep-link:default" 14 + "deep-link:default", 15 + "core:window:allow-close", 16 + "core:window:allow-minimize", 17 + "core:window:allow-toggle-maximize", 18 + "core:window:allow-internal-toggle-maximize" 15 19 ] 16 20 }
+131 -14
src/App.tsx
··· 1 - import { useState } from "react"; 1 + import { useState, useEffect } from "react"; 2 2 import reactLogo from "./assets/react.svg"; 3 3 import { invoke } from "@tauri-apps/api/core"; 4 4 import "./App.css"; 5 5 import { Button } from "./components/ui/button"; 6 6 import LoginPage from "./routes/Login"; 7 + import { getCurrentWindow } from "@tauri-apps/api/window"; 8 + import { 9 + Agent, 10 + AtpAgent, 11 + type AtpSessionData, 12 + type AtpSessionEvent, 13 + } from "@atproto/api"; 14 + import { 15 + BrowserOAuthClient, 16 + OAuthSession, 17 + } from "@atproto/oauth-client-browser"; 18 + import { LoaderIcon } from "lucide-react"; 19 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 20 + 21 + function LoggedInScreen({ 22 + session, 23 + onLogout, 24 + agent, 25 + }: { 26 + session: OAuthSession; 27 + onLogout: () => void; 28 + agent: Agent; 29 + }) { 30 + const [userData, setUserData] = useState<ProfileViewDetailed | null>(null); 31 + 32 + useEffect(() => { 33 + (async () => { 34 + const sessionData = await agent.getProfile({ actor: agent.assertDid }); 35 + setUserData(sessionData.data); 36 + })(); 37 + }, [agent]); 38 + 39 + return ( 40 + <div className="p-4 mt-10"> 41 + <div className="flex justify-between items-center mb-4"> 42 + <h1 className="text-2xl font-bold">Welcome!</h1> 43 + <Button onClick={onLogout}>Logout</Button> 44 + </div> 45 + <div className="bg-card rounded-lg p-4"> 46 + <p className="mb-2 text-white"> 47 + Logged in as: <span className="font-mono">@{userData?.handle}</span> 48 + </p> 49 + <p className="text-sm text-muted-foreground"></p> 50 + </div> 51 + </div> 52 + ); 53 + } 7 54 8 55 function App() { 9 - const [greetMsg, setGreetMsg] = useState(""); 10 - const [name, setName] = useState(""); 56 + const [session, setSession] = useState<OAuthSession | null>(null); 57 + const appWindow = getCurrentWindow(); 58 + const [client, setClient] = useState<BrowserOAuthClient | null>(null); 59 + const [agent, setAgent] = useState<Agent | null>(null); 60 + 61 + // Load session from localStorage on mount 62 + useEffect(() => { 63 + (async () => { 64 + const client = await BrowserOAuthClient.load({ 65 + clientId: "https://atproto-backup.pages.dev/client_metadata.json", 66 + handleResolver: "https://bsky.social", 67 + }); 68 + 69 + //@ts-expect-error 70 + const result: undefined | { session: OAuthSession; state?: string } = 71 + await client.init(); 72 + 73 + if (result) { 74 + const { session, state } = result; 75 + if (state != null) { 76 + console.log( 77 + `${session.sub} was successfully authenticated (state: ${state})` 78 + ); 79 + } else { 80 + console.log(`${session.sub} was restored (last active session)`); 81 + } 82 + setSession(session); 83 + setAgent(new Agent(session)); 84 + } 85 + setClient(client); 86 + })(); 87 + }, []); 11 88 12 - async function greet() { 13 - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ 14 - setGreetMsg(await invoke("greet", { name })); 15 - } 89 + const handleLogin = (newSession: OAuthSession) => { 90 + setSession(newSession); 91 + setAgent(new Agent(newSession)); 92 + }; 93 + 94 + const handleLogout = () => { 95 + setSession(null); 96 + setAgent(null); 97 + }; 16 98 17 99 return ( 18 - <main className="dark"> 100 + <main className="dark bg-background min-h-screen flex flex-col"> 19 101 <div className="titlebar" data-tauri-drag-region> 20 102 <div className="controls"> 21 - <Button variant="ghost" id="titlebar-minimize" title="minimize"> 103 + <Button 104 + variant="ghost" 105 + id="titlebar-minimize" 106 + title="minimize" 107 + onClick={() => { 108 + appWindow.minimize(); 109 + }} 110 + > 22 111 <svg 23 112 xmlns="http://www.w3.org/2000/svg" 24 113 width="24" ··· 28 117 <path fill="currentColor" d="M19 13H5v-2h14z" /> 29 118 </svg> 30 119 </Button> 31 - <button id="titlebar-maximize" title="maximize"> 120 + <Button 121 + id="titlebar-maximize" 122 + title="maximize" 123 + onClick={() => { 124 + appWindow.toggleMaximize(); 125 + }} 126 + > 32 127 <svg 33 128 xmlns="http://www.w3.org/2000/svg" 34 129 width="24" ··· 37 132 > 38 133 <path fill="currentColor" d="M4 4h16v16H4zm2 4v10h12V8z" /> 39 134 </svg> 40 - </button> 41 - <button id="titlebar-close" title="close"> 135 + </Button> 136 + <Button 137 + id="titlebar-close" 138 + title="close" 139 + onClick={() => { 140 + appWindow.close(); 141 + }} 142 + > 42 143 <svg 43 144 xmlns="http://www.w3.org/2000/svg" 44 145 width="24" ··· 50 151 d="M13.46 12L19 17.54V19h-1.46L12 13.46L6.46 19H5v-1.46L10.54 12L5 6.46V5h1.46L12 10.54L17.54 5H19v1.46z" 51 152 /> 52 153 </svg> 53 - </button> 154 + </Button> 54 155 </div> 55 156 </div> 56 157 57 - <LoginPage /> 158 + {client ? ( 159 + <> 160 + {session && agent ? ( 161 + <LoggedInScreen 162 + session={session} 163 + onLogout={handleLogout} 164 + agent={agent} 165 + /> 166 + ) : ( 167 + <LoginPage onLogin={handleLogin} client={client} /> 168 + )} 169 + </> 170 + ) : ( 171 + <div className="flex-1 flex items-center justify-center"> 172 + <LoaderIcon className="animate-spin" /> 173 + </div> 174 + )} 58 175 </main> 59 176 ); 60 177 }
+54 -60
src/routes/Login.tsx
··· 1 - import { useState, useEffect } from "react"; 1 + import { useState, useEffect, useRef } from "react"; 2 2 import { Input } from "@/components/ui/input"; 3 3 import { Button } from "@/components/ui/button"; 4 4 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 5 - import { AtpSessionEvent, Agent, CredentialSession } from "@atproto/api"; 5 + import { 6 + AtpSessionEvent, 7 + Agent, 8 + CredentialSession, 9 + AtpSessionData, 10 + } from "@atproto/api"; 6 11 import { 7 12 BrowserOAuthClient, 8 13 BrowserOAuthClientOptions, 14 + OAuthSession, 9 15 } from "@atproto/oauth-client-browser"; 10 16 import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; 11 17 12 18 type LoginMethod = "credential" | "oauth"; 13 19 14 - export default function LoginPage() { 20 + interface LoginPageProps { 21 + onLogin: (session: OAuthSession) => void; 22 + client: BrowserOAuthClient; 23 + } 24 + 25 + export default function LoginPage({ 26 + onLogin, 27 + client: oauthClient, 28 + }: LoginPageProps) { 15 29 const [identifier, setIdentifier] = useState(""); 16 30 const [password, setPassword] = useState(""); 17 31 const [loading, setLoading] = useState(false); 18 32 const [error, setError] = useState(""); 19 33 const [loginMethod, setLoginMethod] = useState<LoginMethod>("credential"); 20 - const [oauthClient, setOauthClient] = useState<BrowserOAuthClient | null>( 21 - null 22 - ); 34 + const processingOAuthRef = useRef(false); 23 35 24 36 // Initialize OAuth client 25 37 useEffect(() => { 26 38 const initOAuthClient = async () => { 27 39 try { 28 - const client = await BrowserOAuthClient.load({ 29 - clientId: "https://atproto-backup.pages.dev/client_metadata.json", 40 + // Set up deep link handler 41 + await onOpenUrl(async (urls) => { 42 + console.log("deep link received:", urls); 43 + if (!oauthClient || urls.length === 0) return; 44 + 45 + // Prevent duplicate processing 46 + if (processingOAuthRef.current) { 47 + console.log( 48 + "Already processing OAuth callback, ignoring duplicate" 49 + ); 50 + return; 51 + } 52 + 53 + try { 54 + processingOAuthRef.current = true; 55 + // Get the first URL from the array and parse it 56 + const url = new URL(urls[0]); 57 + 58 + // Process the OAuth callback with the URLSearchParams directly 59 + const session = await oauthClient.callback(url.searchParams); 60 + console.log("OAuth callback successful!", session); 61 + onLogin(session.session); 62 + setLoading(false); 63 + } catch (err) { 64 + console.error("Failed to process OAuth callback:", err); 65 + setError("Failed to complete OAuth login"); 66 + } finally { 67 + processingOAuthRef.current = false; 68 + } 30 69 }); 31 - setOauthClient(client); 32 70 } catch (err) { 33 71 console.error("Failed to initialize OAuth client:", err); 34 72 } 35 73 }; 36 74 initOAuthClient(); 37 - }, []); 75 + }, [onLogin]); 38 76 39 77 const handleLogin = async () => { 40 78 if (!oauthClient) { ··· 54 92 55 93 // Sign in using OAuth with popup 56 94 const session = await oauthClient.signInPopup(identifier, { 57 - scope: "atproto", 95 + scope: "atproto transition:generic", 96 + ui_locales: "en", 97 + signal: new AbortController().signal, 58 98 }); 59 99 60 100 console.log("OAuth login successful!", session); 61 - // Store session, redirect, etc. 101 + onLogin(session); 62 102 } catch (err: any) { 63 103 console.error(err); 64 104 setError(err.message || "OAuth login failed"); ··· 67 107 } 68 108 }; 69 109 70 - const handleOAuthRedirect = async () => { 71 - if (!oauthClient) { 72 - setError("OAuth client not initialized"); 73 - return; 74 - } 75 - 76 - setLoading(true); 77 - setError(""); 78 - 79 - try { 80 - if (!identifier) { 81 - setError("Please enter your handle or identifier"); 82 - return; 83 - } 84 - 85 - // Sign in using OAuth with redirect 86 - await oauthClient.signInRedirect(identifier, { 87 - scope: "atproto", 88 - }); 89 - } catch (err: any) { 90 - console.error(err); 91 - setError(err.message || "OAuth redirect failed"); 92 - } finally { 93 - setLoading(false); 94 - } 95 - }; 96 - 97 - // Handle OAuth callback on page load 98 - useEffect(() => { 99 - const handleCallback = async () => { 100 - if (!oauthClient) return; 101 - 102 - try { 103 - const result = await oauthClient.signInCallback(); 104 - if (result) { 105 - console.log("OAuth callback successful!", result); 106 - // Handle successful login 107 - } 108 - } catch (err) { 109 - console.error("OAuth callback error:", err); 110 - setError("OAuth callback failed"); 111 - } 112 - }; 113 - 114 - handleCallback(); 115 - }, [oauthClient]); 116 110 return ( 117 111 <div className="min-h-screen flex items-center justify-center bg-background px-4"> 118 112 <Card className="w-full max-w-sm"> 119 113 <CardHeader> 120 - <CardTitle>Login to your Bluesky account</CardTitle> 114 + <CardTitle>Login with your handle on the Atmosphere</CardTitle> 121 115 </CardHeader> 122 116 <CardContent className="space-y-4"> 123 117 <Input ··· 127 121 /> 128 122 {error && <p className="text-sm text-red-500">{error}</p>} 129 123 <Button 130 - className="w-full" 124 + className="w-full cursor-pointer" 131 125 onClick={handleLogin} 132 126 disabled={loading || identifier == null} 133 127 >