Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions apps/desktop/src/auth/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from "@hypr/supabase";

import { env } from "../env";
import { getScheme } from "../shared/utils";
import { buildWebAppUrl } from "../shared/utils";
import { useAuth } from "./context";

async function getClaimsFromToken(
Expand Down Expand Up @@ -85,11 +85,8 @@ export function BillingProvider({ children }: { children: ReactNode }) {
);

const upgradeToPro = useCallback(async () => {
const scheme = await getScheme();
void openerCommands.openUrl(
`${env.VITE_APP_URL}/app/checkout?period=monthly&scheme=${scheme}`,
null,
);
const url = await buildWebAppUrl("/app/checkout", { period: "monthly" });
void openerCommands.openUrl(url, null);
}, []);

const value = useMemo<BillingContextValue>(
Expand Down
11 changes: 3 additions & 8 deletions apps/desktop/src/auth/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ import {
useRef,
useState,
} from "react";
import { env } from "~/env";
import { DEVICE_FINGERPRINT_HEADER, getScheme } from "~/shared/utils";
import { buildWebAppUrl, DEVICE_FINGERPRINT_HEADER } from "~/shared/utils";

import { commands as analyticsCommands } from "@hypr/plugin-analytics";
import { commands as miscCommands } from "@hypr/plugin-misc";
Expand Down Expand Up @@ -266,12 +265,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);

const signIn = useCallback(async () => {
const base = env.VITE_APP_URL ?? "http://localhost:3000";
const scheme = await getScheme();
await openerCommands.openUrl(
`${base}/auth?flow=desktop&scheme=${scheme}`,
null,
);
const url = await buildWebAppUrl("/auth");
await openerCommands.openUrl(url, null);
}, []);

const signOut = useCallback(async () => {
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/shared/hooks/useDeeplinkHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { useEffect } from "react";
import { useAuth } from "~/auth";
import { useTabs } from "~/store/zustand/tabs";

import { events as deeplink2Events } from "@hypr/plugin-deeplink2";
import {
commands as deeplink2Commands,
events as deeplink2Events,
} from "@hypr/plugin-deeplink2";

export function useDeeplinkHandler() {
const auth = useAuth();
Expand All @@ -17,6 +20,8 @@ export function useDeeplinkHandler() {
}

const unlisten = deeplink2Events.deepLinkEvent.listen(({ payload }) => {
void deeplink2Commands.stopCallbackServer();

if (payload.to === "/auth/callback") {
const { access_token, refresh_token } = payload.search;
if (access_token && refresh_token && auth) {
Expand Down
12 changes: 9 additions & 3 deletions apps/desktop/src/shared/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { getIdentifier } from "@tauri-apps/api/app";

// export * from "../shared/config/configure-pro-settings";
// export * from "~/sidebar/timeline/utils";
// export * from "~/stt/segment";
import { commands as deeplink2Commands } from "@hypr/plugin-deeplink2";

export const id = () => crypto.randomUUID() as string;

Expand All @@ -24,10 +22,18 @@ export const buildWebAppUrl = async (
params?: Record<string, string>,
): Promise<string> => {
const { env } = await import("~/env");

const scheme = await getScheme();
const result = await deeplink2Commands.startCallbackServer(scheme);
if (result.status !== "ok") {
throw new Error(`Failed to start callback server: ${result.error}`);
}
const redirectUri = `http://127.0.0.1:${result.data}`;

const url = new URL(path, env.VITE_APP_URL);
url.searchParams.set("flow", "desktop");
url.searchParams.set("scheme", scheme);
url.searchParams.set("redirect_uri", redirectUri);
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
Expand Down
9 changes: 0 additions & 9 deletions apps/web/content/deeplinks/notification.mdx

This file was deleted.

5 changes: 5 additions & 0 deletions apps/web/src/functions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from "zod";

import { env } from "@/env";
import { isAdminEmail } from "@/functions/admin";
import { getDesktopReturnContext } from "@/functions/desktop-flow";
import {
getSupabaseAdminClient,
getSupabaseDesktopFlowClient,
Expand All @@ -14,6 +15,7 @@ const shared = z.object({
flow: z.enum(["desktop", "web"]).default("desktop"),
scheme: z.string().optional(),
redirect: z.string().optional(),
redirect_uri: z.string().optional(),
});

type Flow = z.infer<typeof shared>["flow"];
Expand All @@ -26,10 +28,13 @@ function buildAuthCallbackParams(data: {
flow: Flow;
scheme?: string;
redirect?: string;
redirect_uri?: string;
}) {
const params = new URLSearchParams({ flow: data.flow });
const { redirectUri } = getDesktopReturnContext(data);
if (data.scheme) params.set("scheme", data.scheme);
if (data.redirect) params.set("redirect", data.redirect);
if (redirectUri) params.set("redirect_uri", redirectUri);
return params;
}

Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/functions/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { createClient } from "@hypr/api-client/client";

import { env, requireEnv } from "@/env";
import { getDesktopReturnContext } from "@/functions/desktop-flow";
import { getStripeClient } from "@/functions/stripe";
import { getSupabaseServerClient } from "@/functions/supabase";

Expand Down Expand Up @@ -58,7 +59,9 @@ const getStripeCustomerIdForUser = async (

const createCheckoutSessionInput = z.object({
period: z.enum(["monthly", "yearly"]),
flow: z.enum(["desktop", "web"]).default("web"),
scheme: z.string().optional(),
redirect_uri: z.string().optional(),
});

export const createCheckoutSession = createServerFn({ method: "POST" })
Expand Down Expand Up @@ -125,9 +128,16 @@ export const createCheckoutSession = createServerFn({ method: "POST" })
: requireEnv(env.STRIPE_MONTHLY_PRICE_ID, "STRIPE_MONTHLY_PRICE_ID");

const successParams = new URLSearchParams({ success: "true" });
const { redirectUri } = getDesktopReturnContext(data);
if (data.flow === "desktop") {
successParams.set("flow", "desktop");
}
if (data.scheme) {
successParams.set("scheme", data.scheme);
}
if (redirectUri) {
successParams.set("redirect_uri", redirectUri);
}

const checkout = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
Expand Down
221 changes: 221 additions & 0 deletions apps/web/src/functions/desktop-flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { z } from "zod";

import type { DeepLink } from "@hypr/plugin-deeplink2";

export const DESKTOP_SCHEMES = [
"hypr",
"hyprnote",
"hyprnote-nightly",
"hyprnote-staging",
"char",
"char-nightly",
"char-staging",
] as const;

export const desktopSchemeSchema = z.enum(DESKTOP_SCHEMES);

export const normalizeDesktopRedirectUri = (
value: string | undefined,
): string | undefined => {
if (!value) {
return undefined;
}

try {
const url = new URL(value);
if (url.protocol !== "http:") {
return undefined;
}
if (!url.port || url.username || url.password || url.search || url.hash) {
return undefined;
}
if (url.pathname !== "/" && url.pathname !== "") {
return undefined;
}

const hostname = url.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
if (
hostname !== "localhost" &&
hostname !== "127.0.0.1" &&
hostname !== "::1"
) {
return undefined;
}

const port = Number.parseInt(url.port, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return undefined;
}

return `http://127.0.0.1:${url.port}`;
} catch {
return undefined;
}
};

export const desktopRedirectUriSchema = z
.string()
.optional()
.transform((v) => normalizeDesktopRedirectUri(v));

export type DesktopFlow = "desktop" | "web";

type DesktopReturnContextInput = {
flow?: DesktopFlow;
scheme?: string;
redirect_uri?: string;
};

export type DesktopReturnContext = {
flow: DesktopFlow;
scheme?: string;
redirectUri?: string;
isDesktop: boolean;
};

export const getDesktopReturnContext = (
input: DesktopReturnContextInput,
): DesktopReturnContext => {
const flow = input.flow ?? "web";
return {
flow,
scheme: input.scheme,
redirectUri: normalizeDesktopRedirectUri(input.redirect_uri),
isDesktop: flow === "desktop",
};
};

type DesktopCallbackPath = DeepLink["to"];

const DESKTOP_CALLBACK_PATHS = {
auth: "/auth/callback",
billing: "/billing/refresh",
integration: "/integration/callback",
} as const satisfies Record<
"auth" | "billing" | "integration",
DesktopCallbackPath
>;

const buildSchemeCallbackUrl = (
scheme: string,
path: DesktopCallbackPath,
params?: URLSearchParams,
): string => {
const normalizedPath = path.replace(/^\//, "");
const search = params?.toString();
return search
? `${scheme}://${normalizedPath}?${search}`
: `${scheme}://${normalizedPath}`;
};

const buildLocalCallbackUrl = (
redirectUri: string,
path: DesktopCallbackPath,
params?: URLSearchParams,
): string => {
const url = new URL(path, `${redirectUri}/`);
if (params) {
url.search = params.toString();
}
return url.toString();
};

export type DesktopCallbackUrls = {
primary?: string;
fallback?: string;
local?: string;
scheme?: string;
};

type DesktopAuthCallbackOptions = {
type: "auth";
access_token: string;
refresh_token: string;
};

type DesktopBillingCallbackOptions = {
type: "billing";
};

type DesktopIntegrationCallbackOptions = {
type: "integration";
integration_id: string;
status: string;
return_to?: string;
};

type DesktopCallbackOptions =
| DesktopAuthCallbackOptions
| DesktopBillingCallbackOptions
| DesktopIntegrationCallbackOptions;

export const buildDesktopCallbackUrls = (
context: DesktopReturnContext,
options: DesktopCallbackOptions,
): DesktopCallbackUrls => {
if (!context.isDesktop) {
return {};
}

const params = new URLSearchParams();
let path: DesktopCallbackPath;

if (options.type === "auth") {
path = DESKTOP_CALLBACK_PATHS.auth;
params.set("access_token", options.access_token);
params.set("refresh_token", options.refresh_token);
} else if (options.type === "billing") {
path = DESKTOP_CALLBACK_PATHS.billing;
} else {
path = DESKTOP_CALLBACK_PATHS.integration;
params.set("integration_id", options.integration_id);
params.set("status", options.status);
if (options.return_to) {
params.set("return_to", options.return_to);
}
}

const scheme = context.scheme
? buildSchemeCallbackUrl(context.scheme, path, params)
: undefined;
const local = context.redirectUri
? buildLocalCallbackUrl(context.redirectUri, path, params)
: undefined;
const primary = local ?? scheme;
const fallback = local && scheme ? scheme : undefined;

return { primary, fallback, local, scheme };
};

export const buildLocalAuthCallbackUrl = (
redirectUri: string,
tokens: { access_token: string; refresh_token: string },
): string => {
return buildLocalCallbackUrl(
redirectUri,
DESKTOP_CALLBACK_PATHS.auth,
new URLSearchParams(tokens),
);
};

export const buildLocalBillingRefreshUrl = (redirectUri: string): string => {
return buildLocalCallbackUrl(redirectUri, DESKTOP_CALLBACK_PATHS.billing);
};

export const buildLocalIntegrationCallbackUrl = (
redirectUri: string,
params: { integration_id: string; status: string; return_to?: string },
): string => {
const searchParams = new URLSearchParams({
integration_id: params.integration_id,
status: params.status,
});
if (params.return_to) {
searchParams.set("return_to", params.return_to);
}
return buildLocalCallbackUrl(
redirectUri,
DESKTOP_CALLBACK_PATHS.integration,
searchParams,
);
};
Loading