From ac644dcddd67fd3e3e976d6cc8ce46723f6fab0b Mon Sep 17 00:00:00 2001
From: Yujong Lee
Date: Thu, 26 Mar 2026 16:13:08 -0700
Subject: [PATCH] done
Signed-off-by: Yujong Lee
---
apps/web/src/components/deeplink-prompt.tsx | 64 ++++++++++++
apps/web/src/hooks/use-auto-deeplink.ts | 49 +++++++++
apps/web/src/routes/_view/callback/auth.tsx | 72 ++------------
.../src/routes/_view/callback/integration.tsx | 99 +++----------------
4 files changed, 132 insertions(+), 152 deletions(-)
create mode 100644 apps/web/src/components/deeplink-prompt.tsx
create mode 100644 apps/web/src/hooks/use-auto-deeplink.ts
diff --git a/apps/web/src/components/deeplink-prompt.tsx b/apps/web/src/components/deeplink-prompt.tsx
new file mode 100644
index 0000000000..944e53fb31
--- /dev/null
+++ b/apps/web/src/components/deeplink-prompt.tsx
@@ -0,0 +1,64 @@
+import { CheckIcon, CopyIcon } from "lucide-react";
+import { useState } from "react";
+
+import { cn } from "@hypr/utils";
+
+import { openDeeplink } from "@/hooks/use-auto-deeplink";
+
+export function DeeplinkPrompt({ url }: { url: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleDeeplink = () => {
+ openDeeplink(url);
+ };
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/hooks/use-auto-deeplink.ts b/apps/web/src/hooks/use-auto-deeplink.ts
new file mode 100644
index 0000000000..4df7739425
--- /dev/null
+++ b/apps/web/src/hooks/use-auto-deeplink.ts
@@ -0,0 +1,49 @@
+import { useEffect, useRef } from "react";
+
+function isMacOS(userAgent: string) {
+ const normalizedUserAgent = userAgent.toLowerCase();
+ const isMobileAppleDevice = /iphone|ipad|ipod/.test(normalizedUserAgent);
+ return (
+ (normalizedUserAgent.includes("macintosh") ||
+ normalizedUserAgent.includes("mac os x")) &&
+ !isMobileAppleDevice
+ );
+}
+
+export function openDeeplink(url: string) {
+ if (isMacOS(window.navigator.userAgent)) {
+ const iframe = document.createElement("iframe");
+ iframe.src = url;
+ iframe.setAttribute("aria-hidden", "true");
+ iframe.tabIndex = -1;
+ iframe.style.position = "absolute";
+ iframe.style.width = "0";
+ iframe.style.height = "0";
+ iframe.style.border = "0";
+ iframe.style.opacity = "0";
+ iframe.style.pointerEvents = "none";
+ document.body.append(iframe);
+
+ return () => {
+ iframe.remove();
+ };
+ }
+
+ window.location.href = url;
+
+ return undefined;
+}
+
+export function useAutoDeeplink(url: string | null) {
+ const lastTriggeredUrl = useRef(null);
+
+ useEffect(() => {
+ if (!url || lastTriggeredUrl.current === url) {
+ return;
+ }
+
+ lastTriggeredUrl.current = url;
+
+ return openDeeplink(url);
+ }, [url]);
+}
diff --git a/apps/web/src/routes/_view/callback/auth.tsx b/apps/web/src/routes/_view/callback/auth.tsx
index a44552e842..2f2bee6ca5 100644
--- a/apps/web/src/routes/_view/callback/auth.tsx
+++ b/apps/web/src/routes/_view/callback/auth.tsx
@@ -1,13 +1,14 @@
import { useOutlit } from "@outlit/browser/react";
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
-import { CheckIcon, CopyIcon } from "lucide-react";
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
import { z } from "zod";
import { cn } from "@hypr/utils";
+import { DeeplinkPrompt } from "@/components/deeplink-prompt";
import { exchangeOAuthCode, exchangeOtpToken } from "@/functions/auth";
import { desktopSchemeSchema } from "@/functions/desktop-flow";
+import { useAutoDeeplink } from "@/hooks/use-auto-deeplink";
import { useAnalytics } from "@/hooks/use-posthog";
const validateSearch = z.object({
@@ -134,7 +135,6 @@ function Component() {
const navigate = useNavigate();
const { identify: identifyOutlit, isInitialized } = useOutlit();
const { identify: identifyPosthog } = useAnalytics();
- const [copied, setCopied] = useState(false);
useEffect(() => {
if (!search.access_token || !isInitialized) return;
@@ -169,26 +169,8 @@ function Component() {
return null;
};
- // Browsers require a user gesture (click) to open custom URL schemes.
- // Auto-triggering via setTimeout fails for email magic links because
- // the page is opened from an external context (email client) without
- // "transient user activation". OAuth redirects work because they maintain
- // activation through the redirect chain.
- const handleDeeplink = () => {
- const deeplink = getDeeplink();
- if (search.flow === "desktop" && deeplink) {
- window.location.href = deeplink;
- }
- };
-
- const handleCopy = async () => {
- const deeplink = getDeeplink();
- if (deeplink) {
- await navigator.clipboard.writeText(deeplink);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
- };
+ const deeplink = search.flow === "desktop" ? getDeeplink() : null;
+ useAutoDeeplink(deeplink);
useEffect(() => {
if (search.flow === "web" && !search.error) {
@@ -246,49 +228,7 @@ function Component() {
- {hasTokens && (
-
-
-
-
-
- )}
+ {hasTokens && deeplink && }
);
diff --git a/apps/web/src/routes/_view/callback/integration.tsx b/apps/web/src/routes/_view/callback/integration.tsx
index 5283b0d0c2..f542adfef7 100644
--- a/apps/web/src/routes/_view/callback/integration.tsx
+++ b/apps/web/src/routes/_view/callback/integration.tsx
@@ -1,12 +1,11 @@
import { useQueryClient } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { CheckIcon, CopyIcon } from "lucide-react";
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
import { z } from "zod";
-import { cn } from "@hypr/utils";
-
+import { DeeplinkPrompt } from "@/components/deeplink-prompt";
import { flowSearchSchema } from "@/functions/desktop-flow";
+import { useAutoDeeplink } from "@/hooks/use-auto-deeplink";
const commonSearch = {
integration_id: z.string(),
@@ -51,31 +50,17 @@ function Component() {
const scheme = search.scheme ?? "hyprnote";
const navigate = useNavigate();
const queryClient = useQueryClient();
- const [copied, setCopied] = useState(false);
-
- const getDeeplink = () => {
- return buildDeeplinkUrl(scheme, {
- integration_id: search.integration_id,
- status: search.status,
- return_to: search.return_to,
- });
- };
- const handleDeeplink = () => {
- const deeplink = getDeeplink();
- if (search.flow === "desktop" && deeplink) {
- window.location.href = deeplink;
- }
- };
+ const deeplink =
+ search.flow === "desktop"
+ ? buildDeeplinkUrl(scheme, {
+ integration_id: search.integration_id,
+ status: search.status,
+ return_to: search.return_to,
+ })
+ : null;
- const handleCopy = async () => {
- const deeplink = getDeeplink();
- if (deeplink) {
- await navigator.clipboard.writeText(deeplink);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
- };
+ useAutoDeeplink(search.status === "success" ? deeplink : null);
useEffect(() => {
if (search.flow === "web") {
@@ -86,22 +71,6 @@ function Component() {
}
}, [search.flow, navigate, queryClient]);
- useEffect(() => {
- if (search.flow === "desktop" && search.status === "success") {
- const deeplink = getDeeplink();
- const timer = setTimeout(() => {
- window.location.href = deeplink;
- }, 250);
- return () => clearTimeout(timer);
- }
- }, [
- search.flow,
- search.status,
- scheme,
- search.integration_id,
- search.return_to,
- ]);
-
const isSuccess = search.status === "success";
if (search.flow === "desktop") {
@@ -119,49 +88,7 @@ function Component() {
- {isSuccess && (
-
-
-
-
-
- )}
+ {isSuccess && deeplink && }
);