+5
-1
src-tauri/capabilities/default.json
+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
+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
+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
>