From c5b383c6a2713945e37c0bae1c88c45826dff30f Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:24:40 +0000 Subject: [PATCH] fix(ai): Resolve issue #1959 - Add the desktop-shaped UI mode and instance connec Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- propr-ui/README.md | 20 + propr-ui/src/App.tsx | 5 +- propr-ui/src/api/apiClient.ts | 6 +- propr-ui/src/api/compatibility.ts | 4 +- propr-ui/src/components/Layout.tsx | 8 +- propr-ui/src/config/runtimeConfig.ts | 14 + propr-ui/src/desktop/DesktopContext.tsx | 17 + .../src/desktop/DesktopExperience.test.tsx | 116 ++++++ propr-ui/src/desktop/DesktopExperience.tsx | 362 ++++++++++++++++++ .../desktop/DesktopPresentationBoundary.tsx | 15 + propr-ui/src/desktop/DesktopTitleBar.tsx | 34 ++ propr-ui/src/desktop/browserAdapters.test.ts | 27 ++ propr-ui/src/desktop/browserAdapters.ts | 159 ++++++++ propr-ui/src/desktop/desktop.css | 253 ++++++++++++ propr-ui/src/desktop/types.ts | 68 ++++ propr-ui/src/pages/LoginPage.tsx | 20 +- 16 files changed, 1116 insertions(+), 12 deletions(-) create mode 100644 propr-ui/src/desktop/DesktopContext.tsx create mode 100644 propr-ui/src/desktop/DesktopExperience.test.tsx create mode 100644 propr-ui/src/desktop/DesktopExperience.tsx create mode 100644 propr-ui/src/desktop/DesktopPresentationBoundary.tsx create mode 100644 propr-ui/src/desktop/DesktopTitleBar.tsx create mode 100644 propr-ui/src/desktop/browserAdapters.test.ts create mode 100644 propr-ui/src/desktop/browserAdapters.ts create mode 100644 propr-ui/src/desktop/desktop.css create mode 100644 propr-ui/src/desktop/types.ts diff --git a/propr-ui/README.md b/propr-ui/README.md index 1a0543e75..ff82e4dd7 100644 --- a/propr-ui/README.md +++ b/propr-ui/README.md @@ -51,6 +51,26 @@ npm run dev The application will be available at `http://localhost:5173` +### Desktop presentation fixtures + +Desktop mode is enabled explicitly by the typed `window.__PROPR_DESKTOP__` +preload bridge. The normal hosted and self-hosted web UI never relies on user +agent detection and continues to use the standard presentation. + +For browser-based development and deterministic screenshots, open one of these +fixture URLs after starting Vite: + +- `/?desktop-fixture=first-run` +- `/?desktop-fixture=recents` +- `/?desktop-fixture=offline` +- `/?desktop-fixture=incompatible` +- `/?desktop-fixture=connected` + +The preload-facing adapter contract lives in `src/desktop/types.ts`. Browser +fixtures implement the same profile persistence, discovery, authentication, +external-browser, local-setup, and connection interfaces without exposing host +commands to React. + ### Building for Production ```bash diff --git a/propr-ui/src/App.tsx b/propr-ui/src/App.tsx index 76cebf0f3..50389b925 100644 --- a/propr-ui/src/App.tsx +++ b/propr-ui/src/App.tsx @@ -21,6 +21,7 @@ import RouteChunkErrorBoundary from './components/RouteChunkErrorBoundary' import { ConnectAccountProvider } from './contexts/ConnectAccountContext' import { BrowserPushProvider } from './hooks/useBrowserPush' import { NotificationCenterProvider } from './contexts/NotificationCenterContext' +import { DesktopPresentationBoundary } from './desktop/DesktopPresentationBoundary' const AiAgentsPage = lazy(() => import('./pages/AiAgentsPage')) const AccessManagementPage = lazy(() => import('./pages/AccessManagementPage')) @@ -360,7 +361,7 @@ const AppContent: React.FC = () => { ); }; -const App: React.FC = () => { +const WebApp: React.FC = () => { // The compatibility gate only applies to the hosted UI — a single static bundle // serving many per-instance proxies, where the UI and API are versioned // independently. On a local/self-hosted origin the UI and API ship together, so @@ -452,4 +453,6 @@ const App: React.FC = () => { ) } +const App: React.FC = () => } desktop={} />; + export default App diff --git a/propr-ui/src/api/apiClient.ts b/propr-ui/src/api/apiClient.ts index f76d203b8..0a0a12b73 100644 --- a/propr-ui/src/api/apiClient.ts +++ b/propr-ui/src/api/apiClient.ts @@ -1,7 +1,11 @@ import { DEMO_MODE_READ_ONLY_CODE } from '@propr/shared'; import { getApiBaseUrl, pathWithActiveHostedTunnelFlow } from '../config/runtimeConfig'; -export const API_BASE_URL = getApiBaseUrl(); +export let API_BASE_URL = getApiBaseUrl(); +/** Update the live binding used by existing API modules when desktop profiles switch. */ +export const setApiBaseUrl = (value: string): void => { + API_BASE_URL = value.trim().replace(/\/+$/, ''); +}; export const INSTANCE_AUTHORIZATION_CHANGED_EVENT = 'propr:instance-authorization-changed'; const TOKEN_REFRESHED_CODE = 'TOKEN_REFRESHED'; const SAFE_PUBLIC_ERROR_CODES = new Set(['AGENT_VERSION_LOOKUP_UNAVAILABLE']); diff --git a/propr-ui/src/api/compatibility.ts b/propr-ui/src/api/compatibility.ts index 98a0a791c..14fde545f 100644 --- a/propr-ui/src/api/compatibility.ts +++ b/propr-ui/src/api/compatibility.ts @@ -5,8 +5,6 @@ import { } from '@propr/shared'; import { getApiBaseUrl } from '../config/runtimeConfig'; -const API_BASE_URL = getApiBaseUrl(); - // Bound the pre-render compatibility probe so a slow/unreachable API can't trap // the user on a spinner waiting out the browser's default fetch timeout. On // timeout we throw a check error, which App treats as transient and renders the @@ -25,7 +23,7 @@ export async function checkProprApiCompatibility(): Promise controller.abort(), COMPATIBILITY_CHECK_TIMEOUT_MS); try { - response = await fetch(`${API_BASE_URL}/api/compatibility`, { + response = await fetch(`${getApiBaseUrl()}/api/compatibility`, { credentials: 'include', cache: 'no-store', signal: controller.signal, diff --git a/propr-ui/src/components/Layout.tsx b/propr-ui/src/components/Layout.tsx index be7d98f19..ce4736071 100644 --- a/propr-ui/src/components/Layout.tsx +++ b/propr-ui/src/components/Layout.tsx @@ -14,6 +14,8 @@ import { QueueStatsUpdatePayload, IndexingUpdatePayload, DraftUpdatePayload } fr import { useCurrentUser, userHasPermission } from '../contexts/AuthContext'; import { ConnectCapacityBanner } from './ConnectPlusBanner'; import { useNotificationCenter } from '../contexts/NotificationCenterContext'; +import { DesktopTitleBar } from '../desktop/DesktopTitleBar'; +import { useDesktop } from '../desktop/DesktopContext'; interface LayoutProps { children: React.ReactNode; @@ -35,6 +37,7 @@ const Layout: React.FC = ({ children }) => { const user = useCurrentUser(); const { unreadCount } = useNotificationCenter(); const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const desktop = useDesktop(); // Track repository indexing statuses for toast notifications const repoStatusesRef = useRef>(new Map()); @@ -164,7 +167,9 @@ const Layout: React.FC = ({ children }) => { }; return ( -
+
+ {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 ( +
+ +

{initial ? 'Edit instance' : 'Connect to an instance'}

+

Enter the address shown by your ProPR server.

+ + + {error && } + +
+ ); +}; + +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 ( +
+ + ); +}; 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 (