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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions propr-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion propr-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -452,4 +453,6 @@ const App: React.FC = () => {
)
}

const App: React.FC = () => <DesktopPresentationBoundary fallback={<WebApp />} desktop={<DemoModeProvider><AppContent /></DemoModeProvider>} />;

export default App
6 changes: 5 additions & 1 deletion propr-ui/src/api/apiClient.ts
Original file line number Diff line number Diff line change
@@ -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']);
Expand Down
4 changes: 1 addition & 3 deletions propr-ui/src/api/compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,7 +23,7 @@ export async function checkProprApiCompatibility(): Promise<ProprApiCompatibilit
const controller = new AbortController();
const timeout = setTimeout(() => 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,
Expand Down
8 changes: 7 additions & 1 deletion propr-ui/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,6 +37,7 @@ const Layout: React.FC<LayoutProps> = ({ 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<Map<string, string>>(new Map());

Expand Down Expand Up @@ -164,7 +167,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
};

return (
<div className="flex h-full overflow-hidden bg-light-100 relative">
<div className="desktop-shell flex h-full min-h-0 flex-col overflow-hidden bg-light-100 relative">
{desktop && <DesktopTitleBar />}
<div className="desktop-shell-content relative flex min-h-0 flex-1 overflow-hidden">
{/* Mobile Overlay */}
{isSidebarOpen && (
<div
Expand Down Expand Up @@ -269,6 +274,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
{children}
</main>
</div>
</div>
</div>
);
};
Expand Down
14 changes: 14 additions & 0 deletions propr-ui/src/config/runtimeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 : '',
Expand All @@ -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;
};
17 changes: 17 additions & 0 deletions propr-ui/src/desktop/DesktopContext.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
openConnectionHelp(): Promise<void>;
retry(): void;
}

export const DesktopContext = createContext<DesktopContextValue | null>(null);

export const useDesktop = (): DesktopContextValue | null => useContext(DesktopContext);
116 changes: 116 additions & 0 deletions propr-ui/src/desktop/DesktopExperience.test.tsx
Original file line number Diff line number Diff line change
@@ -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<DesktopConnectionResult> = 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(<DesktopExperience adapters={adapters}><div>Shared route tree</div></DesktopExperience>);

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(<DesktopExperience adapters={adapters}><div>Dashboard content</div></DesktopExperience>);

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(<DesktopExperience adapters={adapters}><div>Connected app</div></DesktopExperience>);

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(
<DesktopExperience adapters={adapters}>
<DesktopTitleBar />
</DesktopExperience>
);

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());
});
});

Loading
Loading