diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx
index 4fc62ed..3682586 100644
--- a/apps/web/src/app.tsx
+++ b/apps/web/src/app.tsx
@@ -3,6 +3,8 @@ import {
createBrowserRouter,
RouterProvider,
Navigate,
+ Outlet,
+ useNavigate,
} from "react-router-dom";
import { AuthInitializer } from "@/components/auth-initializer";
import { AuthGuard } from "@/components/auth-guard";
@@ -18,10 +20,10 @@ import { TelemetryPage } from "@/pages/telemetry-page";
import { LoginPage } from "@/pages/login-page";
import { RegisterPage } from "@/pages/register-page";
import { MainLayout } from "@/components/layout/main-layout";
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
import { useAuthStore } from "@/store/auth-store";
import { apiClient } from "@/lib/api-client";
-import { setupDeepLinkHandler } from "@/lib/tauri";
+import { setupDeepLinkHandler, consumeDeepLinkTarget } from "@/lib/tauri";
function LogoutProses() {
const setUser = useAuthStore((state) => state.setUser);
@@ -32,7 +34,7 @@ function LogoutProses() {
}).catch((error) => {
console.error("Oops, gagal logout dari server:", error);
- setUser(null);
+ setUser(null);
});
}, [setUser]);
@@ -43,105 +45,138 @@ import { trackPageView, trackError } from "./lib/telemetry";
const queryClient = new QueryClient();
const router = createBrowserRouter([
- { path: "/", element: },
- { path: "/login", element: },
- { path: "/register", element: },
{
- path: "/dashboard",
- element: (
-
-
-
-
-
- ),
- },
- {
- path: "/scan",
- element: (
-
-
-
-
-
- ),
- },
- {
- path: "/library",
- element: (
-
-
-
-
-
- ),
- },
- {
- path: "/diagnoses",
- element: (
-
-
-
-
-
- ),
- },
- {
- path: "/diagnoses/:id",
- element: (
-
-
-
-
-
- ),
- },
- {
- path: "/expert/reviews",
- element: (
-
-
-
-
-
- ),
- },
- {
- path: "/catalog",
- element: (
-
-
-
-
-
- ),
- },
- {
- path: "/catalog/:slug",
- element: (
-
-
-
-
-
- ),
- },
- {
- path: "/logout",
- element: (
-
- ),
- },
- {
- path: "/telemetry",
- element: (
-
-
-
- ),
+ element: ,
+ children: [
+ { path: "/", element: },
+ { path: "/login", element: },
+ { path: "/register", element: },
+ {
+ path: "/dashboard",
+ element: (
+
+
+
+
+
+ ),
+ },
+ {
+ path: "/scan",
+ element: (
+
+
+
+
+
+ ),
+ },
+ {
+ path: "/library",
+ element: (
+
+
+
+
+
+ ),
+ },
+ {
+ path: "/diagnoses",
+ element: (
+
+
+
+
+
+ ),
+ },
+ {
+ path: "/diagnoses/:id",
+ element: (
+
+
+
+
+
+ ),
+ },
+ {
+ path: "/expert/reviews",
+ element: (
+
+
+
+
+
+ ),
+ },
+ {
+ path: "/catalog",
+ element: (
+
+
+
+
+
+ ),
+ },
+ {
+ path: "/catalog/:slug",
+ element: (
+
+
+
+
+
+ ),
+ },
+ {
+ path: "/logout",
+ element: ,
+ },
+ {
+ path: "/telemetry",
+ element: (
+
+
+
+ ),
+ },
+ ],
},
]);
+/**
+ * Listens for deep-link custom events and routes via React Router's navigate(),
+ * avoiding full page reloads that break the Tauri IPC bridge.
+ */
+function DeepLinkRouterHandler() {
+ const navigate = useNavigate();
+ const handled = useRef(new Set());
+
+ useEffect(() => {
+ // Check for cold-start pending deep link
+ const pending = consumeDeepLinkTarget();
+ if (pending && !handled.current.has(pending)) {
+ handled.current.add(pending);
+ navigate(pending, { replace: true });
+ }
+
+ // Listen for warm-start deep links
+ const handler = (e: CustomEvent) => {
+ const target = e.detail;
+ if (handled.current.has(target)) return;
+ handled.current.add(target);
+ navigate(target, { replace: true });
+ };
+ window.addEventListener('zeavis:deeplink', handler as EventListener);
+ return () => window.removeEventListener('zeavis:deeplink', handler as EventListener);
+ }, [navigate]);
+
+ return ;
+}
+
function PageViewTracker() {
const location = window.location;
useEffect(() => {
diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx
index 6fe38ca..31c1324 100644
--- a/apps/web/src/components/auth-form.tsx
+++ b/apps/web/src/components/auth-form.tsx
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
+import { apiBaseUrl } from '@/lib/api-client';
import { isTauri, openUrl } from '@/lib/tauri';
type AuthFormProps = {
@@ -29,10 +30,7 @@ export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubm
const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const platform = isTauri() ? 'tauri' : 'web';
- // Use API base URL, not window.location.origin — on Tauri Android
- // the origin is http://tauri.localhost which is not the API server.
- const apiBase = import.meta.env.VITE_API_BASE_URL || window.location.origin;
- const googleUrl = `${apiBase}/api/v1/auth/google?platform=${platform}`;
+ const googleUrl = `${apiBaseUrl}/api/v1/auth/google?platform=${platform}`;
await openUrl(googleUrl);
}, []);
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts
index ab04878..bbf14c2 100644
--- a/apps/web/src/lib/api-client.ts
+++ b/apps/web/src/lib/api-client.ts
@@ -14,7 +14,7 @@ import type {
} from '@zeavis/shared';
import { recordApiCall } from './telemetry';
-const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id';
+export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id';
const AUTH_TOKEN_KEY = 'zeavis_auth_token';
diff --git a/apps/web/src/lib/tauri.ts b/apps/web/src/lib/tauri.ts
index 3f7f4b5..37f621f 100644
--- a/apps/web/src/lib/tauri.ts
+++ b/apps/web/src/lib/tauri.ts
@@ -1,6 +1,13 @@
/**
* Lightweight Tauri environment detection and utilities.
* Uses raw __TAURI_INTERNALS__ IPC to avoid bundling/import issues on Android.
+ *
+ * Deep-link flow (no full page reloads — uses React Router navigate()):
+ * 1. Tauri deep-link plugin receives URL via intent/custom-scheme.
+ * 2. processDeepLinkUrl stores the target path in sessionStorage +
+ * dispatches a custom DOM event.
+ * 3. inside picks it up and
+ * calls navigate(), keeping the React app alive.
*/
let _isTauri: boolean | null = null;
@@ -22,7 +29,6 @@ function tauriInvoke(): (cmd: string, args?: Record) => Promise
export async function openUrl(url: string): Promise {
if (!isTauri()) {
- // Not in Tauri — normal browser navigation
window.location.href = url;
return;
}
@@ -31,47 +37,76 @@ export async function openUrl(url: string): Promise {
await invoke('plugin:opener|open_url', { url });
} catch (err) {
console.error('Tauri openUrl failed, trying fallback:', err);
- // Fallback: navigate the WebView (Google will block, but best effort)
window.location.href = url;
}
}
-function processDeepLinkUrl(url: string): void {
- try {
- const u = new URL(url);
- const target = u.pathname + u.search + u.hash;
- if (target && target !== '/') {
- window.location.href = target;
- return;
- }
- } catch { /* fall through */ }
+// ── Deep link handling (no full reload) ─────────────────────────────────
- // Fallback: handle both :// and :/ custom schemes
- let match = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/);
- if (!match) match = url.match(/^[^:]+:\/(\/.*)?$/);
- if (match?.[1]) window.location.href = match[1];
+const DEEP_LINK_KEY = 'zeavis_pending_deeplink';
+const DEEP_LINK_EVENT = 'zeavis:deeplink';
+
+/** Store a target path for the React Router to pick up without page reload. */
+function storeDeepLinkTarget(target: string): void {
+ try { sessionStorage.setItem(DEEP_LINK_KEY, target); } catch { /* ignore */ }
}
+/** Read and clear the stored deep link target. */
+export function consumeDeepLinkTarget(): string | null {
+ try {
+ const v = sessionStorage.getItem(DEEP_LINK_KEY);
+ if (v) sessionStorage.removeItem(DEEP_LINK_KEY);
+ return v;
+ } catch { return null; }
+}
+
+
export async function setupDeepLinkHandler(): Promise {
if (!isTauri()) return;
try {
const invoke = tauriInvoke();
- // Cold-start: app just opened via intent:// or custom scheme
+ // Cold-start: app opened via intent:// (e.g. from Google OAuth callback).
+ // Use window.location.href for this (full page reload) — at cold start there
+ // is no SPA state to lose, so redirecting via location.href avoids orphaned
+ // IPC promises that cause "Cannot read properties of undefined (reading 'runCallback')".
invoke('plugin:deep-link|get_current')
.then((urls: any) => {
- if (urls?.[0]) processDeepLinkUrl(urls[0]);
+ if (!urls?.[0]) return;
+ const target = extractDeepLinkTarget(urls[0]);
+ if (target && target !== window.location.pathname + window.location.search + window.location.hash) {
+ window.location.href = target;
+ }
})
- .catch(() => { /* plugin may not be registered yet */ });
+ .catch(() => {});
- // Warm-start: listen for new URLs while app is running
+ // Warm-start: listen for new URLs (already running app).
+ // Use React Router navigate() here since we have SPA state.
const { listen } = await import('@tauri-apps/api/event');
listen('deep-link://new-url', (event: any) => {
const urls = event.payload as string[];
- for (const url of urls) processDeepLinkUrl(url);
+ for (const url of urls) {
+ const target = extractDeepLinkTarget(url);
+ if (target) {
+ storeDeepLinkTarget(target);
+ window.dispatchEvent(new CustomEvent(DEEP_LINK_EVENT, { detail: target }));
+ }
+ }
});
} catch (err) {
console.error('Tauri deep-link setup failed:', err);
}
}
+
+/** Extract path+query+hash from a deep-link URL. */
+function extractDeepLinkTarget(url: string): string {
+ try {
+ const u = new URL(url);
+ return u.pathname + u.search + u.hash;
+ } catch {
+ let m = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/);
+ if (!m) m = url.match(/^[^:]+:\/(\/.*)?$/);
+ return m?.[1] ?? '';
+ }
+}
diff --git a/apps/web/src/pages/login-page.tsx b/apps/web/src/pages/login-page.tsx
index 59f57ab..9603b8a 100644
--- a/apps/web/src/pages/login-page.tsx
+++ b/apps/web/src/pages/login-page.tsx
@@ -1,9 +1,10 @@
import { useState, useEffect, useRef } from "react";
-import { Link, useNavigate } from "react-router-dom";
+import { Link, useNavigate, useLocation } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form";
import { apiClient, setAuthToken } from "@/lib/api-client";
import { useAuthStore } from "@/store/auth-store";
+import { isTauri } from "@/lib/tauri";
function getUrlParam(name: string): string | null {
return new URLSearchParams(window.location.search).get(name);
@@ -13,11 +14,13 @@ export function LoginPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser);
+ const location = useLocation();
const [error, setError] = useState(null);
const oauthTokenConsumed = useRef(false);
const [oauthProcessing, setOauthProcessing] = useState(false);
// Handle OAuth callback: the API redirects to /login?token=
+ // Must re-run on location.search change (SPA navigates to /login?token=xxx)
useEffect(() => {
const token = getUrlParam("token");
if (!token || oauthTokenConsumed.current) return;
@@ -39,7 +42,36 @@ export function LoginPage() {
setOauthProcessing(false);
setError(err instanceof Error ? err.message : "Google login gagal");
});
- }, [setUser, queryClient, navigate]);
+ }, [setUser, queryClient, navigate, location.search]);
+
+ // Backup: when app returns from background (e.g. after Google OAuth browser)
+ // re-check URL params — the deep-link event may have been missed.
+ useEffect(() => {
+ if (!isTauri()) return;
+ if (getUrlParam("token") || oauthTokenConsumed.current) return;
+
+ const onVisibility = () => {
+ if (document.visibilityState !== "visible") return;
+ const token = getUrlParam("token");
+ if (token && !oauthTokenConsumed.current) {
+ setOauthProcessing(true);
+ }
+ };
+
+ const onFocus = () => {
+ const token = getUrlParam("token");
+ if (token && !oauthTokenConsumed.current) {
+ setOauthProcessing(true);
+ }
+ };
+
+ document.addEventListener("visibilitychange", onVisibility);
+ window.addEventListener("focus", onFocus);
+ return () => {
+ document.removeEventListener("visibilitychange", onVisibility);
+ window.removeEventListener("focus", onFocus);
+ };
+ }, []);
// Show OAuth error from query param
const oauthError = getUrlParam("error");
@@ -48,6 +80,13 @@ export function LoginPage() {
queryFn: () => apiClient.getMe(),
});
+ // Already authenticated — redirect to dashboard
+ useEffect(() => {
+ if (!meQuery.isLoading && meQuery.data?.user) {
+ navigate("/dashboard", { replace: true });
+ }
+ }, [meQuery.data, meQuery.isLoading, navigate]);
+
const mutation = useMutation({
mutationFn: apiClient.login,
onSuccess: (response) => {