+
+ {desktop &&
}
+
{/* Mobile Overlay */}
{isSidebarOpen && (
= ({ children }) => {
{children}
+
);
};
diff --git a/propr-ui/src/config/runtimeConfig.ts b/propr-ui/src/config/runtimeConfig.ts
index 1cde62247..b570334a6 100644
--- a/propr-ui/src/config/runtimeConfig.ts
+++ b/propr-ui/src/config/runtimeConfig.ts
@@ -61,6 +61,7 @@ const WINDOW_NAME_CONTEXT_PREFIX = 'propr-hosted-flow-context:';
const WINDOW_NAME_CONTEXT_SEPARATOR = '|';
let activeHostedTunnelFlowId: string | null = null;
+let desktopApiBaseUrl: string | null = null;
/**
* Hostname of the managed hosted UI (e.g. `app.propr.dev`), derived from the
@@ -501,6 +502,8 @@ export const getApiBaseUrl = (): string => {
return '';
}
+ if (desktopApiBaseUrl !== null) return desktopApiBaseUrl;
+
return resolveApiBaseUrl(
typeof window !== 'undefined' ? window.location.hostname : '',
typeof window !== 'undefined' ? window.location.search : '',
@@ -509,3 +512,14 @@ export const getApiBaseUrl = (): string => {
storageForWindow()
);
};
+
+/** Set by the desktop presentation boundary after a profile has passed its probe. */
+export const setDesktopApiBaseUrl = (value: string | null): void => {
+ if (value === null) {
+ desktopApiBaseUrl = null;
+ return;
+ }
+ const normalized = value.trim().replace(/\/+$/, '');
+ if (normalized && !isValidHttpUrl(normalized)) throw new Error('Desktop API base URL must use http(s).');
+ desktopApiBaseUrl = normalized;
+};
diff --git a/propr-ui/src/desktop/DesktopContext.tsx b/propr-ui/src/desktop/DesktopContext.tsx
new file mode 100644
index 000000000..c3351d9bd
--- /dev/null
+++ b/propr-ui/src/desktop/DesktopContext.tsx
@@ -0,0 +1,17 @@
+import { createContext, useContext } from 'react';
+import type { DesktopConnectionResult, DesktopPlatform, DesktopProfile } from './types';
+
+export interface DesktopContextValue {
+ isDesktop: true;
+ platform: DesktopPlatform;
+ profile: DesktopProfile;
+ connection: DesktopConnectionResult;
+ openProfileManager(): void;
+ authenticate(): Promise
;
+ openConnectionHelp(): Promise;
+ retry(): void;
+}
+
+export const DesktopContext = createContext(null);
+
+export const useDesktop = (): DesktopContextValue | null => useContext(DesktopContext);
diff --git a/propr-ui/src/desktop/DesktopExperience.test.tsx b/propr-ui/src/desktop/DesktopExperience.test.tsx
new file mode 100644
index 000000000..e8ea211dc
--- /dev/null
+++ b/propr-ui/src/desktop/DesktopExperience.test.tsx
@@ -0,0 +1,116 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { DesktopExperience } from './DesktopExperience';
+import { DesktopTitleBar } from './DesktopTitleBar';
+import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types';
+
+const apiMock = vi.hoisted(() => ({ setApiBaseUrl: vi.fn() }));
+const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() }));
+
+vi.mock('../api/apiClient', () => ({ setApiBaseUrl: apiMock.setApiBaseUrl }));
+vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: runtimeMock.setDesktopApiBaseUrl }));
+
+const localProfile: DesktopProfile = {
+ id: 'local',
+ name: 'This computer',
+ baseUrl: 'http://127.0.0.1:3000',
+ kind: 'local',
+};
+
+const adaptersFor = (
+ profiles: DesktopProfile[] = [],
+ activeId: string | null = null,
+ probe: (profile: DesktopProfile) => Promise = async () => ({ status: 'ready', version: '0.8.15' })
+): DesktopAdapters => ({
+ platform: 'linux',
+ profiles: {
+ list: vi.fn(async () => profiles),
+ save: vi.fn(async () => undefined),
+ remove: vi.fn(async () => undefined),
+ getActiveId: vi.fn(async () => activeId),
+ setActiveId: vi.fn(async () => undefined),
+ },
+ discovery: { discover: vi.fn(async () => []) },
+ authentication: { authenticate: vi.fn(async () => undefined) },
+ externalBrowser: { open: vi.fn(async () => undefined) },
+ localSetup: { setup: vi.fn(async () => localProfile) },
+ connection: { probe: vi.fn(probe) },
+});
+
+describe('DesktopExperience', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('runs first-time local setup through adapters before mounting the shared app', async () => {
+ const adapters = adaptersFor();
+ render(Shared route tree
);
+
+ expect(await screen.findByRole('heading', { name: 'Let’s set up this computer' })).toBeInTheDocument();
+ expect(screen.queryByText('Shared route tree')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /Set up this computer/i }));
+
+ expect(await screen.findByText('Shared route tree')).toBeInTheDocument();
+ expect(adapters.localSetup.setup).toHaveBeenCalledOnce();
+ expect(adapters.connection.probe).toHaveBeenCalledWith(localProfile);
+ expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({ id: 'local' }));
+ expect(adapters.profiles.setActiveId).toHaveBeenCalledWith('local');
+ expect(runtimeMock.setDesktopApiBaseUrl).toHaveBeenCalledWith(localProfile.baseUrl);
+ expect(apiMock.setApiBaseUrl).toHaveBeenCalledWith(localProfile.baseUrl);
+ });
+
+ it('shows a retryable offline state and recovers without reloading', async () => {
+ const probe = vi.fn()
+ .mockResolvedValueOnce({ status: 'offline', message: 'The instance is offline.' })
+ .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' });
+ const adapters = adaptersFor([localProfile], localProfile.id, probe);
+ render(Dashboard content
);
+
+ expect(await screen.findByRole('heading', { name: 'This computer' })).toBeInTheDocument();
+ expect(screen.getByText('The instance is offline.')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /Try again/i }));
+
+ expect(await screen.findByText('Dashboard content')).toBeInTheDocument();
+ expect(probe).toHaveBeenCalledTimes(2);
+ });
+
+ it('supports editing a recent profile and connecting to the updated URL', async () => {
+ const adapters = adaptersFor([localProfile]);
+ render(Connected app
);
+
+ expect(await screen.findByText('Recent instances')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Edit This computer' }));
+ fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Office ProPR' } });
+ fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://office.example.com/' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
+
+ expect(await screen.findByText('Connected app')).toBeInTheDocument();
+ expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({
+ id: 'local',
+ name: 'Office ProPR',
+ baseUrl: 'https://office.example.com',
+ }));
+ });
+
+ it('opens instance management with the desktop shortcut and exposes connection status', async () => {
+ const adapters = adaptersFor([localProfile], localProfile.id);
+ render(
+
+
+
+ );
+
+ expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument();
+ fireEvent.keyDown(document, { key: ',', ctrlKey: true });
+ expect(await screen.findByRole('dialog', { name: 'Manage instances' })).toBeInTheDocument();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ });
+});
+
diff --git a/propr-ui/src/desktop/DesktopExperience.tsx b/propr-ui/src/desktop/DesktopExperience.tsx
new file mode 100644
index 000000000..5f4b9658d
--- /dev/null
+++ b/propr-ui/src/desktop/DesktopExperience.tsx
@@ -0,0 +1,362 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { AlertTriangle, ArrowLeft, ChevronRight, Cloud, Computer, LoaderCircle, Pencil, Plus, RefreshCw, Search, Server, Trash2, X } from 'lucide-react';
+import { setApiBaseUrl } from '../api/apiClient';
+import * as runtimeConfig from '../config/runtimeConfig';
+import { DesktopContext } from './DesktopContext';
+import { normalizeBaseUrl } from './browserAdapters';
+import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types';
+import './desktop.css';
+
+type ExperienceState =
+ | { phase: 'loading' }
+ | { phase: 'choose' }
+ | { phase: 'connecting'; profile: DesktopProfile }
+ | { phase: 'blocked'; profile: DesktopProfile; result: Exclude }
+ | { phase: 'connected'; profile: DesktopProfile; result: Extract };
+
+interface DesktopExperienceProps {
+ adapters: DesktopAdapters;
+ children: React.ReactNode;
+}
+
+const profileId = (): string => {
+ try { return crypto.randomUUID(); } catch { return `profile-${Date.now()}`; }
+};
+
+const mergeProfiles = (current: DesktopProfile[], incoming: DesktopProfile[]): DesktopProfile[] => {
+ const profiles = new Map(current.map(profile => [profile.id, profile]));
+ incoming.forEach(profile => profiles.set(profile.id, profile));
+ return [...profiles.values()].sort((a, b) => (b.lastConnectedAt || '').localeCompare(a.lastConnectedAt || ''));
+};
+
+const connectionLabel = (result: DesktopConnectionResult): string => {
+ if (result.status === 'incompatible') return 'Update required';
+ if (result.status === 'authentication-required') return 'Sign in required';
+ if (result.status === 'offline') return 'Instance unavailable';
+ return 'Connected';
+};
+
+const DesktopBrand: React.FC = () => (
+
+

+
ProPR
+
+);
+
+interface ProfileEditorProps {
+ initial?: DesktopProfile;
+ onCancel(): void;
+ onSave(profile: DesktopProfile): void;
+}
+
+const ProfileEditor: React.FC = ({ initial, onCancel, onSave }) => {
+ const [name, setName] = useState(initial?.name || 'My ProPR');
+ const [baseUrl, setBaseUrl] = useState(initial?.baseUrl || 'http://127.0.0.1:3000');
+ const [error, setError] = useState(null);
+
+ const submit = (event: React.FormEvent) => {
+ event.preventDefault();
+ try {
+ onSave({
+ id: initial?.id || profileId(),
+ name: name.trim() || 'My ProPR',
+ baseUrl: normalizeBaseUrl(baseUrl),
+ kind: initial?.kind || (new URL(baseUrl).hostname === '127.0.0.1' || new URL(baseUrl).hostname === 'localhost' ? 'local' : 'remote'),
+ lastConnectedAt: initial?.lastConnectedAt,
+ });
+ } catch (caught) {
+ setError(caught instanceof Error ? caught.message : 'Enter a valid instance URL.');
+ }
+ };
+
+ return (
+
+ );
+};
+
+interface ProfileListProps {
+ profiles: DesktopProfile[];
+ onConnect(profile: DesktopProfile): void;
+ onEdit(profile: DesktopProfile): void;
+ onRemove(profile: DesktopProfile): void;
+}
+
+const ProfileList: React.FC = ({ profiles, onConnect, onEdit, onRemove }) => (
+
+
Recent instances
+
+ {profiles.map(profile => (
+
+
+
+
+
+ ))}
+
+
+);
+
+interface ChooserProps extends ProfileListProps {
+ busy: boolean;
+ error: string | null;
+ onLocalSetup(): void;
+ onConnectNew(): void;
+ onDiscover(): void;
+}
+
+const InstanceChooser: React.FC = ({ profiles, busy, error, onLocalSetup, onConnectNew, onDiscover, ...listProps }) => (
+
+
+
+
ProPR Desktop
+
{profiles.length ? 'Choose an instance' : 'Let’s set up this computer'}
+
Keep your repositories and coding agents close, or connect securely to a ProPR instance you already use.
+
+
+
+
+
+ {error && {error}
}
+ {profiles.length > 0 && }
+
+
+);
+
+const ConnectionPanel: React.FC<{
+ profile: DesktopProfile;
+ result?: Exclude;
+ onBack(): void;
+ onRetry(): void;
+ onAuthenticate(): void;
+ onHelp(): void;
+}> = ({ profile, result, onBack, onRetry, onAuthenticate, onHelp }) => (
+
+
+ {!result ? (
+ <>
+
+ Connecting to {profile.name}
+ Checking the instance and desktop compatibility…
+ >
+ ) : (
+ <>
+
+ {connectionLabel(result)}
+ {profile.name}
+ {result.message || 'This instance needs authentication before ProPR Desktop can connect.'}
+ {result.status === 'incompatible' && result.version && Instance version {result.version} · Desktop {__APP_VERSION__}
}
+
+ {result.status === 'authentication-required' && }
+
+
+
+
+ >
+ )}
+
+);
+
+export const DesktopExperience: React.FC = ({ adapters, children }) => {
+ const [profiles, setProfiles] = useState([]);
+ const [state, setState] = useState({ phase: 'loading' });
+ const [editing, setEditing] = useState(null);
+ const [managerOpen, setManagerOpen] = useState(false);
+ const [operationError, setOperationError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [networkOffline, setNetworkOffline] = useState(!navigator.onLine);
+
+ const connect = useCallback(async (profile: DesktopProfile) => {
+ setOperationError(null);
+ setState({ phase: 'connecting', profile });
+ const result = await adapters.connection.probe(profile);
+ if (result.status !== 'ready') {
+ setState({ phase: 'blocked', profile, result });
+ return;
+ }
+ const connectedProfile = { ...profile, lastConnectedAt: new Date().toISOString() };
+ await adapters.profiles.save(connectedProfile);
+ await adapters.profiles.setActiveId(profile.id);
+ setProfiles(current => mergeProfiles(current, [connectedProfile]));
+ runtimeConfig.setDesktopApiBaseUrl(connectedProfile.baseUrl);
+ setApiBaseUrl(connectedProfile.baseUrl);
+ setState({ phase: 'connected', profile: connectedProfile, result });
+ }, [adapters]);
+
+ useEffect(() => {
+ let cancelled = false;
+ void Promise.all([adapters.profiles.list(), adapters.profiles.getActiveId()]).then(([stored, activeId]) => {
+ if (cancelled) return;
+ setProfiles(stored);
+ const active = stored.find(profile => profile.id === activeId);
+ if (active) void connect(active);
+ else setState({ phase: 'choose' });
+ }).catch(error => {
+ if (!cancelled) {
+ setOperationError(error instanceof Error ? error.message : 'Profiles could not be loaded.');
+ setState({ phase: 'choose' });
+ }
+ });
+ return () => { cancelled = true; };
+ }, [adapters, connect]);
+
+ useEffect(() => {
+ const online = () => setNetworkOffline(false);
+ const offline = () => setNetworkOffline(true);
+ window.addEventListener('online', online);
+ window.addEventListener('offline', offline);
+ return () => {
+ window.removeEventListener('online', online);
+ window.removeEventListener('offline', offline);
+ };
+ }, []);
+
+ useEffect(() => {
+ const handleKeyboard = (event: KeyboardEvent) => {
+ if (state.phase !== 'connected') return;
+ if ((event.metaKey || event.ctrlKey) && event.key === ',') {
+ event.preventDefault();
+ setManagerOpen(true);
+ } else if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'r') {
+ event.preventDefault();
+ void connect(state.profile);
+ } else if (event.key === 'Escape') {
+ setManagerOpen(false);
+ setEditing(null);
+ }
+ };
+ document.addEventListener('keydown', handleKeyboard);
+ return () => document.removeEventListener('keydown', handleKeyboard);
+ }, [connect, state]);
+
+ const removeProfile = async (profile: DesktopProfile) => {
+ if (!window.confirm(`Remove “${profile.name}” from this computer?`)) return;
+ await adapters.profiles.remove(profile.id);
+ setProfiles(current => current.filter(item => item.id !== profile.id));
+ if (state.phase === 'connected' && state.profile.id === profile.id) setState({ phase: 'choose' });
+ };
+
+ const saveProfile = async (profile: DesktopProfile, shouldConnect = true) => {
+ await adapters.profiles.save(profile);
+ setProfiles(current => mergeProfiles(current, [profile]));
+ setEditing(null);
+ if (shouldConnect) void connect(profile);
+ };
+
+ const setupLocal = async () => {
+ setBusy(true);
+ setOperationError(null);
+ try {
+ const profile = await adapters.localSetup.setup();
+ await saveProfile(profile);
+ } catch (error) {
+ setOperationError(error instanceof Error ? error.message : 'Local setup could not be started.');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const discover = async () => {
+ setBusy(true);
+ setOperationError(null);
+ try {
+ const discovered = await adapters.discovery.discover();
+ setProfiles(current => mergeProfiles(current, discovered));
+ if (!discovered.length) setOperationError('No new ProPR instances were found on this network.');
+ } catch (error) {
+ setOperationError(error instanceof Error ? error.message : 'Network discovery is unavailable.');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const choose = () => {
+ void adapters.profiles.setActiveId(null);
+ setManagerOpen(false);
+ setEditing(null);
+ setState({ phase: 'choose' });
+ };
+
+ const retry = () => {
+ if ('profile' in state) void connect(state.profile);
+ };
+
+ const content = () => {
+ if (state.phase === 'loading') return Opening ProPR…
;
+ if (state.phase === 'connecting') return undefined} onHelp={() => void adapters.externalBrowser.open('https://propr.dev')} />;
+ if (state.phase === 'blocked') return void adapters.authentication.authenticate(state.profile)} onHelp={() => void adapters.externalBrowser.open('https://propr.dev')} />;
+ if (editing) return setEditing(null)} onSave={profile => void saveProfile(profile)} />;
+ return void setupLocal()} onConnectNew={() => setEditing('new')} onDiscover={() => void discover()} onConnect={profile => void connect(profile)} onEdit={setEditing} onRemove={profile => void removeProfile(profile)} />;
+ };
+
+ if (state.phase !== 'connected') {
+ return {content()}
;
+ }
+
+ const displayedConnection: DesktopConnectionResult = networkOffline
+ ? { status: 'offline', message: 'This computer is offline.' }
+ : state.result;
+ const contextValue = {
+ isDesktop: true as const,
+ platform: adapters.platform,
+ profile: state.profile,
+ connection: displayedConnection,
+ openProfileManager: () => setManagerOpen(true),
+ authenticate: () => adapters.authentication.authenticate(state.profile),
+ openConnectionHelp: () => adapters.externalBrowser.open('https://propr.dev'),
+ retry,
+ };
+
+ return (
+
+ {children}
+ {managerOpen && (
+ { if (event.target === event.currentTarget) setManagerOpen(false); }}>
+
+ Desktop
Manage instances
+ {editing ? (
+ setEditing(null)} onSave={profile => void saveProfile(profile, false)} />
+ ) : (
+ <>
+ { setManagerOpen(false); void connect(profile); }} onEdit={setEditing} onRemove={profile => void removeProfile(profile)} />
+
+ >
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/propr-ui/src/desktop/DesktopPresentationBoundary.tsx b/propr-ui/src/desktop/DesktopPresentationBoundary.tsx
new file mode 100644
index 000000000..339843ebe
--- /dev/null
+++ b/propr-ui/src/desktop/DesktopPresentationBoundary.tsx
@@ -0,0 +1,15 @@
+import React, { useState } from 'react';
+import { resolveDesktopAdapters } from './browserAdapters';
+import { DesktopExperience } from './DesktopExperience';
+
+interface DesktopPresentationBoundaryProps {
+ desktop: React.ReactNode;
+ fallback: React.ReactNode;
+}
+
+/** Keeps desktop detection at the application edge and leaves the route tree shared. */
+export const DesktopPresentationBoundary: React.FC = ({ desktop, fallback }) => {
+ const adapters = useState(resolveDesktopAdapters)[0];
+ return adapters ? {desktop} : fallback;
+};
+
diff --git a/propr-ui/src/desktop/DesktopTitleBar.tsx b/propr-ui/src/desktop/DesktopTitleBar.tsx
new file mode 100644
index 000000000..a94705464
--- /dev/null
+++ b/propr-ui/src/desktop/DesktopTitleBar.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { ChevronDown, CircleAlert, CloudOff, RefreshCw, Wifi } from 'lucide-react';
+import { useDesktop } from './DesktopContext';
+
+export const DesktopTitleBar: React.FC = () => {
+ const desktop = useDesktop();
+ if (!desktop) return null;
+
+ const connected = desktop.connection.status === 'ready';
+ const incompatible = desktop.connection.status === 'incompatible';
+ const label = connected ? 'Connected' : incompatible ? 'Update required' : 'Offline';
+
+ return (
+
+
+
ProPR
+
+
+
+
+ );
+};
diff --git a/propr-ui/src/desktop/browserAdapters.test.ts b/propr-ui/src/desktop/browserAdapters.test.ts
new file mode 100644
index 000000000..55ceda838
--- /dev/null
+++ b/propr-ui/src/desktop/browserAdapters.test.ts
@@ -0,0 +1,27 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { normalizeBaseUrl, resolveDesktopAdapters } from './browserAdapters';
+
+describe('desktop browser fixtures', () => {
+ afterEach(() => {
+ window.history.replaceState(null, '', '/');
+ delete window.__PROPR_DESKTOP__;
+ });
+
+ it('does not enable desktop presentation for the normal hosted web app', () => {
+ expect(resolveDesktopAdapters()).toBeNull();
+ });
+
+ it('explicitly enables deterministic screenshot fixtures', async () => {
+ window.history.replaceState(null, '', '/?desktop-fixture=recents');
+ const adapters = resolveDesktopAdapters();
+ expect(adapters).not.toBeNull();
+ await expect(adapters?.profiles.list()).resolves.toHaveLength(2);
+ });
+
+ it('normalizes safe instance origins and rejects non-http protocols', () => {
+ expect(normalizeBaseUrl(' https://propr.example.com/// ')).toBe('https://propr.example.com');
+ expect(() => normalizeBaseUrl('file:///tmp/propr')).toThrow(/http/);
+ expect(() => normalizeBaseUrl('https://user:secret@example.com')).toThrow(/credentials/);
+ });
+});
+
diff --git a/propr-ui/src/desktop/browserAdapters.ts b/propr-ui/src/desktop/browserAdapters.ts
new file mode 100644
index 000000000..aa6104937
--- /dev/null
+++ b/propr-ui/src/desktop/browserAdapters.ts
@@ -0,0 +1,159 @@
+import { evaluateProprApiCompatibility } from '@propr/shared';
+import type {
+ DesktopAdapters,
+ DesktopConnectionResult,
+ DesktopPlatform,
+ DesktopProfile,
+ ProprDesktopBridge,
+} from './types';
+
+const PROFILES_KEY = 'propr.desktop.profiles';
+const ACTIVE_PROFILE_KEY = 'propr.desktop.activeProfile';
+const FIXTURE_QUERY_KEY = 'desktop-fixture';
+
+type DesktopFixture = 'first-run' | 'recents' | 'offline' | 'incompatible' | 'connected';
+
+const fixtureProfile: DesktopProfile = {
+ id: 'fixture-local',
+ name: 'This computer',
+ baseUrl: 'http://127.0.0.1:3000',
+ kind: 'local',
+ lastConnectedAt: '2026-08-29T12:00:00.000Z',
+};
+
+const normalizeBaseUrl = (value: string): string => {
+ const url = new URL(value.trim());
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ throw new Error('Instance URLs must use http:// or https://.');
+ }
+ if (url.username || url.password) throw new Error('Instance URLs cannot contain credentials.');
+ url.pathname = url.pathname.replace(/\/+$/, '');
+ url.search = '';
+ url.hash = '';
+ return url.toString().replace(/\/+$/, '');
+};
+
+const readProfiles = (): DesktopProfile[] => {
+ try {
+ const value = JSON.parse(window.localStorage.getItem(PROFILES_KEY) || '[]') as unknown;
+ return Array.isArray(value) ? value.filter(isDesktopProfile) : [];
+ } catch {
+ return [];
+ }
+};
+
+const isDesktopProfile = (value: unknown): value is DesktopProfile => {
+ if (!value || typeof value !== 'object') return false;
+ const profile = value as Partial;
+ return typeof profile.id === 'string'
+ && typeof profile.name === 'string'
+ && typeof profile.baseUrl === 'string'
+ && (profile.kind === 'local' || profile.kind === 'remote');
+};
+
+const saveProfiles = (profiles: DesktopProfile[]): void => {
+ window.localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles));
+};
+
+const detectPlatform = (): DesktopPlatform => {
+ const platform = navigator.platform.toLowerCase();
+ if (platform.includes('mac')) return 'macos';
+ if (platform.includes('win')) return 'windows';
+ return 'linux';
+};
+
+const fixtureFromLocation = (): DesktopFixture | null => {
+ const fixture = new URLSearchParams(window.location.search).get(FIXTURE_QUERY_KEY);
+ return fixture === 'first-run' || fixture === 'recents' || fixture === 'offline'
+ || fixture === 'incompatible' || fixture === 'connected'
+ ? fixture
+ : null;
+};
+
+const probeProfile = async (profile: DesktopProfile): Promise => {
+ try {
+ const response = await fetch(`${normalizeBaseUrl(profile.baseUrl)}/api/compatibility`, {
+ credentials: 'include',
+ cache: 'no-store',
+ signal: AbortSignal.timeout(8_000),
+ });
+ if (response.status === 401 || response.status === 403) {
+ return { status: 'authentication-required', message: 'Sign in to continue to this instance.' };
+ }
+ if (response.status === 404) return { status: 'ready' };
+ if (!response.ok) return { status: 'offline', message: `The instance returned HTTP ${response.status}.` };
+ const metadata = await response.json() as { apiCompatibility?: string; version?: string };
+ const compatibility = evaluateProprApiCompatibility(metadata);
+ if (compatibility.compatible || compatibility.reason === 'missing') {
+ return { status: 'ready', version: compatibility.apiVersion ?? undefined };
+ }
+ return {
+ status: 'incompatible',
+ message: compatibility.message,
+ version: compatibility.apiVersion ?? undefined,
+ };
+ } catch {
+ return { status: 'offline', message: 'ProPR could not reach this instance. Check that it is running and try again.' };
+ }
+};
+
+const createBrowserAdapters = (fixture: DesktopFixture | null): DesktopAdapters => ({
+ platform: detectPlatform(),
+ profiles: {
+ async list() {
+ if (fixture === 'first-run') return [];
+ if (fixture) return [fixtureProfile, { ...fixtureProfile, id: 'fixture-team', name: 'Team server', baseUrl: 'https://propr.example.test', kind: 'remote' }];
+ return readProfiles();
+ },
+ async save(profile) {
+ const normalized = { ...profile, baseUrl: normalizeBaseUrl(profile.baseUrl) };
+ saveProfiles([...readProfiles().filter(item => item.id !== profile.id), normalized]);
+ },
+ async remove(profileId) {
+ saveProfiles(readProfiles().filter(profile => profile.id !== profileId));
+ if (window.localStorage.getItem(ACTIVE_PROFILE_KEY) === profileId) {
+ window.localStorage.removeItem(ACTIVE_PROFILE_KEY);
+ }
+ },
+ async getActiveId() {
+ if (fixture === 'connected') return fixtureProfile.id;
+ return fixture ? null : window.localStorage.getItem(ACTIVE_PROFILE_KEY);
+ },
+ async setActiveId(profileId) {
+ if (profileId) window.localStorage.setItem(ACTIVE_PROFILE_KEY, profileId);
+ else window.localStorage.removeItem(ACTIVE_PROFILE_KEY);
+ },
+ },
+ discovery: { async discover() { return fixture ? [fixtureProfile] : []; } },
+ externalBrowser: { async open(url) { window.open(url, '_blank', 'noopener,noreferrer'); } },
+ authentication: {
+ async authenticate(profile) {
+ const redirect = encodeURIComponent('propr://authentication-complete');
+ window.open(`${normalizeBaseUrl(profile.baseUrl)}/api/auth/github?redirect_to=${redirect}`, '_blank', 'noopener,noreferrer');
+ },
+ },
+ localSetup: {
+ async setup() {
+ if (fixture) return fixtureProfile;
+ throw new Error('Local setup will be available when the desktop host adapter is connected.');
+ },
+ },
+ connection: {
+ async probe(profile) {
+ if (fixture === 'offline') return { status: 'offline', message: 'The instance is offline. Start it and try again.' };
+ if (fixture === 'incompatible') return { status: 'incompatible', message: 'This instance requires a newer version of ProPR Desktop.', version: '0.7.0' };
+ if (fixture) return { status: 'ready', version: '0.8.15' };
+ return probeProfile(profile);
+ },
+ },
+});
+
+export const resolveDesktopAdapters = (): DesktopAdapters | null => {
+ const bridge: ProprDesktopBridge | undefined = window.__PROPR_DESKTOP__;
+ if (bridge?.isDesktop) return bridge;
+ const fixture = fixtureFromLocation();
+ return fixture ? createBrowserAdapters(fixture) : null;
+};
+
+export { normalizeBaseUrl };
+
diff --git a/propr-ui/src/desktop/desktop.css b/propr-ui/src/desktop/desktop.css
new file mode 100644
index 000000000..273898b67
--- /dev/null
+++ b/propr-ui/src/desktop/desktop.css
@@ -0,0 +1,253 @@
+:root {
+ --desktop-titlebar-height: 2.75rem;
+ --desktop-focus: #0f766e;
+}
+
+.desktop-entry {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ overflow: auto;
+ padding: max(2.5rem, env(safe-area-inset-top)) 1.5rem 2.5rem;
+ color: #17212b;
+ background:
+ radial-gradient(circle at 10% 0%, rgba(36, 163, 163, 0.16), transparent 35rem),
+ radial-gradient(circle at 100% 100%, rgba(15, 118, 110, 0.10), transparent 32rem),
+ #f4f7f7;
+}
+
+.desktop-app {
+ height: 100vh;
+ overflow: hidden;
+ background: #f8fafc;
+}
+
+.desktop-app > .desktop-shell {
+ height: 100%;
+}
+
+.desktop-brand {
+ display: flex;
+ align-items: center;
+ gap: .65rem;
+ font-size: 1.15rem;
+ font-weight: 750;
+ letter-spacing: -.02em;
+}
+
+.desktop-brand img {
+ width: 2rem;
+ height: 2rem;
+ border-radius: .55rem;
+}
+
+.desktop-welcome-card,
+.desktop-connection-card {
+ width: min(100%, 38rem);
+ border: 1px solid #dce5e5;
+ border-radius: 1.25rem;
+ background: rgba(255, 255, 255, .96);
+ box-shadow: 0 24px 70px rgba(25, 48, 48, .12), 0 2px 8px rgba(25, 48, 48, .05);
+ padding: 2rem;
+}
+
+.desktop-welcome-copy {
+ padding: 2.4rem 0 1.75rem;
+}
+
+.desktop-eyebrow {
+ display: block;
+ color: #0f766e;
+ font-size: .7rem;
+ font-weight: 750;
+ letter-spacing: .12em;
+ text-transform: uppercase;
+}
+
+.desktop-welcome-copy h1,
+.desktop-connection-card h1,
+.desktop-profile-form h2,
+.desktop-profile-manager h2 {
+ margin: .35rem 0 .5rem;
+ color: #132525;
+ font-weight: 720;
+ letter-spacing: -.035em;
+}
+
+.desktop-welcome-copy h1,
+.desktop-connection-card h1 { font-size: 1.85rem; line-height: 1.15; }
+.desktop-profile-form h2,
+.desktop-profile-manager h2 { font-size: 1.35rem; }
+
+.desktop-welcome-copy p,
+.desktop-connection-card p,
+.desktop-profile-form > p {
+ color: #5e6d6d;
+ line-height: 1.55;
+ font-size: .925rem;
+}
+
+.desktop-setup-actions { display: grid; gap: .7rem; }
+
+.desktop-choice-button {
+ display: grid;
+ grid-template-columns: 2.7rem 1fr auto;
+ align-items: center;
+ gap: .85rem;
+ width: 100%;
+ padding: .85rem;
+ border: 1px solid #dbe4e4;
+ border-radius: .8rem;
+ color: #243737;
+ text-align: left;
+ background: white;
+ transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
+}
+
+.desktop-choice-button:hover:not(:disabled) { border-color: #83baba; box-shadow: 0 6px 20px rgba(28, 91, 91, .08); transform: translateY(-1px); }
+.desktop-choice-button > span:first-child { display: grid; place-items: center; width: 2.7rem; height: 2.7rem; border-radius: .65rem; background: #eef5f4; color: #167575; }
+.desktop-choice-button svg { width: 1.2rem; height: 1.2rem; }
+.desktop-choice-button strong,
+.desktop-choice-button small { display: block; }
+.desktop-choice-button strong { font-size: .9rem; }
+.desktop-choice-button small { margin-top: .18rem; color: #728080; font-size: .75rem; }
+.desktop-choice-primary { border-color: #a8d2cf; background: #f7fbfa; }
+
+.desktop-recents { margin-top: 1.65rem; }
+.desktop-recents h2 { margin-bottom: .55rem; color: #657474; font-size: .72rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
+.desktop-profile-list { display: grid; gap: .4rem; }
+.desktop-profile-row { display: flex; align-items: stretch; min-width: 0; border: 1px solid #e1e8e8; border-radius: .7rem; background: #fff; overflow: hidden; }
+.desktop-profile-row:hover { border-color: #bad1d0; }
+.desktop-profile-connect { display: grid; grid-template-columns: 2rem minmax(0, 1fr) auto; align-items: center; gap: .7rem; min-width: 0; flex: 1; padding: .65rem .7rem; text-align: left; }
+.desktop-profile-connect strong,
+.desktop-profile-connect small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.desktop-profile-connect strong { color: #273939; font-size: .84rem; }
+.desktop-profile-connect small { margin-top: .12rem; color: #778686; font-size: .7rem; }
+.desktop-profile-icon { display: grid; place-items: center; width: 2rem; height: 2rem; border-radius: .5rem; background: #f0f5f5; color: #377b78; }
+.desktop-profile-icon svg,
+.desktop-profile-chevron { width: 1rem; height: 1rem; }
+.desktop-profile-chevron { color: #92a0a0; }
+
+.desktop-icon-button { display: grid; place-items: center; width: 2.4rem; min-width: 2.4rem; color: #6b7b7b; }
+.desktop-icon-button:hover { color: #0f766e; background: #f2f7f7; }
+.desktop-icon-button svg { width: 1rem; height: 1rem; }
+.desktop-danger-button:hover { color: #b42318; background: #fff4f2; }
+
+.desktop-discover-button,
+.desktop-back-button,
+.desktop-link-button {
+ display: inline-flex;
+ align-items: center;
+ gap: .4rem;
+ color: #47706f;
+ font-size: .78rem;
+ font-weight: 600;
+}
+
+.desktop-discover-button { margin: 1rem auto 0; width: 100%; justify-content: center; padding: .4rem; }
+.desktop-discover-button:hover,
+.desktop-back-button:hover,
+.desktop-link-button:hover { color: #0f766e; text-decoration: underline; }
+.desktop-discover-button svg,
+.desktop-back-button svg { width: .9rem; height: .9rem; }
+
+.desktop-profile-form { padding-top: 2rem; }
+.desktop-profile-form > p { margin-bottom: 1.25rem; }
+.desktop-profile-form label { display: grid; gap: .4rem; margin-top: .8rem; color: #435555; font-size: .76rem; font-weight: 650; }
+.desktop-profile-form input { width: 100%; border: 1px solid #cdd9d9; border-radius: .55rem; padding: .68rem .75rem; color: #192c2c; font-size: .86rem; font-weight: 450; outline: none; }
+.desktop-profile-form input:focus { border-color: #16827c; box-shadow: 0 0 0 3px rgba(22, 130, 124, .15); }
+
+.desktop-primary-button,
+.desktop-secondary-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: .45rem;
+ border-radius: .55rem;
+ padding: .65rem 1rem;
+ font-size: .82rem;
+ font-weight: 700;
+}
+.desktop-profile-form .desktop-primary-button { width: 100%; margin-top: 1.25rem; }
+.desktop-primary-button { color: white; background: #147b76; }
+.desktop-primary-button:hover { background: #0f6864; }
+.desktop-secondary-button { border: 1px solid #ccdada; color: #345554; background: white; }
+.desktop-secondary-button:hover { border-color: #86b3b0; background: #f7fbfb; }
+.desktop-primary-button svg,
+.desktop-secondary-button svg { width: .95rem; height: .95rem; }
+.desktop-inline-error { margin-top: .8rem; border: 1px solid #fed0ca; border-radius: .55rem; padding: .65rem .75rem; color: #9f2d20; background: #fff6f4; font-size: .76rem; line-height: 1.45; }
+
+.desktop-connection-card { text-align: center; }
+.desktop-connection-card .desktop-brand { justify-content: center; }
+.desktop-connection-visual { display: grid; place-items: center; width: 4.25rem; height: 4.25rem; margin: 2.7rem auto 1.25rem; border-radius: 1.2rem; color: #a14336; background: #fff0ed; }
+.desktop-connection-visual svg { width: 1.8rem; height: 1.8rem; }
+.desktop-connecting { color: #147b76; background: #edf8f7; }
+.desktop-connecting svg { animation: desktop-spin 1s linear infinite; }
+.desktop-version-note { margin: 1.2rem auto; border-radius: .5rem; padding: .55rem; color: #695f46; background: #faf6e8; font-size: .75rem; }
+.desktop-connection-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: .6rem; margin-top: 1.5rem; }
+.desktop-connection-actions .desktop-link-button { flex-basis: 100%; justify-content: center; margin-top: .35rem; }
+
+.desktop-loading { display: flex; align-items: center; gap: .65rem; color: #536969; font-size: .85rem; }
+.desktop-loading svg { width: 1.2rem; height: 1.2rem; }
+.desktop-spin { animation: desktop-spin 1s linear infinite; }
+@keyframes desktop-spin { to { transform: rotate(360deg); } }
+
+.desktop-titlebar {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: var(--desktop-titlebar-height);
+ min-height: var(--desktop-titlebar-height);
+ border-bottom: 1px solid #dbe4e4;
+ color: #526565;
+ background: rgba(247, 250, 250, .94);
+ user-select: none;
+ z-index: 60;
+}
+.desktop-titlebar-drag { position: absolute; inset: 0; -webkit-app-region: drag; }
+.desktop-window-title { position: relative; font-size: .72rem; font-weight: 700; pointer-events: none; }
+.desktop-titlebar-actions { position: absolute; right: .7rem; display: flex; align-items: center; -webkit-app-region: no-drag; }
+.desktop-platform-macos .desktop-titlebar-actions { right: .75rem; }
+.desktop-platform-macos .desktop-window-title { padding-left: 4.5rem; }
+.desktop-connection-pill { position: relative; display: flex; align-items: center; gap: .4rem; max-width: 15rem; border: 1px solid #d4dfdf; border-radius: 999px; padding: .27rem .55rem; color: #536666; background: rgba(255,255,255,.85); font-size: .68rem; font-weight: 650; }
+.desktop-connection-pill:hover { border-color: #a5c5c3; background: #fff; }
+.desktop-connection-pill > svg { width: .78rem; height: .78rem; }
+.desktop-connection-pill > span:not(.desktop-connection-dot) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.desktop-connection-dot { width: .42rem; height: .42rem; border-radius: 50%; background: #24a36f; box-shadow: 0 0 0 2px rgba(36,163,111,.12); }
+.desktop-connection-offline .desktop-connection-dot { background: #d47b36; }
+.desktop-connection-incompatible .desktop-connection-dot { background: #c4483b; }
+.desktop-pill-retry { margin-left: .1rem; }
+
+.desktop-modal-backdrop { position: fixed; inset: 0; display: grid; place-items: center; padding: 1.5rem; background: rgba(18, 34, 34, .35); backdrop-filter: blur(2px); z-index: 100; }
+.desktop-profile-manager { width: min(100%, 32rem); max-height: min(42rem, calc(100vh - 3rem)); overflow-y: auto; border: 1px solid #d8e2e2; border-radius: 1rem; padding: 1.35rem; background: white; box-shadow: 0 30px 80px rgba(17, 34, 34, .25); }
+.desktop-profile-manager > header { display: flex; align-items: flex-start; justify-content: space-between; border-bottom: 1px solid #e7eeee; padding-bottom: .85rem; margin-bottom: 1rem; }
+.desktop-profile-manager .desktop-recents { margin-top: 0; }
+.desktop-add-instance { width: 100%; margin-top: .8rem; }
+
+.desktop-app .desktop-shell-content > aside { box-shadow: none; background: #fbfdfd; }
+.desktop-app .desktop-shell-content > aside nav a { border-right-width: 0; border-left: 2px solid transparent; }
+.desktop-app .desktop-shell-content > aside nav a.bg-red-50 { border-left-color: #1d8a8a; background: #edf7f6; }
+.desktop-app .desktop-shell-content header { box-shadow: none; }
+
+button:focus-visible,
+a:focus-visible,
+input:focus-visible {
+ outline: 2px solid var(--desktop-focus);
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .desktop-choice-button { transition: none; }
+ .desktop-choice-button:hover:not(:disabled) { transform: none; }
+ .desktop-spin,
+ .desktop-connecting svg { animation-duration: 2s; }
+}
+
+@media (max-width: 640px) {
+ .desktop-entry { align-items: start; padding: 1rem; }
+ .desktop-welcome-card,
+ .desktop-connection-card { border-radius: .9rem; padding: 1.25rem; }
+ .desktop-welcome-copy { padding: 1.8rem 0 1.25rem; }
+}
+
diff --git a/propr-ui/src/desktop/types.ts b/propr-ui/src/desktop/types.ts
new file mode 100644
index 000000000..c65687110
--- /dev/null
+++ b/propr-ui/src/desktop/types.ts
@@ -0,0 +1,68 @@
+export type DesktopPlatform = 'macos' | 'windows' | 'linux';
+
+export interface DesktopProfile {
+ id: string;
+ name: string;
+ baseUrl: string;
+ kind: 'local' | 'remote';
+ lastConnectedAt?: string;
+}
+
+export type DesktopConnectionResult =
+ | { status: 'ready'; version?: string }
+ | { status: 'authentication-required'; message?: string }
+ | { status: 'incompatible'; message: string; version?: string }
+ | { status: 'offline'; message: string };
+
+export interface DesktopProfileAdapter {
+ list(): Promise;
+ save(profile: DesktopProfile): Promise;
+ remove(profileId: string): Promise;
+ getActiveId(): Promise;
+ setActiveId(profileId: string | null): Promise;
+}
+
+export interface DesktopDiscoveryAdapter {
+ discover(): Promise;
+}
+
+export interface DesktopAuthenticationAdapter {
+ authenticate(profile: DesktopProfile): Promise;
+}
+
+export interface DesktopExternalBrowserAdapter {
+ open(url: string): Promise;
+}
+
+export interface DesktopLocalSetupAdapter {
+ setup(): Promise;
+}
+
+export interface DesktopConnectionAdapter {
+ probe(profile: DesktopProfile): Promise;
+}
+
+export interface DesktopAdapters {
+ platform: DesktopPlatform;
+ profiles: DesktopProfileAdapter;
+ discovery: DesktopDiscoveryAdapter;
+ authentication: DesktopAuthenticationAdapter;
+ externalBrowser: DesktopExternalBrowserAdapter;
+ localSetup: DesktopLocalSetupAdapter;
+ connection: DesktopConnectionAdapter;
+}
+
+/**
+ * Small preload-facing contract. Electron can expose this object through
+ * contextBridge without exposing Node or command execution to React.
+ */
+export interface ProprDesktopBridge extends DesktopAdapters {
+ isDesktop: true;
+}
+
+declare global {
+ interface Window {
+ __PROPR_DESKTOP__?: ProprDesktopBridge;
+ }
+}
+
diff --git a/propr-ui/src/pages/LoginPage.tsx b/propr-ui/src/pages/LoginPage.tsx
index e587704b2..432fc7ad6 100644
--- a/propr-ui/src/pages/LoginPage.tsx
+++ b/propr-ui/src/pages/LoginPage.tsx
@@ -9,11 +9,11 @@ import {
pathWithActiveHostedTunnelFlow,
} from '../config/runtimeConfig';
import { isProprProxyUrl } from '@propr/shared';
+import { useDesktop } from '../desktop/DesktopContext';
-const API_BASE_URL = getApiBaseUrl();
// For OAuth, use main API to avoid registering multiple callback URLs
// Falls back to API_BASE_URL for main site
-const OAUTH_API_URL = import.meta.env.VITE_OAUTH_API_URL || API_BASE_URL;
+const getOAuthApiUrl = (): string => import.meta.env.VITE_OAUTH_API_URL || getApiBaseUrl();
const HOSTED_OAUTH_COMPLETION_PATH = '/login?oauth_complete=true';
const HOSTED_OAUTH_POLL_INTERVAL_MS = 1_000;
const HOSTED_OAUTH_POPUP_CHECK_INTERVAL_MS = 500;
@@ -84,7 +84,7 @@ const validateOAuthApiBaseUrl = (
throw new Error('OAuth API URL must be a bare http(s) origin.');
}
if (options.hostedPopupCompletion && isHostedUiOrigin(hostname)) {
- const activeApiBaseUrl = (options.activeApiBaseUrl ?? API_BASE_URL).trim();
+ const activeApiBaseUrl = (options.activeApiBaseUrl ?? getApiBaseUrl()).trim();
let activeApiUrl: URL;
try {
activeApiUrl = validatedHttpUrl(activeApiBaseUrl);
@@ -124,7 +124,7 @@ const resolveReturnPath = (state: unknown, redirectToParam: string | null): stri
export const buildGithubOAuthUrl = (
returnPath: string,
origin = window.location.origin,
- oauthApiUrl = OAUTH_API_URL,
+ oauthApiUrl = getOAuthApiUrl(),
hostname = window.location.hostname,
options: BuildGithubOAuthUrlOptions = {}
): string => {
@@ -163,6 +163,7 @@ const LoginPage: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const { isDemoMode, isLoading: isDemoModeLoading } = useDemoMode();
+ const desktop = useDesktop();
const loggedOut = searchParams.get('logged_out') === 'true';
const isOAuthCompletion = searchParams.get('oauth_complete') === 'true';
const hostedOAuthFlowRef = useRef(null);
@@ -314,6 +315,13 @@ const LoginPage: React.FC = () => {
}, [failHostedOAuthFlow, navigate, returnPathWithActiveFlow, stopHostedOAuthFlow]);
const handleLogin = useCallback(() => {
+ if (desktop) {
+ setHostedOAuthError(null);
+ void desktop.authenticate().catch(error => {
+ setHostedOAuthError(error instanceof Error ? error.message : 'GitHub sign-in could not be opened.');
+ });
+ return;
+ }
// Local/self-hosted OAuth keeps using redirect_to for the final same-tab
// navigation back to the page the user came from.
// Hosted OAuth completes in a popup and the initiating tab polls its own
@@ -325,7 +333,7 @@ const LoginPage: React.FC = () => {
oauthUrl = buildGithubOAuthUrl(
returnPath,
window.location.origin,
- OAUTH_API_URL,
+ getOAuthApiUrl(),
window.location.hostname,
{ hostedPopupCompletion: hostedLogin }
);
@@ -342,7 +350,7 @@ const LoginPage: React.FC = () => {
return;
}
window.location.href = oauthUrl;
- }, [returnPath, startHostedOAuthFlow]);
+ }, [desktop, returnPath, startHostedOAuthFlow]);
if (isRecovering) {
return (