Files
openswarm/frontend/src/shared/hooks/useDeepLink.ts
T

117 lines
4.4 KiB
TypeScript

import { useEffect } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { activateSubscription } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { fetchTools } from '@/shared/state/toolsSlice';
import { API_BASE } from '@/shared/config';
import { report } from '@/shared/serviceClient';
// Listens for openswarm://auth?token=...&plan=...&expires=... URLs coming
// from the Electron main process via window.openswarm.onAuthUrl. Parses the
// payload and dispatches activateSubscription so the backend validates and
// persists the bearer.
//
// Safe no-op in web/browser contexts where window.openswarm isn't defined.
export function useDeepLink(): void {
const dispatch = useAppDispatch();
useEffect(() => {
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
// Both listeners are optional — useDeepLink no-ops in browser/web context
// where window.openswarm is undefined.
if (!api) return;
const unsubscribe = api.onAuthUrl?.((rawUrl: string) => {
try {
// openswarm://auth?token=... (host = "auth", search carries fields)
const url = new URL(rawUrl);
if (url.host !== 'auth' && url.pathname !== '//auth' && url.pathname !== '/auth') {
console.warn('[deep-link] Unknown openswarm:// host:', url.host);
return;
}
const token = url.searchParams.get('token');
if (!token) {
console.warn('[deep-link] Missing token in', rawUrl);
return;
}
const plan = url.searchParams.get('plan');
const expires = url.searchParams.get('expires');
report('subscription', 'deep_link_received', {
plan: plan ?? 'unknown',
});
dispatch(
activateSubscription({
token,
plan,
expires,
}),
)
.unwrap()
.then((res) => {
report('subscription', 'activated', { plan: res.plan });
// Re-fetch the model list so the Claude models (via OpenSwarm
// Pro proxy) show up in the chat picker right away.
dispatch(fetchModels());
})
.catch((err) => {
console.error('[deep-link] Activation failed:', err);
report('subscription', 'activation_failed', {
message: String(err).slice(0, 120),
});
});
} catch (e) {
console.error('[deep-link] Failed to parse URL', rawUrl, e);
}
});
// OAuth claim deep-link listener. The Electron main process routes
// openswarm://oauth/{provider}/complete to its own IPC channel so we
// can claim tokens immediately rather than routing through Settings.
let unsubscribeOauth: (() => void) | undefined;
if (api?.onOauthClaim) {
unsubscribeOauth = api.onOauthClaim(async (rawUrl: string) => {
try {
const url = new URL(rawUrl);
// Expected: openswarm://oauth/{provider}/complete?session_id=...&tool_id=...
if (url.host !== 'oauth' || !url.pathname.endsWith('/complete')) {
console.warn('[deep-link] Unexpected oauth-claim URL:', rawUrl);
return;
}
const sessionId = url.searchParams.get('session_id');
const toolId = url.searchParams.get('tool_id');
if (!sessionId || !toolId) {
console.warn('[deep-link] Missing session_id or tool_id in', rawUrl);
return;
}
report('oauth', 'deep_link_received', { provider: url.pathname.split('/')[1] || 'unknown' });
const resp = await fetch(`${API_BASE}/tools/oauth/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, tool_id: toolId }),
});
if (!resp.ok) {
const text = await resp.text();
console.error('[deep-link] OAuth claim failed:', resp.status, text);
report('oauth', 'claim_failed', { status: resp.status });
return;
}
report('oauth', 'claim_succeeded');
// Refresh tools so the UI reflects the newly-connected tool.
dispatch(fetchTools());
} catch (e) {
console.error('[deep-link] OAuth claim threw:', e);
}
});
}
return () => {
unsubscribe?.();
unsubscribeOauth?.();
};
}, [dispatch]);
}