Skip to content

Commit b34291a

Browse files
committed
Improve desktop sign-in flow and fallback
1 parent c5ef8cf commit b34291a

File tree

2 files changed

+281
-88
lines changed

2 files changed

+281
-88
lines changed

apps/desktop/src/utils/auth.ts

Lines changed: 131 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,27 @@ import { authStore, generalSettingsStore } from "~/store";
1010
import { identifyUser, trackEvent } from "./analytics";
1111
import { commands } from "./tauri";
1212

13+
const paramsValidator = z.union([
14+
z.object({
15+
type: z.literal("api_key"),
16+
api_key: z.string(),
17+
user_id: z.string(),
18+
}),
19+
z.object({
20+
token: z.string(),
21+
user_id: z.string(),
22+
expires: z.coerce.number(),
23+
}),
24+
]);
25+
26+
type AuthParams = z.infer<typeof paramsValidator>;
27+
1328
export function createSignInMutation() {
1429
return createMutation(() => ({
1530
mutationFn: async (abort: AbortController) => {
16-
const platform = import.meta.env.DEV ? "web" : "desktop";
17-
18-
let session;
19-
20-
if (platform === "web")
21-
session = await createLocalServerSession(abort.signal);
22-
else session = await createDeepLinkSession(abort.signal);
31+
const session = import.meta.env.DEV
32+
? await createLocalServerSession(abort.signal)
33+
: await createHybridDesktopSession(abort.signal);
2334

2435
await shell.open(session.url.toString());
2536

@@ -49,6 +60,59 @@ async function createSessionRequestUrl(
4960
}
5061

5162
async function createLocalServerSession(signal: AbortSignal) {
63+
const localCallback = await startLocalCallbackSession(signal);
64+
65+
return {
66+
url: await createSessionRequestUrl(localCallback.port, "web"),
67+
complete: async () => {
68+
const result = await localCallback.complete;
69+
await localCallback.dispose();
70+
71+
if (!result) return null;
72+
if (signal.aborted) throw new Error("Sign in aborted");
73+
74+
return result;
75+
},
76+
};
77+
}
78+
79+
async function createHybridDesktopSession(signal: AbortSignal) {
80+
const deepLink = await startDeepLinkSession(signal);
81+
const localCallback = await startLocalCallbackSession(signal);
82+
83+
return {
84+
url: await createSessionRequestUrl(localCallback.port, "desktop"),
85+
complete: async () => {
86+
const result = await Promise.race([
87+
deepLink.complete.then((data) => ({
88+
source: "deep-link" as const,
89+
data,
90+
})),
91+
localCallback.complete.then((data) => ({
92+
source: "local" as const,
93+
data,
94+
})),
95+
]);
96+
97+
await deepLink.dispose();
98+
99+
if (result.source === "deep-link") {
100+
window.setTimeout(() => {
101+
void localCallback.dispose();
102+
}, 10000);
103+
} else {
104+
await localCallback.dispose();
105+
}
106+
107+
if (!result.data) return null;
108+
if (signal.aborted) throw new Error("Sign in aborted");
109+
110+
return result.data;
111+
},
112+
};
113+
}
114+
115+
async function startLocalCallbackSession(signal: AbortSignal) {
52116
await invoke("plugin:oauth|stop").catch(() => {});
53117

54118
const port: string = await invoke("plugin:oauth|start", {
@@ -59,103 +123,90 @@ async function createLocalServerSession(signal: AbortSignal) {
59123
"Cache-Control": "no-store, no-cache, must-revalidate",
60124
Pragma: "no-cache",
61125
},
62-
// Add a cleanup function to stop the server after handling the request
63126
cleanup: true,
64127
},
65128
});
66129

67-
signal.onabort = () => {
68-
invoke("plugin:oauth|stop").catch(() => {});
69-
};
130+
let settled = false;
131+
let stopListening: (() => void) | undefined;
132+
let resolvePromise: (data: AuthParams | null) => void = () => {};
70133

71-
let res: (url: URL | null) => void;
72-
73-
const stopListening = await listen(
74-
"oauth://url",
75-
(data: { payload: string }) => {
76-
console.log(data);
77-
if (
78-
!(data.payload.includes("token") || data.payload.includes("api_key"))
79-
) {
80-
return;
81-
}
82-
83-
const urlObject = new URL(data.payload);
84-
res(urlObject);
85-
},
86-
);
134+
const complete = new Promise<AuthParams | null>((resolve) => {
135+
resolvePromise = resolve;
136+
});
87137

88-
signal.onabort = (_e: Event) => {
89-
res(null);
138+
const settle = (value: AuthParams | null) => {
139+
if (settled) return;
140+
settled = true;
141+
resolvePromise(value);
90142
};
91143

92-
return {
93-
url: await createSessionRequestUrl(port, "web"),
94-
complete: async () => {
95-
const url = await new Promise<URL | null>((_res) => {
96-
res = _res;
97-
});
98-
99-
stopListening();
100-
if (!url) return null;
101-
if (signal.aborted) throw new Error("Sign in aborted");
144+
stopListening = await listen("oauth://url", (data: { payload: string }) => {
145+
if (!(data.payload.includes("token") || data.payload.includes("api_key"))) {
146+
return;
147+
}
102148

103-
const a = [...url.searchParams].reduce((acc, [k, v]) => {
104-
acc[k] = v;
105-
return acc;
106-
}, {} as any);
149+
settle(parseAuthParams(new URL(data.payload)));
150+
});
107151

108-
return paramsValidator.parse(a);
109-
},
152+
const dispose = async () => {
153+
stopListening?.();
154+
stopListening = undefined;
155+
settle(null);
156+
await invoke("plugin:oauth|stop").catch(() => {});
110157
};
158+
159+
signal.addEventListener("abort", () => void dispose(), { once: true });
160+
161+
return { port, complete, dispose };
111162
}
112163

113-
const paramsValidator = z.union([
114-
z.object({
115-
type: z.literal("api_key"),
116-
api_key: z.string(),
117-
user_id: z.string(),
118-
}),
119-
z.object({
120-
token: z.string(),
121-
user_id: z.string(),
122-
expires: z.coerce.number(),
123-
}),
124-
]);
164+
async function startDeepLinkSession(signal: AbortSignal) {
165+
let settled = false;
166+
let stopListening: (() => void) | undefined;
167+
let resolvePromise: (data: AuthParams | null) => void = () => {};
125168

126-
async function createDeepLinkSession(signal: AbortSignal) {
127-
let res: (data: z.infer<typeof paramsValidator>) => void;
128-
const p = new Promise<z.infer<typeof paramsValidator>>((r) => {
129-
res = r;
169+
const complete = new Promise<AuthParams | null>((resolve) => {
170+
resolvePromise = resolve;
130171
});
131-
const stopListening = await onOpenUrl(async (urls) => {
132-
for (const urlString of urls) {
133-
if (signal.aborted) return;
134172

135-
const url = new URL(urlString);
173+
const settle = (value: AuthParams | null) => {
174+
if (settled) return;
175+
settled = true;
176+
resolvePromise(value);
177+
};
136178

137-
res(
138-
paramsValidator.parse(
139-
[...url.searchParams].reduce((acc, [k, v]) => {
140-
acc[k] = v;
141-
return acc;
142-
}, {} as any),
143-
),
144-
);
179+
stopListening = await onOpenUrl(async (urls) => {
180+
for (const urlString of urls) {
181+
if (signal.aborted) return;
182+
settle(parseAuthParams(new URL(urlString)));
145183
}
146184
});
147185

148-
signal.onabort = () => {
149-
stopListening();
186+
const dispose = async () => {
187+
stopListening?.();
188+
stopListening = undefined;
189+
settle(null);
150190
};
151191

152-
return {
153-
url: await createSessionRequestUrl(null, "desktop"),
154-
complete: () => p,
155-
};
192+
signal.addEventListener("abort", () => void dispose(), { once: true });
193+
194+
return { complete, dispose };
195+
}
196+
197+
function parseAuthParams(url: URL) {
198+
return paramsValidator.parse(
199+
[...url.searchParams].reduce(
200+
(acc, [key, value]) => {
201+
acc[key] = value;
202+
return acc;
203+
},
204+
{} as Record<string, string>,
205+
),
206+
);
156207
}
157208

158-
async function processAuthData(data: z.infer<typeof paramsValidator>) {
209+
async function processAuthData(data: AuthParams) {
159210
identifyUser(data.user_id);
160211
trackEvent("user_signed_in", { platform: "desktop" });
161212

0 commit comments

Comments
 (0)